diff --git a/.claude/skills/ha-pr-comment-audit/SKILL.md b/.claude/skills/ha-pr-comment-audit/SKILL.md new file mode 100644 index 000000000000..d949cbbe6e74 --- /dev/null +++ b/.claude/skills/ha-pr-comment-audit/SKILL.md @@ -0,0 +1,17 @@ +--- +name: ha-pr-comment-audit +description: Audits the review comment threads on a Home Assistant GitHub pull request, flagging unaddressed comments and requests for clarification. Use when checking whether PR feedback has been handled, either standalone or as part of a full PR review. +--- + +# Check Home Assistant PR Review Comments + +## Instructions: +- Resolve the PR context first. If a PR number is not given, use 'gh pr view' to identify the current branch's PR. +- Fetch the review comment threads for the PR (e.g. 'gh api' for review threads/comments). +- Flag comments that have not been addressed. If the author has replied but has not implemented the suggestion, still flag it and summarize the reply. +- Flag comments for which the author has asked for clarification. +- Generate a summary of the flagged comments, including a link for each comment. Don't include comments that have been addressed. + + +## IMPORTANT: +- Only provide feedback in the CONSOLE. DO NOT ACT ON GITHUB. diff --git a/.claude/skills/ha-pr-reviewer/SKILL.md b/.claude/skills/ha-pr-reviewer/SKILL.md index 370e6246725a..05060b3de2f0 100644 --- a/.claude/skills/ha-pr-reviewer/SKILL.md +++ b/.claude/skills/ha-pr-reviewer/SKILL.md @@ -5,11 +5,11 @@ description: Reviews Home Assistant GitHub pull requests and provides feedback c # Review GitHub Pull Request -## Follow these steps: -1. Use 'gh pr view' to get the PR details and description. -2. Use 'gh pr diff' to see all the changes in the PR. -3. Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. -4. Check if all existing review comments have been addressed. +## Instructions: +- Use 'gh pr view' to get the PR details and description. +- Use 'gh pr diff' to see all the changes in the PR. +- Review the changes following the `ha-review` skill. It is VERY IMPORTANT to follow the `ha-review` skill instructions. Explicitly pass the PR's target/base branch to the `ha-review` skill (obtained via `gh pr view`) so it diffs against the correct base. +- Run a subagent in parallel to check the PR review comments following the `ha-pr-comment-audit` skill. ## IMPORTANT: - Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB. diff --git a/.claude/skills/ha-review/SKILL.md b/.claude/skills/ha-review/SKILL.md index f78cbe0dfd5f..12e7cb4318df 100644 --- a/.claude/skills/ha-review/SKILL.md +++ b/.claude/skills/ha-review/SKILL.md @@ -5,6 +5,9 @@ description: Reviews Home Assistant code changes and provides constructive feedb # Review Code Changes +## Scope: +- Unless instructed otherwise, review the full branch changes against the target branch. Resolve the base to an available ref (prefer `upstream/`, then `origin/`, then local ``) and review `git diff "$(git merge-base "$BASE_REF" HEAD)"..HEAD`; use `dev` as the default base. + ## Analyze the code changes for: - Code quality and style consistency - Potential bugs or issues diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 2783972953b3..000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,49 +0,0 @@ - -## The problem - - - -## Environment - - -- Home Assistant Core release with the issue: -- Last working Home Assistant Core release (if known): -- Operating environment (OS/Container/Supervised/Core): -- Integration causing this issue: -- Link to integration documentation on our website: - -## Problem-relevant `configuration.yaml` - - -```yaml - -``` - -## Traceback/Error logs - - -```txt - -``` - -## Additional information - diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png index abbc0f76ff02..47357c6581cc 100644 Binary files a/.github/assets/screenshot-integrations.png and b/.github/assets/screenshot-integrations.png differ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 30e3c670c6ae..5fa2da4a257b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,6 +8,131 @@ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. +- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments. Even unchecked checkboxes or empty sections must be present. This is a hard requirement. + +## Pull Request template + +The PR description must follow this template (from `.github/PULL_REQUEST_TEMPLATE.md`): + +```markdown + +## Breaking change + + + +## Proposed change + + + +## Type of change + + +- [ ] Dependency upgrade +- [ ] Bugfix (non-breaking change which fixes an issue) +- [ ] New integration (thank you!) +- [ ] New feature (which adds functionality to an existing integration) +- [ ] Deprecation (breaking change to happen in the future) +- [ ] Breaking change (fix/feature causing existing functionality to break) +- [ ] Code quality improvements to existing code or addition of tests + +## Additional information + + +- This PR fixes or closes issue: fixes # +- This PR is related to issue: +- Link to documentation pull request: +- Link to developer documentation pull request: +- Link to frontend pull request: + +## Checklist + + +- [ ] 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. +- [ ] I have followed the [development checklist][dev-checklist] +- [ ] 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: + +- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository] + +If the code communicates with devices, web services, or third-party tools: + +- [ ] The [manifest file][manifest-docs] has all fields filled out correctly. + Updated and included derived files by running: `python3 -m script.hassfest`. +- [ ] New or updated dependencies have been added to `requirements_all.txt`. + Updated by running `python3 -m script.gen_requirements_all`. +- [ ] For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description. + + + +To help with the load of incoming pull requests: + +- [ ] I have reviewed two other [open pull requests][prs] in this repository. + +[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone+-status%3Afailure + + +[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/ +[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/ +[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/ +[docs-repository]: https://github.com/home-assistant/home-assistant.io +[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr +``` # GitHub Copilot & Claude Code Instructions diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ab765750ccdd..51f5a5f72da4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -342,13 +342,13 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -378,7 +378,7 @@ jobs: # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev - name: Generate Docker metadata id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ matrix.registry }}/home-assistant sep-tags: "," @@ -392,7 +392,7 @@ jobs: type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v3.7.1 - name: Copy architecture images to DockerHub if: matrix.registry == 'docker.io/homeassistant' @@ -521,14 +521,14 @@ jobs: persist-credentials: false - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -541,7 +541,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e74f3e694ce3..e026da1a34b7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -116,7 +116,7 @@ jobs: # of a new uv cache entry after a version bump. echo "key=venv-${CACHE_VERSION}-${HA_SHORT_VERSION}-${HASH_REQUIREMENTS_TEST}-${HASH_REQUIREMENTS}-${HASH_REQUIREMENTS_ALL}-${HASH_PACKAGE_CONSTRAINTS}-${HASH_GEN_REQUIREMENTS}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: core with: filters: .core_files.yaml @@ -131,7 +131,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: integrations with: filters: .integration_paths.yaml @@ -281,7 +281,7 @@ jobs: echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" echo "::add-matcher::.github/workflows/matchers/codespell.json" - name: Run prek - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 + uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5 env: PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor RUFF_OUTPUT_FORMAT: github @@ -302,7 +302,7 @@ jobs: with: persist-credentials: false - name: Run zizmor - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 + uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5 with: extra-args: --all-files zizmor diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 31ef4c06f05b..4e1287cc395d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:python" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 000000000000..97fd2dfc6fdf --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,95 @@ +name: E2E tests + +# yamllint disable-line rule:truthy +on: + workflow_dispatch: + inputs: + version: + description: "Image tag or digest to test (e.g. dev, 2026.7.1, 2026.8.0b0, sha256:0a1b2c3d…)" + default: "dev" + required: true + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version }} + cancel-in-progress: true + +jobs: + boot_check: + name: Boot check ${{ matrix.arch }} core image + if: github.repository_owner == 'home-assistant' + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runs-on: ubuntu-24.04 + - arch: aarch64 + runs-on: ubuntu-24.04-arm + env: + BASE_URL: http://localhost:8123 + services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images] + ports: + - 8123:8123 + # Gate steps until Home Assistant answers (60 x 5s ≈ 300s startup budget) + options: >- + --health-cmd="curl --fail --silent --max-time 10 --output /dev/null http://127.0.0.1:8123/" + --health-start-period=10s + --health-interval=5s + --health-retries=60 + steps: + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: tests/e2e/package.json + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: tests/e2e/pnpm-lock.yaml + + - name: Install E2E test dependencies + working-directory: tests/e2e + run: pnpm install --frozen-lockfile + + - name: Install Playwright browser + working-directory: tests/e2e + run: pnpm exec playwright install --with-deps chromium + + - name: Run Playwright E2E tests + working-directory: tests/e2e + run: pnpm exec playwright test + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-${{ matrix.arch }} + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + + - name: Dump container logs + if: always() + env: + CONTAINER: ${{ job.services.homeassistant.id }} + run: docker logs "$CONTAINER" > homeassistant.log 2>&1 || true + + - name: Upload container logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: container-logs-${{ matrix.arch }} + path: homeassistant.log diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 06f1638125f6..91798343783d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: # - Issues # - No issues marked as no-stale or help-wanted - name: 60 days stale PRs policy and 90 days stale issue policy - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 + uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 with: repo-token: ${{ steps.token.outputs.token }} remove-stale-when-updated: true diff --git a/.gitignore b/.gitignore index 9d8cbaf15e09..5fb2ad904d14 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,7 @@ pytest_buckets.txt .claude/worktrees/ .serena/ +# Playwright e2e tests +tests/e2e/node_modules/ +tests/e2e/playwright-report/ +tests/e2e/test-results/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 191130dd5c05..bd15be304e0b 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.15.20 + rev: v0.15.21 hooks: - id: ruff-check args: diff --git a/.prettierignore b/.prettierignore index c63290996661..54c2d65e4d62 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ homeassistant/generated/* tests/components/lidarr/fixtures/initialize.js tests/components/lidarr/fixtures/initialize-wrong.js tests/fixtures/core/config/yaml_errors/ +tests/e2e/pnpm-lock.yaml diff --git a/.strict-typing b/.strict-typing index 66075b93743b..e7e3bd1c8870 100644 --- a/.strict-typing +++ b/.strict-typing @@ -228,6 +228,7 @@ homeassistant.components.fujitsu_fglair.* homeassistant.components.fully_kiosk.* homeassistant.components.fumis.* homeassistant.components.fyta.* +homeassistant.components.gatus.* homeassistant.components.generic_hygrostat.* homeassistant.components.generic_thermostat.* homeassistant.components.geo_location.* @@ -242,6 +243,7 @@ homeassistant.components.google.* homeassistant.components.google_assistant_sdk.* homeassistant.components.google_cloud.* homeassistant.components.google_drive.* +homeassistant.components.google_health.* homeassistant.components.google_photos.* homeassistant.components.google_sheets.* homeassistant.components.google_weather.* @@ -254,6 +256,7 @@ homeassistant.components.guntamatic.* homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* +homeassistant.components.harman_luxury.* homeassistant.components.hdfury.* homeassistant.components.heos.* homeassistant.components.here_travel_time.* @@ -335,6 +338,7 @@ homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.led_ble.* +homeassistant.components.led_infrared.* homeassistant.components.lektrico.* homeassistant.components.letpot.* homeassistant.components.lg_infrared.* @@ -346,7 +350,9 @@ homeassistant.components.lifx.* homeassistant.components.light.* homeassistant.components.linkplay.* homeassistant.components.litejet.* +homeassistant.components.litellm.* homeassistant.components.litterrobot.* +homeassistant.components.llama_cpp.* homeassistant.components.local_ip.* homeassistant.components.local_todo.* homeassistant.components.lock.* @@ -393,6 +399,7 @@ homeassistant.components.nam.* homeassistant.components.namecheapdns.* homeassistant.components.nasweb.* homeassistant.components.neato.* +homeassistant.components.neopool.* homeassistant.components.nest.* homeassistant.components.netatmo.* homeassistant.components.network.* diff --git a/CODEOWNERS b/CODEOWNERS index 409c3e54bdfa..5c6830e92882 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -607,6 +607,8 @@ CLAUDE.md @home-assistant/core /tests/components/frontend/ @home-assistant/frontend /homeassistant/components/frontier_silicon/ @wlcrs /tests/components/frontier_silicon/ @wlcrs +/homeassistant/components/fuelprices_dk/ @MTrab +/tests/components/fuelprices_dk/ @MTrab /homeassistant/components/fujitsu_fglair/ @crevetor /tests/components/fujitsu_fglair/ @crevetor /homeassistant/components/fully_kiosk/ @cgarwood @@ -623,6 +625,8 @@ CLAUDE.md @home-assistant/core /tests/components/gardena_bluetooth/ @elupus /homeassistant/components/gate/ @home-assistant/core /tests/components/gate/ @home-assistant/core +/homeassistant/components/gatus/ @TN-1 +/tests/components/gatus/ @TN-1 /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 @@ -675,6 +679,8 @@ CLAUDE.md @home-assistant/core /tests/components/google_drive/ @tronikos /homeassistant/components/google_generative_ai_conversation/ @tronikos @ivanlh /tests/components/google_generative_ai_conversation/ @tronikos @ivanlh +/homeassistant/components/google_health/ @allenporter +/tests/components/google_health/ @allenporter /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob /homeassistant/components/google_photos/ @allenporter @@ -713,10 +719,14 @@ CLAUDE.md @home-assistant/core /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame /tests/components/hanna/ @bestycame +/homeassistant/components/harbor/ @Lash-L @afgarcia86 +/tests/components/harbor/ @Lash-L @afgarcia86 /homeassistant/components/hardkernel/ @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core /tests/components/hardware/ @home-assistant/core +/homeassistant/components/harman_luxury/ @sbesh91 +/tests/components/harman_luxury/ @sbesh91 /homeassistant/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /homeassistant/components/hassio/ @home-assistant/supervisor @@ -777,8 +787,8 @@ CLAUDE.md @home-assistant/core /tests/components/homematicip_cloud/ @hahn-th @lackas /homeassistant/components/homevolt/ @danielhiversen @liudger /tests/components/homevolt/ @danielhiversen @liudger -/homeassistant/components/homewizard/ @DCSBL -/tests/components/homewizard/ @DCSBL +/homeassistant/components/homewizard/ @DCSBL @lexpostma +/tests/components/homewizard/ @DCSBL @lexpostma /homeassistant/components/honeywell/ @mkmer /tests/components/honeywell/ @mkmer /homeassistant/components/honeywell_string_lights/ @balloob @@ -938,6 +948,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley +/homeassistant/components/karakeep/ @sli-cka +/tests/components/karakeep/ @sli-cka /homeassistant/components/keba/ @dannerph /homeassistant/components/keenetic_ndms2/ @foxel /tests/components/keenetic_ndms2/ @foxel @@ -993,6 +1005,8 @@ CLAUDE.md @home-assistant/core /tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco +/homeassistant/components/led_infrared/ @tr4nt0r +/tests/components/led_infrared/ @tr4nt0r /homeassistant/components/lektrico/ @lektrico /tests/components/lektrico/ @lektrico /homeassistant/components/letpot/ @jpelgrom @@ -1022,10 +1036,14 @@ CLAUDE.md @home-assistant/core /homeassistant/components/linux_battery/ @fabaff /homeassistant/components/litejet/ @joncar /tests/components/litejet/ @joncar +/homeassistant/components/litellm/ @luismalves +/tests/components/litellm/ @luismalves /homeassistant/components/litterrobot/ @natekspencer @tkdrob /tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/livisi/ @StefanIacobLivisi @planbnet /tests/components/livisi/ @StefanIacobLivisi @planbnet +/homeassistant/components/llama_cpp/ @allenporter +/tests/components/llama_cpp/ @allenporter /homeassistant/components/llm/ @home-assistant/core /tests/components/llm/ @home-assistant/core /homeassistant/components/local_calendar/ @allenporter @@ -1051,6 +1069,7 @@ CLAUDE.md @home-assistant/core /homeassistant/components/lovelace/ @home-assistant/frontend /tests/components/lovelace/ @home-assistant/frontend /homeassistant/components/luci/ @mzdrale +/tests/components/luci/ @mzdrale /homeassistant/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck /homeassistant/components/lunatone/ @MoonDevLT @@ -1061,6 +1080,8 @@ CLAUDE.md @home-assistant/core /tests/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron_caseta/ @swails @danaues @eclair4151 /tests/components/lutron_caseta/ @swails @danaues @eclair4151 +/homeassistant/components/lyngdorf/ @fishloa +/tests/components/lyngdorf/ @fishloa /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/madvr/ @iloveicedgreentea @@ -1115,8 +1136,8 @@ CLAUDE.md @home-assistant/core /tests/components/microbees/ @microBeesTech /homeassistant/components/miele/ @astrandb /tests/components/miele/ @astrandb -/homeassistant/components/mikrotik/ @engrbm87 -/tests/components/mikrotik/ @engrbm87 +/homeassistant/components/mikrotik/ @engrbm87 @chemelli74 +/tests/components/mikrotik/ @engrbm87 @chemelli74 /homeassistant/components/mill/ @danielhiversen /tests/components/mill/ @danielhiversen /homeassistant/components/min_max/ @gjohansson-ST @@ -1139,8 +1160,8 @@ CLAUDE.md @home-assistant/core /tests/components/moehlenhoff_alpha2/ @j-a-n /homeassistant/components/moisture/ @home-assistant/core /tests/components/moisture/ @home-assistant/core -/homeassistant/components/monarch_money/ @jeeftor -/tests/components/monarch_money/ @jeeftor +/homeassistant/components/monarch_money/ @jeeftor @bradleyseanf +/tests/components/monarch_money/ @jeeftor @bradleyseanf /homeassistant/components/monoprice/ @etsinko @OnFreund /tests/components/monoprice/ @etsinko @OnFreund /homeassistant/components/monzo/ @jakemartin-icl @@ -1189,6 +1210,8 @@ CLAUDE.md @home-assistant/core /tests/components/nasweb/ @nasWebio /homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul +/homeassistant/components/neopool/ @svasek +/tests/components/neopool/ @svasek /homeassistant/components/ness_alarm/ @nickw444 @poshy163 /tests/components/ness_alarm/ @nickw444 @poshy163 /homeassistant/components/nest/ @allenporter @@ -1360,8 +1383,6 @@ CLAUDE.md @home-assistant/core /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 /tests/components/pegel_online/ @mib1185 -/homeassistant/components/permobil/ @IsakNyberg -/tests/components/permobil/ @IsakNyberg /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/pglab/ @pglab-electronics @@ -1577,6 +1598,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/schlage/ @dknowles2 /tests/components/schlage/ @dknowles2 /homeassistant/components/schluter/ @prairieapps +/homeassistant/components/scorpiontrack/ @Herbertmt978 +/tests/components/scorpiontrack/ @Herbertmt978 /homeassistant/components/scrape/ @fabaff @gjohansson-ST /tests/components/scrape/ @fabaff @gjohansson-ST /homeassistant/components/screenlogic/ @dieselrabbit @bdraco @@ -1696,8 +1719,8 @@ CLAUDE.md @home-assistant/core /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn /tests/components/songpal/ @rytilahti @shenxn -/homeassistant/components/sonos/ @jjlawren @peterager -/tests/components/sonos/ @jjlawren @peterager +/homeassistant/components/sonos/ @peterager @jjlawren +/tests/components/sonos/ @peterager @jjlawren /homeassistant/components/soundtouch/ @kroimon /tests/components/soundtouch/ @kroimon /homeassistant/components/spaceapi/ @fabaff @@ -1718,8 +1741,8 @@ CLAUDE.md @home-assistant/core /tests/components/starline/ @anonym-tsk /homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST /tests/components/statistics/ @ThomDietrich @gjohansson-ST -/homeassistant/components/steam_online/ @tkdrob -/tests/components/steam_online/ @tkdrob +/homeassistant/components/steam_online/ @tr4nt0r @tkdrob +/tests/components/steam_online/ @tr4nt0r @tkdrob /homeassistant/components/steamist/ @bdraco /tests/components/steamist/ @bdraco /homeassistant/components/stiebel_eltron/ @fucm @ThyMYthOS @@ -1805,8 +1828,8 @@ CLAUDE.md @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 -/homeassistant/components/tesla_wall_connector/ @einarhauks -/tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/tesla_wall_connector/ @einarhauks @sarabveer +/tests/components/tesla_wall_connector/ @einarhauks @sarabveer /homeassistant/components/teslemetry/ @Bre77 /tests/components/teslemetry/ @Bre77 /homeassistant/components/tessie/ @Bre77 @@ -1946,6 +1969,8 @@ CLAUDE.md @home-assistant/core /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven +/homeassistant/components/vibration/ @home-assistant/core +/tests/components/vibration/ @home-assistant/core /homeassistant/components/vicare/ @CFenner @lackas /tests/components/vicare/ @CFenner @lackas /homeassistant/components/victron_ble/ @rajlaud diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 81a9fca6160f..0c606c38d080 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -47,7 +47,7 @@ from .components import ( file_upload as file_upload_pre_import, # noqa: F401 group as group_pre_import, # noqa: F401 history as history_pre_import, # noqa: F401 - http, # not named pre_import since it has requirements + http as http_import, # noqa: F401 - not named pre_import since it has requirements image_upload as image_upload_import, # noqa: F401 - not named pre_import since it has requirements logbook as logbook_pre_import, # noqa: F401 lovelace as lovelace_pre_import, # noqa: F401 @@ -264,6 +264,7 @@ DEFAULT_INTEGRATIONS = { "occupancy", "power", "temperature", + "vibration", "window", } DEFAULT_INTEGRATIONS_RECOVERY_MODE = { @@ -414,12 +415,7 @@ async def async_setup_hass( _LOGGER.info("Starting in recovery mode") hass.config.recovery_mode = True - http_conf = (await http.async_get_last_config(hass)) or {} - - await async_from_config_dict( - {"recovery_mode": {}, "http": http_conf}, - hass, - ) + await async_from_config_dict({"recovery_mode": {}}, hass) if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 117b7c6b63dd..4f3c62067ab4 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -8,6 +8,7 @@ "google_cloud", "google_drive", "google_generative_ai_conversation", + "google_health", "google_mail", "google_maps", "google_photos", diff --git a/homeassistant/brands/mitsubishi.json b/homeassistant/brands/mitsubishi.json index c5c0a2d9c909..830a04566840 100644 --- a/homeassistant/brands/mitsubishi.json +++ b/homeassistant/brands/mitsubishi.json @@ -1,5 +1,5 @@ { "domain": "mitsubishi", "name": "Mitsubishi", - "integrations": ["melcloud", "mitsubishi_comfort"] + "integrations": ["melcloud", "melcloud_home", "mitsubishi_comfort"] } diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py index 34b8a713de63..979ebf3ed9b4 100644 --- a/homeassistant/components/acaia/binary_sensor.py +++ b/homeassistant/components/acaia/binary_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import override -from aioacaia.acaiascale import AcaiaScale +from aioacaia import AcaiaScale from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py index 1e260ee4e74d..120933092375 100644 --- a/homeassistant/components/acaia/button.py +++ b/homeassistant/components/acaia/button.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, override -from aioacaia.acaiascale import AcaiaScale +from aioacaia import AcaiaScale from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py index 6cceb21ff8bc..a52c5a009f80 100644 --- a/homeassistant/components/acaia/config_flow.py +++ b/homeassistant/components/acaia/config_flow.py @@ -3,8 +3,8 @@ import logging from typing import Any, override +from aioacaia.discovery import is_new_scale from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice -from aioacaia.helpers import is_new_scale import voluptuous as vol from homeassistant.components.bluetooth import ( diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py index f9847c31a8d2..06338078dd66 100644 --- a/homeassistant/components/acaia/coordinator.py +++ b/homeassistant/components/acaia/coordinator.py @@ -4,7 +4,7 @@ from datetime import timedelta import logging from typing import override -from aioacaia.acaiascale import AcaiaScale +from aioacaia import AcaiaScale from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError from homeassistant.components.bluetooth import async_get_scanner diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json index e6fdea2600e2..25472715d469 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.18"] + "requirements": ["aioacaia==0.2.0"] } diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py index 735aacd78d59..0c74f50871bd 100644 --- a/homeassistant/components/acaia/sensor.py +++ b/homeassistant/components/acaia/sensor.py @@ -4,8 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass from typing import override -from aioacaia.acaiascale import AcaiaDeviceState, AcaiaScale +from aioacaia import AcaiaScale from aioacaia.const import UnitMass as AcaiaUnitOfMass +from aioacaia.scale import AcaiaDeviceState from homeassistant.components.sensor import ( RestoreSensor, diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index e88a2960379f..3840fc515b0a 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -5,6 +5,7 @@ from typing import Any import voluptuous as vol +from homeassistant.components.media_source import local_source from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR from homeassistant.core import ( @@ -34,6 +35,7 @@ from .const import ( ) from .entity import AITaskEntity from .http import async_setup as async_setup_http +from .media_source import async_get_media_source from .task import ( GenDataTask, GenDataTaskResult, @@ -88,6 +90,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) await hass.data[DATA_PREFERENCES].async_load() async_setup_http(hass) + if hass.config.media_dirs: + source = await async_get_media_source(hass) + hass.http.register_view(local_source.LocalMediaView(hass, source)) hass.services.async_register( DOMAIN, SERVICE_GENERATE_DATA, diff --git a/homeassistant/components/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py index 9f0e493b0ada..378809eee554 100644 --- a/homeassistant/components/ai_task/media_source.py +++ b/homeassistant/components/ai_task/media_source.py @@ -2,14 +2,16 @@ from pathlib import Path -from homeassistant.components.media_source import MediaSource, local_source +from homeassistant.components.media_source import local_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.singleton import singleton from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR -async def async_get_media_source(hass: HomeAssistant) -> MediaSource: +@singleton(DATA_MEDIA_SOURCE, async_=True) +async def async_get_media_source(hass: HomeAssistant) -> local_source.LocalSource: """Set up local media source.""" media_dirs = list(hass.config.media_dirs.values()) @@ -20,11 +22,10 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR - hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource( + return local_source.LocalSource( hass, DOMAIN, "AI generated images", {IMAGE_DIR: str(media_dir)}, f"/{DOMAIN}", ) - return source diff --git a/homeassistant/components/aidot/coordinator.py b/homeassistant/components/aidot/coordinator.py index 7ec6a46ecd03..b751ac4af0f8 100644 --- a/homeassistant/components/aidot/coordinator.py +++ b/homeassistant/components/aidot/coordinator.py @@ -163,6 +163,4 @@ class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 89ff2a45f9ac..3a0dfa49742e 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -38,11 +38,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: lat = data[CONF_LATITUDE] lng = data[CONF_LONGITUDE] - distance = data[CONF_RADIUS] # Check that the provided latitude/longitude provide a response try: - test_data = await client.observations.latLong(lat, lng, distance=distance) + test_data = await client.observations.latLong(lat, lng) except InvalidKeyError as exc: raise InvalidAuth from exc diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index f96c0e66a16e..020aecec00f8 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -77,7 +77,6 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): obs = await self.airnow.observations.latLong( self.latitude, self.longitude, - distance=self.distance, ) except (AirNowError, ClientConnectorError, InvalidJsonError) as error: diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index da1c936b68fb..fa321fe1a158 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyairnow"], - "requirements": ["pyairnow==1.3.1"] + "requirements": ["pyairnow==1.4.0"] } diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py index 24adef5dbcd1..1e35887be249 100644 --- a/homeassistant/components/airobot/button.py +++ b/homeassistant/components/airobot/button.py @@ -4,11 +4,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, override -from pyairobotrest.exceptions import ( - AirobotConnectionError, - AirobotError, - AirobotTimeoutError, -) +from pyairobotrest.exceptions import AirobotError from homeassistant.components.button import ( ButtonDeviceClass, @@ -84,10 +80,6 @@ class AirobotButton(AirobotEntity, ButtonEntity): """Handle the button press.""" try: await self.entity_description.press_fn(self.coordinator) - # pylint: disable-next=home-assistant-action-swallowed-exception - except AirobotConnectionError, AirobotTimeoutError: - # Connection errors during reboot are expected as device restarts - pass except AirobotError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/airobot/manifest.json b/homeassistant/components/airobot/manifest.json index 6a2e01f07325..76ef0a73e01e 100644 --- a/homeassistant/components/airobot/manifest.json +++ b/homeassistant/components/airobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "local_polling", "loggers": ["pyairobotrest"], "quality_scale": "platinum", - "requirements": ["pyairobotrest==0.3.0"] + "requirements": ["pyairobotrest==0.4.0"] } diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index ecfcd026b665..1188cfd443bd 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( EntityCategory, UnitOfDensity, UnitOfPressure, + UnitOfRadiationConcentration, UnitOfRatio, UnitOfSoundPressure, UnitOfTemperature, @@ -33,36 +34,34 @@ from .coordinator import AirthingsDataUpdateCoordinator SENSORS: dict[str, SensorEntityDescription] = { "radonShortTermAvg": SensorEntityDescription( key="radonShortTermAvg", - native_unit_of_measurement="Bq/m³", - translation_key="radon", + device_class=SensorDeviceClass.RADON, + native_unit_of_measurement=( + UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER + ), suggested_display_precision=0, ), "temp": SensorEntityDescription( key="temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=UnitOfRatio.PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), "sla": SensorEntityDescription( key="sla", device_class=SensorDeviceClass.SOUND_PRESSURE, native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "battery": SensorEntityDescription( @@ -70,47 +69,40 @@ SENSORS: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=UnitOfRatio.PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "voc": SensorEntityDescription( key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=UnitOfRatio.PARTS_PER_BILLION, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "light": SensorEntityDescription( key="light", native_unit_of_measurement=UnitOfRatio.PERCENTAGE, translation_key="light", - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "lux": SensorEntityDescription( key="lux", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "virusRisk": SensorEntityDescription( key="virusRisk", translation_key="virus_risk", - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "mold": SensorEntityDescription( key="mold", translation_key="mold", - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "rssi": SensorEntityDescription( @@ -119,21 +111,18 @@ SENSORS: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "pm1": SensorEntityDescription( key="pm1", native_unit_of_measurement=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), "pm25": SensorEntityDescription( key="pm25", native_unit_of_measurement=UnitOfDensity.MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, - state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), } @@ -145,8 +134,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airthings sensor.""" - coordinator = entry.runtime_data + entities = [ AirthingsDeviceSensor( coordinator, diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json index 5e6d58addbe5..3c3e5dfc636d 100644 --- a/homeassistant/components/airthings/strings.json +++ b/homeassistant/components/airthings/strings.json @@ -26,9 +26,6 @@ "mold": { "name": "Mold" }, - "radon": { - "name": "Radon" - }, "virus_risk": { "name": "Virus Risk" } diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py index 43b6268bd093..195bd0e74cfa 100644 --- a/homeassistant/components/airthings_ble/const.py +++ b/homeassistant/components/airthings_ble/const.py @@ -5,9 +5,6 @@ from airthings_ble import AirthingsDeviceType DOMAIN = "airthings_ble" MFCT_ID = 820 -VOLUME_BECQUEREL = "Bq/m³" -VOLUME_PICOCURIE = "pCi/L" - DEVICE_MODEL = "device_model" DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py index ca580483e373..7c7284f7e751 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( DEFAULT_SCAN_INTERVAL, @@ -36,9 +35,7 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]): def __init__(self, hass: HomeAssistant, entry: AirthingsBLEConfigEntry) -> None: """Initialize the coordinator.""" - self.airthings = AirthingsBluetoothDeviceData( - _LOGGER, hass.config.units is METRIC_SYSTEM - ) + self.airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric=True) device_model = entry.data.get(DEVICE_MODEL) interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get( diff --git a/homeassistant/components/airthings_ble/icons.json b/homeassistant/components/airthings_ble/icons.json index 04b951999baf..600e0d3f6e77 100644 --- a/homeassistant/components/airthings_ble/icons.json +++ b/homeassistant/components/airthings_ble/icons.json @@ -9,15 +9,9 @@ "smartlink": "mdi:hub" } }, - "radon_1day_avg": { - "default": "mdi:radioactive" - }, "radon_1day_level": { "default": "mdi:radioactive" }, - "radon_longterm_avg": { - "default": "mdi:radioactive" - }, "radon_longterm_level": { "default": "mdi:radioactive" } diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index b707e8c2a921..afeaacc62f16 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -1,7 +1,6 @@ """Support for airthings ble sensors.""" from collections.abc import Callable -import dataclasses from dataclasses import dataclass import logging from typing import override @@ -19,6 +18,7 @@ from homeassistant.const import ( EntityCategory, Platform, UnitOfPressure, + UnitOfRadiationConcentration, UnitOfRatio, UnitOfSoundPressure, UnitOfTemperature, @@ -33,9 +33,8 @@ from homeassistant.helpers.entity_registry import ( ) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM -from .const import DOMAIN, VOLUME_BECQUEREL, VOLUME_PICOCURIE +from .const import DOMAIN from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -65,15 +64,15 @@ SENSORS_MAPPING_TEMPLATE: dict[str, AirthingsBLESensorEntityDescription] = { "radon_1day_avg": AirthingsBLESensorEntityDescription( key="radon_1day_avg", translation_key="radon_1day_avg", - native_unit_of_measurement=VOLUME_BECQUEREL, - suggested_display_precision=0, + device_class=SensorDeviceClass.RADON, + native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), "radon_longterm_avg": AirthingsBLESensorEntityDescription( key="radon_longterm_avg", translation_key="radon_longterm_avg", - native_unit_of_measurement=VOLUME_BECQUEREL, - suggested_display_precision=0, + device_class=SensorDeviceClass.RADON, + native_unit_of_measurement=UnitOfRadiationConcentration.BECQUEREL_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), "radon_1day_level": AirthingsBLESensorEntityDescription( @@ -210,26 +209,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airthings BLE sensors.""" - is_metric = hass.config.units is METRIC_SYSTEM - coordinator = entry.runtime_data - # we need to change some units - sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy() - if not is_metric: - for key, val in sensors_mapping.items(): - if val.native_unit_of_measurement is not VOLUME_BECQUEREL: - continue - sensors_mapping[key] = dataclasses.replace( - val, - native_unit_of_measurement=VOLUME_PICOCURIE, - suggested_display_precision=1, - ) - entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) for sensor_type, sensor_value in coordinator.data.sensors.items(): - if sensor_type not in sensors_mapping: + if sensor_type not in SENSORS_MAPPING_TEMPLATE: _LOGGER.debug( "Unknown sensor type detected: %s, %s", sensor_type, @@ -238,7 +223,9 @@ async def async_setup_entry( continue async_migrate(hass, coordinator.data.address, sensor_type) entities.append( - AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type]) + AirthingsSensor( + coordinator, coordinator.data, SENSORS_MAPPING_TEMPLATE[sensor_type] + ) ) async_add_entities(entities) diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index d6d3380e72d9..879bf6ec932f 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -9,14 +9,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_STATE, CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_SHOW_ON_MAP, CONF_STATE, + EntityStateAttribute, UnitOfDensity, UnitOfRatio, ) @@ -191,12 +190,14 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): ) if self.coordinator.config_entry.options[CONF_SHOW_ON_MAP]: - self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude - self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude + self._attr_extra_state_attributes[EntityStateAttribute.LATITUDE] = latitude + self._attr_extra_state_attributes[EntityStateAttribute.LONGITUDE] = ( + longitude + ) self._attr_extra_state_attributes.pop("lati", None) self._attr_extra_state_attributes.pop("long", None) else: self._attr_extra_state_attributes["lati"] = latitude self._attr_extra_state_attributes["long"] = longitude - self._attr_extra_state_attributes.pop(ATTR_LATITUDE, None) - self._attr_extra_state_attributes.pop(ATTR_LONGITUDE, None) + self._attr_extra_state_attributes.pop(EntityStateAttribute.LATITUDE, None) + self._attr_extra_state_attributes.pop(EntityStateAttribute.LONGITUDE, None) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 516988da4510..1e5cf061a6bd 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -111,6 +111,4 @@ def remove_stale_devices( 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 - ) + device_registry.async_remove_device(device_entry.id) diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py index 1a45247dae8d..e418903edd37 100644 --- a/homeassistant/components/aladdin_connect/api.py +++ b/homeassistant/components/aladdin_connect/api.py @@ -41,7 +41,6 @@ class AsyncConfigEntryAuth(Auth): @override 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() + await self._oauth_session.async_ensure_token_valid() return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index cdbb561ceb77..71e6a83702ff 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -1,7 +1,7 @@ """Alexa Devices integration.""" -from homeassistant.const import CONF_COUNTRY, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_COUNTRY, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2 @@ -56,7 +56,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo on_reauth_required=_on_http2_reauth_required, ) - entry.async_on_unload(coordinator.api.stop_http2_processing) + async def _async_stop_http2(_event: Event | None = None) -> None: + """Stop HTTP/2 processing on entry unload or HA shutdown.""" + await coordinator.api.stop_http2_processing() + + entry.async_on_unload(_async_stop_http2) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_http2) + ) entry.runtime_data = coordinator diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py index d4d56033c45a..a9712201ca50 100644 --- a/homeassistant/components/alexa_devices/button.py +++ b/homeassistant/components/alexa_devices/button.py @@ -1,20 +1,43 @@ """Support for buttons.""" -from typing import override +from dataclasses import dataclass +from typing import Final, override -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call -from .entity import AmazonServiceEntity +from .entity import AmazonEntity, AmazonServiceEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +@dataclass(frozen=True, kw_only=True) +class AmazonButtonEntityDescription(ButtonEntityDescription): + """Amazon Devices button entity description.""" + + capability: str + + +DEVICE_BUTTONS: Final = { + AmazonButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + capability="ALEXA_DEVICE_REBOOT", + ), +} + + async def async_setup_entry( hass: HomeAssistant, entry: AmazonConfigEntry, @@ -24,8 +47,9 @@ async def async_setup_entry( coordinator = entry.runtime_data known_routines: set[str] = set() + known_devices: set[str] = set() - def _check_routines() -> None: + def _check_routines_devices() -> None: current_routines = set(coordinator.api.routines) new_routines = current_routines - known_routines if new_routines: @@ -34,8 +58,19 @@ async def async_setup_entry( AmazonRoutineButton(coordinator, routine) for routine in new_routines ) - _check_routines() - entry.async_on_unload(coordinator.async_add_listener(_check_routines)) + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + AmazonDeviceButton(coordinator, serial_num, button_desc) + for button_desc in DEVICE_BUTTONS + for serial_num in new_devices + if button_desc.capability in coordinator.data[serial_num].capabilities + ) + + _check_routines_devices() + entry.async_on_unload(coordinator.async_add_listener(_check_routines_devices)) class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): @@ -54,3 +89,13 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): """Handle button press action.""" async with alexa_api_call(self.coordinator): await self.coordinator.api.call_routine(self._routine) + + +class AmazonDeviceButton(AmazonEntity, ButtonEntity): + """Button entity for Alexa device.""" + + @override + async def async_press(self) -> None: + """Handle button press action.""" + async with alexa_api_call(self.coordinator): + await self.coordinator.api.restart_device(self.device) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 330a41dccf27..82fe8b3ce90a 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==14.1.8"] + "requirements": ["aioamazondevices==14.2.0"] } diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 953743c66a6a..aa68ddbf5244 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -106,7 +106,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: dev_reg = dr.async_get(hass) - dev_reg.async_clear_config_entry(entry.entry_id) + dev_reg.async_clear_config_entry(entry.entry_id, entry.domain) en_reg = er.async_get(hass) en_reg.async_clear_config_entry(entry.entry_id) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 920c36d97b7b..fd30f1c6fafb 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -28,9 +28,9 @@ from homeassistant.components.recorder import ( ) from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_DOMAIN, BASE_PLATFORMS, + EntityStateAttribute, __version__ as HA_VERSION, ) from homeassistant.core import ( @@ -915,7 +915,9 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901 # 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) + entity_state.attributes.get( + EntityStateAttribute.ASSUMED_STATE, False + ) if entity_state is not None else None ), diff --git a/homeassistant/components/analytics_insights/quality_scale.yaml b/homeassistant/components/analytics_insights/quality_scale.yaml index e842dc6f3b1f..3455dc26750b 100644 --- a/homeassistant/components/analytics_insights/quality_scale.yaml +++ b/homeassistant/components/analytics_insights/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: done comment: | The coordinator handles this. - parallel-updates: todo + parallel-updates: done reauthentication-flow: status: exempt comment: | diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index 05136dc90611..f68a697decae 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -20,6 +20,8 @@ from . import AnalyticsInsightsConfigEntry from .const import DOMAIN from .coordinator import AnalyticsData, HomeassistantAnalyticsDataUpdateCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class AnalyticsSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/androidtv/diagnostics.py b/homeassistant/components/androidtv/diagnostics.py index 47cf6aa5ea88..e7f2cdb540c8 100644 --- a/homeassistant/components/androidtv/diagnostics.py +++ b/homeassistant/components/androidtv/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics( return data data["device"] = { - **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV), + **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV), "entities": {}, } @@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **async_redact_data( - attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), - TO_REDACT, - ), + **async_redact_data(entity_dict, TO_REDACT), "state": state_dict, } diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 3d7c3ce41386..05f30d960b90 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import CONF_CHAT_MODEL, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER from .coordinator import AnthropicConfigEntry, AnthropicCoordinator @@ -137,7 +137,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # 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 + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -147,20 +147,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 48705f909e58..ee49b60c7874 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -19,12 +19,11 @@ from homeassistant.config_entries import ( SubentryFlowResult, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, llm @@ -569,8 +568,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): { "role": "user", "content": "Where are the following coordinates located: " - f"({zone_home.attributes[ATTR_LATITUDE]}," - f" {zone_home.attributes[ATTR_LONGITUDE]})?", + f"({zone_home.attributes[EntityStateAttribute.LATITUDE]}," + f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?", } ], max_tokens=cast(int, DEFAULT[CONF_MAX_TOKENS]), diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 4a4ffb86554d..df503af7b46e 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -233,8 +233,9 @@ def _convert_content( # noqa: C901 """Transform HA chat_log content into Anthropic API format.""" messages: list[MessageParam] = [] container_id: str | None = None + contents = list(chat_content) - for content in chat_content: + for index, content in enumerate(contents): if isinstance(content, conversation.ToolResultContent): external_tool = True if content.tool_name == "web_search": @@ -322,14 +323,26 @@ def _convert_content( # noqa: C901 else: messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] elif isinstance(content, conversation.UserContent): + has_text = bool(content.content.strip()) + # Attachments are only appended to the last message afterwards, so + # an empty message is only useful for attachments if it is last + has_attachments = bool(content.attachments) and index == len(contents) - 1 + if not has_text and not has_attachments: + # The API rejects whitespace-only text blocks and empty + # messages, so drop content that carries neither text nor + # usable attachments + continue # Combine consequent user messages if not messages or messages[-1]["role"] != "user": messages.append( MessageParam( role="user", - content=content.content, + content=content.content if has_text else [], ) ) + elif not has_text: + # Attachments are appended to the last user message later + continue elif isinstance(messages[-1]["content"], str): messages[-1]["content"] = [ TextBlockParam(type="text", text=messages[-1]["content"]), @@ -375,7 +388,7 @@ def _convert_content( # noqa: C901 ): container_id = content.native.container.id - if content.content: + if content.content and content.content.strip(): current_index = 0 for detail in ( content.native.citation_details @@ -455,7 +468,11 @@ def _convert_content( # noqa: C901 ] ) - if ( + if not messages[-1]["content"]: + # Drop assistant messages that ended up without any content + # (e.g. whitespace-only text): the API rejects empty messages + messages.pop() + elif ( isinstance(messages[-1]["content"], list) and len(messages[-1]["content"]) == 1 and messages[-1]["content"][0]["type"] == "text" diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 3153ab89fb31..398f3ccef507 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["anthropic==0.108.0"] } diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ded5f8e57be6..39b74b3fe3b1 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -46,6 +46,7 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, recorder, template +from homeassistant.helpers.http import MIN_COMPRESSED_RESPONSE_SIZE from homeassistant.helpers.json import json_dumps, json_fragment from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType @@ -223,12 +224,14 @@ class APIStatesView(HomeAssistantView): for state in hass.states.async_all() if entity_perm(state.entity_id, POLICY_READ) ) + body = b"".join((b"[", b",".join(states), b"]")) response = web.Response( - body=b"".join((b"[", b",".join(states), b"]")), + body=body, content_type=CONTENT_TYPE_JSON, zlib_executor_size=32768, ) - response.enable_compression() + if len(body) > MIN_COMPRESSED_RESPONSE_SIZE: + response.enable_compression() return response @@ -297,11 +300,12 @@ class APIEntityStateView(HomeAssistantView): return self.json_message( "Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR ) - resp = self.json(state.as_dict(), status_code) - - resp.headers.add("Location", f"/api/states/{entity_id}") - - return resp + return web.Response( + body=state.as_dict_json, + content_type=CONTENT_TYPE_JSON, + status=status_code, + headers={"Location": f"/api/states/{entity_id}"}, + ) @ha.callback def delete(self, request: web.Request, entity_id: str) -> web.Response: diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 0a39f0ab6f47..d905b3214bde 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -204,7 +204,7 @@ class AppleTvMediaPlayer( return MediaPlayerState.PLAYING if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped): return MediaPlayerState.PAUSED - return MediaPlayerState.IDLE # Bad or unknown state? + return MediaPlayerState.IDLE # type: ignore[unreachable] # Bad or unknown state? return None @callback diff --git a/homeassistant/components/aqualogic/__init__.py b/homeassistant/components/aqualogic/__init__.py index 1c0233ecfb65..016dd3b4ca54 100644 --- a/homeassistant/components/aqualogic/__init__.py +++ b/homeassistant/components/aqualogic/__init__.py @@ -1,5 +1,6 @@ """Support for AquaLogic devices.""" +import contextlib from datetime import timedelta import logging import threading @@ -9,22 +10,19 @@ from typing import override from aqualogic.core import AquaLogic import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN, PLATFORMS, UPDATE_TOPIC + _LOGGER = logging.getLogger(__name__) -DOMAIN = "aqualogic" -UPDATE_TOPIC = f"{DOMAIN}_update" -CONF_UNIT = "unit" RECONNECT_INTERVAL = timedelta(seconds=10) CONFIG_SCHEMA = vol.Schema( @@ -36,19 +34,84 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +type AquaLogicConfigEntry = ConfigEntry[AquaLogicProcessor] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up AquaLogic platform.""" - host = config[DOMAIN][CONF_HOST] - port = config[DOMAIN][CONF_PORT] - processor = AquaLogicProcessor(hass, host, port) - hass.data[DOMAIN] = processor - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, processor.start_listen) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, processor.shutdown) - _LOGGER.debug("AquaLogicProcessor %s:%i initialized", host, port) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the AquaLogic component.""" + if DOMAIN not in config: + return True + + hass.async_create_task(_async_import(hass, config[DOMAIN])) return True +async def _async_import(hass: HomeAssistant, conf: dict) -> None: + """Import AquaLogic configuration from YAML and surface appropriate issues.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: conf[CONF_HOST], CONF_PORT: conf[CONF_PORT]}, + ) + + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2027.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AquaLogic", + }, + ) + return + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2027.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "AquaLogic", + }, + ) + + +async def async_setup_entry(hass: HomeAssistant, entry: AquaLogicConfigEntry) -> bool: + """Set up AquaLogic from a config entry.""" + processor = AquaLogicProcessor(hass, entry.data[CONF_HOST], entry.data[CONF_PORT]) + entry.runtime_data = processor + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + processor.start() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AquaLogicConfigEntry) -> bool: + """Unload an AquaLogic config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + processor = entry.runtime_data + processor.shutdown() + await hass.async_add_executor_job(lambda: processor.join(timeout=5)) + if processor.is_alive(): + _LOGGER.warning("Processor thread did not stop within timeout") + return unload_ok + + class AquaLogicProcessor(threading.Thread): """AquaLogic event processor thread.""" @@ -59,17 +122,15 @@ class AquaLogicProcessor(threading.Thread): self._host = host self._port = port self._shutdown = False - self._panel = None + self._panel: AquaLogic | None = None - def start_listen(self, event: Event) -> None: - """Start event-processing thread.""" - _LOGGER.debug("Event processing thread started") - self.start() - - def shutdown(self, event: Event) -> None: + def shutdown(self) -> None: """Signal shutdown of processing event.""" _LOGGER.debug("Event processing signaled exit") self._shutdown = True + if (panel := self._panel) is not None and panel._socket is not None: # noqa: SLF001 + with contextlib.suppress(OSError): + panel._socket.close() # noqa: SLF001 def data_changed(self, panel: AquaLogic) -> None: """Aqualogic data changed callback.""" @@ -82,13 +143,26 @@ class AquaLogicProcessor(threading.Thread): while True: panel = AquaLogic() self._panel = panel - panel.connect(self._host, self._port) - panel.process(self.data_changed) + try: + panel.connect(self._host, self._port) + panel.process(self.data_changed) + except OSError: + pass + except Exception as err: + _LOGGER.exception( + "Unexpected error in AquaLogic processor: %s", + type(err).__name__, + ) if self._shutdown: return - _LOGGER.error("Connection to %s:%d lost", self._host, self._port) + _LOGGER.warning( + "Connection to %s:%d lost, retrying in %d seconds", + self._host, + self._port, + int(RECONNECT_INTERVAL.total_seconds()), + ) time.sleep(RECONNECT_INTERVAL.total_seconds()) @property diff --git a/homeassistant/components/aqualogic/config_flow.py b/homeassistant/components/aqualogic/config_flow.py new file mode 100644 index 000000000000..9e7b3f893e0d --- /dev/null +++ b/homeassistant/components/aqualogic/config_flow.py @@ -0,0 +1,109 @@ +"""Config flow for AquaLogic.""" + +import contextlib +import threading +from typing import Any, override + +from aqualogic.core import AquaLogic +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): cv.port, + } +) + +# Worst case scenario, this covers both a plain socket timeout (READ_TIMEOUT) +# and an additional frame-scan timeout (another READ_TIMEOUT), plus one second. +_PROBE_TIMEOUT = AquaLogic.READ_TIMEOUT * 2 + 1 + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class InvalidDevice(Exception): + """Error to indicate the device is not an AquaLogic panel.""" + + +def _verify_device(host: str, port: int) -> None: + """Connect and verify the device is an AquaLogic panel. + + Raises CannotConnect if the host is unreachable. + Raises InvalidDevice if no valid AquaLogic data is received within the timeout. + """ + confirmed = threading.Event() + + def _on_data(_: AquaLogic) -> None: + confirmed.set() + + panel = AquaLogic() + try: + panel.connect(host, port) + except OSError as err: + raise CannotConnect from err + + probe = threading.Thread(target=panel.process, args=(_on_data,), daemon=True) + probe.start() + try: + confirmed.wait(timeout=_PROBE_TIMEOUT) + finally: + if (sock := panel._socket) is not None: # noqa: SLF001 + with contextlib.suppress(OSError): + sock.close() + + if not confirmed.is_set(): + raise InvalidDevice + + +class AquaLogicConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for AquaLogic.""" + + VERSION = 1 + + @override + 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(user_input) + + try: + await self.hass.async_add_executor_job( + _verify_device, user_input[CONF_HOST], user_input[CONF_PORT] + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidDevice: + errors["base"] = "invalid_device" + else: + return self.async_create_entry(title="AquaLogic", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Import AquaLogic config from configuration.yaml.""" + self._async_abort_entries_match( + {CONF_HOST: import_data[CONF_HOST], CONF_PORT: import_data[CONF_PORT]} + ) + + try: + await self.hass.async_add_executor_job( + _verify_device, import_data[CONF_HOST], import_data[CONF_PORT] + ) + except CannotConnect, InvalidDevice: + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="AquaLogic", data=import_data) diff --git a/homeassistant/components/aqualogic/const.py b/homeassistant/components/aqualogic/const.py new file mode 100644 index 000000000000..881dfe960dfb --- /dev/null +++ b/homeassistant/components/aqualogic/const.py @@ -0,0 +1,8 @@ +"""Constants for the AquaLogic integration.""" + +from homeassistant.const import Platform + +DOMAIN = "aqualogic" +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] + +UPDATE_TOPIC = f"{DOMAIN}_update" diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json index cc807e4bb198..2165a1f27ef1 100644 --- a/homeassistant/components/aqualogic/manifest.json +++ b/homeassistant/components/aqualogic/manifest.json @@ -2,9 +2,10 @@ "domain": "aqualogic", "name": "AquaLogic", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aqualogic", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["aqualogic"], - "quality_scale": "legacy", "requirements": ["aqualogic==2.6"] } diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index a3ff7c1d9d90..6f03b9d2b196 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -3,27 +3,18 @@ from dataclasses import dataclass from typing import override -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, - PERCENTAGE, - UnitOfPower, - UnitOfTemperature, -) +from homeassistant.const import PERCENTAGE, UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv 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 DOMAIN, UPDATE_TOPIC, AquaLogicProcessor +from . import AquaLogicConfigEntry, AquaLogicProcessor +from .const import UPDATE_TOPIC @dataclass(frozen=True) @@ -101,34 +92,18 @@ SENSOR_TYPES: tuple[AquaLogicSensorEntityDescription, ...] = ( ), ) -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS, default=SENSOR_KEYS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ) - } -) - - -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: AquaLogicConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the sensor platform.""" - processor: AquaLogicProcessor = hass.data[DOMAIN] - monitored_conditions = config[CONF_MONITORED_CONDITIONS] + """Set up the sensor entities.""" + processor = entry.runtime_data - entities = [ - AquaLogicSensor(processor, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ] - - async_add_entities(entities) + async_add_entities( + AquaLogicSensor(processor, description) for description in SENSOR_TYPES + ) class AquaLogicSensor(SensorEntity): @@ -172,4 +147,5 @@ class AquaLogicSensor(SensorEntity): self._attr_native_value = getattr(panel, self.entity_description.key) self.async_write_ha_state() else: - self._attr_native_unit_of_measurement = None + self._attr_native_value = None + self.async_write_ha_state() diff --git a/homeassistant/components/aqualogic/strings.json b/homeassistant/components/aqualogic/strings.json new file mode 100644 index 000000000000..e8a7414483d4 --- /dev/null +++ b/homeassistant/components/aqualogic/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_device": "The device at the given address is not an AquaLogic panel" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your AquaLogic controller", + "port": "TCP port used to connect to the AquaLogic controller" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your YAML configuration, the AquaLogic device could not be reached or did not respond as an AquaLogic panel. Please ensure the device is accessible and restart Home Assistant to retry, or remove the {domain} key from your configuration and set up the integration via the UI.", + "title": "The {integration_title} YAML configuration is being removed" + } + } +} diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index 09a3d0c7501b..4524028e15a5 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -3,55 +3,39 @@ from typing import Any, override from aqualogic.core import States -import voluptuous as vol -from homeassistant.components.switch import ( - PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, - SwitchEntity, -) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv 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 DOMAIN, UPDATE_TOPIC, AquaLogicProcessor +from . import AquaLogicConfigEntry, AquaLogicProcessor +from .const import UPDATE_TOPIC -SWITCH_TYPES = { - "lights": "Lights", - "filter": "Filter", - "filter_low_speed": "Filter Low Speed", - "aux_1": "Aux 1", - "aux_2": "Aux 2", - "aux_3": "Aux 3", - "aux_4": "Aux 4", - "aux_5": "Aux 5", - "aux_6": "Aux 6", - "aux_7": "Aux 7", +_SWITCH_MAP: dict[str, tuple[str, States]] = { + "lights": ("Lights", States.LIGHTS), + "filter": ("Filter", States.FILTER), + "filter_low_speed": ("Filter Low Speed", States.FILTER_LOW_SPEED), + "aux_1": ("Aux 1", States.AUX_1), + "aux_2": ("Aux 2", States.AUX_2), + "aux_3": ("Aux 3", States.AUX_3), + "aux_4": ("Aux 4", States.AUX_4), + "aux_5": ("Aux 5", States.AUX_5), + "aux_6": ("Aux 6", States.AUX_6), + "aux_7": ("Aux 7", States.AUX_7), } -PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCH_TYPES)): vol.All( - cv.ensure_list, [vol.In(SWITCH_TYPES)] - ) - } -) - -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: AquaLogicConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the switch platform.""" - processor: AquaLogicProcessor = hass.data[DOMAIN] + """Set up the switch entities.""" + processor = entry.runtime_data async_add_entities( - AquaLogicSwitch(processor, switch_type) - for switch_type in config[CONF_MONITORED_CONDITIONS] + AquaLogicSwitch(processor, switch_type) for switch_type in _SWITCH_MAP ) @@ -62,20 +46,10 @@ class AquaLogicSwitch(SwitchEntity): def __init__(self, processor: AquaLogicProcessor, switch_type: str) -> None: """Initialize switch.""" + name, state = _SWITCH_MAP[switch_type] self._processor = processor - self._state_name = { - "lights": States.LIGHTS, - "filter": States.FILTER, - "filter_low_speed": States.FILTER_LOW_SPEED, - "aux_1": States.AUX_1, - "aux_2": States.AUX_2, - "aux_3": States.AUX_3, - "aux_4": States.AUX_4, - "aux_5": States.AUX_5, - "aux_6": States.AUX_6, - "aux_7": States.AUX_7, - }[switch_type] - self._attr_name = f"AquaLogic {SWITCH_TYPES[switch_type]}" + self._state_name = state + self._attr_name = f"AquaLogic {name}" @property @override diff --git a/homeassistant/components/aqvify/coordinator.py b/homeassistant/components/aqvify/coordinator.py index 10a77e3d0204..65f064e29c39 100644 --- a/homeassistant/components/aqvify/coordinator.py +++ b/homeassistant/components/aqvify/coordinator.py @@ -201,22 +201,15 @@ class AqvifyAggrDataCoordinator( self.api_client = api_client - @staticmethod - def _get_times() -> tuple[str, str]: - """Determine strings for time parameters for aggregated data from API.""" - date_time_fmt = "%Y-%m-%dT%H:%MZ" - base_time = utcnow() - timedelta(hours=1) - beg_time = base_time.replace(minute=0).strftime(date_time_fmt) - end_time = base_time.replace(minute=59).strftime(date_time_fmt) - return beg_time, end_time - @override async def _async_update_data(self) -> dict[str, AqvifyHourAggregatedValues]: """Fetch device state.""" devices = self.config_entry.runtime_data.coordinator.data.devices device_data: dict[str, AqvifyHourAggregatedValues] = {} - beg_time, end_time = self._get_times() + base_time = utcnow() - timedelta(hours=1) + beg_time = base_time.replace(minute=0, second=0, microsecond=0) + end_time = base_time.replace(minute=59, second=0, microsecond=0) for device in devices.devices.values(): device_key = device.device_key if TYPE_CHECKING: diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 258d29fceeb4..c7932f7adee9 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -25,7 +25,7 @@ from homeassistant.components import ( wake_word, websocket_api, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL +from homeassistant.const import MATCH_ALL, EntityStateAttribute from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -1255,7 +1255,7 @@ class PipelineRun: if ( intent_agent_state := self.hass.states.get(self.intent_agent.id) ) and intent_agent_state.attributes.get( - ATTR_SUPPORTED_FEATURES, 0 + EntityStateAttribute.SUPPORTED_FEATURES, 0 ) & conversation.ConversationEntityFeature.CONTROL: intent_filter = _async_local_fallback_intent_filter diff --git a/homeassistant/components/assist_satellite/llm.py b/homeassistant/components/assist_satellite/llm.py new file mode 100644 index 000000000000..6590875fe4bc --- /dev/null +++ b/homeassistant/components/assist_satellite/llm.py @@ -0,0 +1,24 @@ +"""LLM tools for the assist_satellite integration.""" + +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the broadcast LLM tool.""" + if api_id != LLM_API_ASSIST: + return None + + # assist_satellite registers the broadcast intent when it is set up, and + # this platform is only queried once that has happened. + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type == intent.INTENT_BROADCAST + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index 7aa6d4d8a7ac..175c35c8297f 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, @@ -39,7 +41,7 @@ async def async_get_config_entry_diagnostics( return data data["device"] = { - **async_redact_data(attr.asdict(hass_device), TO_REDACT_DEV), + **async_redact_data(device_entry_as_dict(hass_device), TO_REDACT_DEV), "entities": {}, "tracked_devices": [], } @@ -60,13 +62,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **async_redact_data( - attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), - TO_REDACT, - ), + **async_redact_data(entity_dict, TO_REDACT), "state": state_dict, } diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 12fd0806245d..a4cfd3a490be 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -8,7 +8,11 @@ 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 +from homeassistant.components.lock import ( + LockEntity, + LockEntityFeature, + LockEntityStateAttribute, +) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -134,7 +138,7 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity): @override async def async_added_to_hass(self) -> None: - """Restore ATTR_CHANGED_BY on startup. + """Restore changed_by on startup. It is likely no longer in the activity log. """ @@ -143,5 +147,7 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity): if not (last_state := await self.async_get_last_state()): return - if ATTR_CHANGED_BY in last_state.attributes: - self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY] + if LockEntityStateAttribute.CHANGED_BY in last_state.attributes: + self._attr_changed_by = last_state.attributes[ + LockEntityStateAttribute.CHANGED_BY + ] diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 348b2b8e2e35..740640dc69df 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -17,10 +17,10 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_ENTITY_PICTURE, PERCENTAGE, STATE_UNAVAILABLE, EntityCategory, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -170,7 +170,7 @@ class AugustOperatorSensor(AugustEntity, RestoreSensor): @override async def async_added_to_hass(self) -> None: - """Restore ATTR_CHANGED_BY on startup. + """Restore attributes on startup. It is likely no longer in the activity log. """ @@ -187,8 +187,8 @@ class AugustOperatorSensor(AugustEntity, RestoreSensor): self._attr_native_value = last_sensor_state.native_value last_attrs = last_state.attributes - if ATTR_ENTITY_PICTURE in last_attrs: - self._attr_entity_picture = last_attrs[ATTR_ENTITY_PICTURE] + if EntityStateAttribute.ENTITY_PICTURE in last_attrs: + self._attr_entity_picture = last_attrs[EntityStateAttribute.ENTITY_PICTURE] if ATTR_OPERATION_REMOTE in last_attrs: self._operated_remote = last_attrs[ATTR_OPERATION_REMOTE] if ATTR_OPERATION_KEYPAD in last_attrs: diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index a48d704141fc..70b66bf2b82c 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index b6fb8df0f7ea..1b485f551f3b 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from datetime import timedelta import logging diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 317b82aed5a0..4403bdecd3d7 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -1,4 +1,4 @@ -"""The aurora component.""" +"""The Aurora integration.""" from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 3f9d4e842ea5..33a1aedc943a 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==72"], + "requirements": ["axis==74"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/backblaze_b2/quality_scale.yaml b/homeassistant/components/backblaze_b2/quality_scale.yaml index 1532f08d9a82..ef20474b3130 100644 --- a/homeassistant/components/backblaze_b2/quality_scale.yaml +++ b/homeassistant/components/backblaze_b2/quality_scale.yaml @@ -123,8 +123,4 @@ rules: comment: | The b2sdk library does not support custom HTTP session injection. It manages HTTP connections internally through its own session management. - strict-typing: - status: exempt - comment: | - The b2sdk dependency does not include a py.typed file and is not PEP 561 compliant. - This is outside the integration's control as it's a third-party library requirement. + strict-typing: todo diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index a8807a062add..625b742164ad 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -62,9 +62,7 @@ async def async_setup_entry( if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in { remote.serial_number for remote in remotes }: - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async_add_entities(new_entities=entities) diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index ba7c768f24aa..aca1b550eada 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -29,6 +29,7 @@ BINARY_SENSOR_TYPES = ( ), BinarySensorEntityDescription( key="input", + translation_key="input", ), ) diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py index fd277810369f..16ab7b4493d7 100644 --- a/homeassistant/components/blebox/button.py +++ b/homeassistant/components/blebox/button.py @@ -43,8 +43,6 @@ async def async_setup_entry( class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity): """Representation of BleBox buttons.""" - _attr_name = None - def __init__( self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button ) -> None: diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index 382d6c34ebe0..82f9cb4944f6 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -70,6 +70,16 @@ } }, "entity": { + "binary_sensor": { + "input": { "name": "Input" } + }, + "button": { + "close": { "name": "Close" }, + "down": { "name": "Down" }, + "fav": { "name": "Favorite" }, + "open": { "name": "Open" }, + "up": { "name": "Up" } + }, "light": { "channel": { "name": "Channel {index}" } }, "sensor": { "active_power": { "name": "Active power" }, diff --git a/homeassistant/components/blebox/update.py b/homeassistant/components/blebox/update.py index e7e0088d3290..389b0560c6fe 100644 --- a/homeassistant/components/blebox/update.py +++ b/homeassistant/components/blebox/update.py @@ -22,7 +22,7 @@ from .const import DOMAIN from .coordinator import BleBoxCoordinator from .entity import BleBoxEntity -PARALLEL_UPDATES = 0 +PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 95f271764a83..981bff1fff71 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -11,7 +11,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -43,6 +42,7 @@ class BlinkSyncModuleHA( ): """Representation of a Blink Alarm Control Panel.""" + _attr_attribution = DEFAULT_ATTRIBUTION _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY _attr_code_arm_required = False _attr_has_entity_name = True @@ -77,7 +77,6 @@ class BlinkSyncModuleHA( """Update attributes for alarm control panel.""" self.sync.attributes["network_info"] = self.api.networks self.sync.attributes["associated_cameras"] = list(self.sync.cameras) - self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION self._attr_extra_state_attributes = self.sync.attributes self._attr_alarm_state = ( AlarmControlPanelState.ARMED_AWAY diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f31a6e0afef5..e525e5ae9255 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.6.4", "bluetooth-data-tools==1.29.18", "dbus-fast==5.0.22", - "habluetooth==6.26.2" + "habluetooth==6.26.5" ] } diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py index e5b25e37e290..33a6516792a6 100644 --- a/homeassistant/components/bosch_alarm/entity.py +++ b/homeassistant/components/bosch_alarm/entity.py @@ -48,7 +48,7 @@ class BoschAlarmEntity(Entity): """Stop observing state changes.""" self.panel.connection_status_observer.detach(self.schedule_update_ha_state) if self._observe_faults: - self.panel.faults_observer.attach(self.schedule_update_ha_state) + self.panel.faults_observer.detach(self.schedule_update_ha_state) class BoschAlarmAreaEntity(BoschAlarmEntity): @@ -92,7 +92,7 @@ class BoschAlarmAreaEntity(BoschAlarmEntity): @override async def async_will_remove_from_hass(self) -> None: """Stop observing state changes.""" - await super().async_added_to_hass() + await super().async_will_remove_from_hass() if self._observe_alarms: self._area.alarm_observer.detach(self.schedule_update_ha_state) if self._observe_ready: @@ -126,7 +126,7 @@ class BoschAlarmPointEntity(BoschAlarmEntity): @override async def async_will_remove_from_hass(self) -> None: """Stop observing state changes.""" - await super().async_added_to_hass() + await super().async_will_remove_from_hass() self._point.status_observer.detach(self.schedule_update_ha_state) @@ -155,7 +155,7 @@ class BoschAlarmDoorEntity(BoschAlarmEntity): @override async def async_will_remove_from_hass(self) -> None: """Stop observing state changes.""" - await super().async_added_to_hass() + await super().async_will_remove_from_hass() self._door.status_observer.detach(self.schedule_update_ha_state) @@ -184,5 +184,5 @@ class BoschAlarmOutputEntity(BoschAlarmEntity): @override async def async_will_remove_from_hass(self) -> None: """Stop observing state changes.""" - await super().async_added_to_hass() + await super().async_will_remove_from_hass() self._output.status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 738d8d187fee..ee3be122bc55 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -176,9 +176,7 @@ class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]): diff --git a/homeassistant/components/bring/services.py b/homeassistant/components/bring/services.py index 3d5b20e7b6dd..7b84835f4a83 100644 --- a/homeassistant/components/bring/services.py +++ b/homeassistant/components/bring/services.py @@ -9,7 +9,7 @@ from bring_api import ( ) import voluptuous as vol -from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.event import EventEntityStateAttribute from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -70,7 +70,7 @@ def async_setup_services(hass: HomeAssistant) -> None: list_uuid = entity.unique_id.split("_")[1] - activity = state.attributes[ATTR_EVENT_TYPE] + activity = state.attributes[EventEntityStateAttribute.EVENT_TYPE] reaction: ReactionType = call.data[ATTR_REACTION] diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 859e5f0d292f..ac3a2799f476 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -90,6 +90,11 @@ class BroadlinkUpdateManager(ABC, Generic[_ApiT]): # noqa: UP046 self.device.api.model, self.device.api.host[0], ) + if not self.coordinator.last_update_success: + # When the previous refresh already failed, the coordinator + # will skip listener notification, so notify explicitly to + # ensure entities flip to unavailable on this transition. + self.coordinator.async_update_listeners() raise UpdateFailed(err) from err if self.available is False: diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 0000fbf09112..6966beb7d3e4 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -257,10 +257,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo # Perform first refresh of fast coordinator (required for entities) await fast_coordinator.async_config_entry_first_refresh() - # Refresh slow coordinator - don't fail if DHW is not available - # This allows the integration to work even if the device doesn't support DHW - await slow_coordinator.async_refresh() - entry.runtime_data = BSBLanData( client=bsblan, fast_coordinator=fast_coordinator, @@ -271,6 +267,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo available_circuits=circuits, ) + # Fetch slow data in the background so it does not block startup. + entry.async_create_background_task( + hass, + slow_coordinator.async_refresh(), + name=f"{DOMAIN}_slow_data_fetch_{entry.entry_id}", + ) + # Register main device before forwarding platforms, so sub-devices # (heating circuits, water heater) can reference it via via_device device_registry = dr.async_get(hass) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 094a74f855f1..4f1ea98d1939 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -2,7 +2,7 @@ from typing import Any, Final, override -from bsblan import BSBLANError, State, get_hvac_action_category +from bsblan import BSBLANError, EntityInfo, State, get_hvac_action_category from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -54,6 +54,14 @@ BSBLAN_TO_HA_HVAC_MODE: Final[dict[int, HVACMode]] = { } +def _resolve_temperature_bound(*sources: EntityInfo[float] | None) -> float | None: + """Return the first usable temperature bound from the given sources.""" + for source in sources: + if source is not None and source.value is not None: + return source.value + return None + + async def async_setup_entry( hass: HomeAssistant, entry: BSBLanConfigEntry, @@ -97,12 +105,23 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity): else: self._attr_unique_id = f"{mac}-climate-{circuit}" # pylint: disable=home-assistant-entity-unique-id-redundant-platform - # Set temperature range from per-circuit static data + # Set temperature range from per-circuit static data. Standard BSB/LPB + # circuits expose the bounds via heating_protective_setpoint (714) and + # comfort_setpoint_max (716); min_temp/max_temp (15006/15007) exist + # only on PPS devices. Inactive parameters ("---") have value None. if (static := data.static.get(circuit)) is not None: - if (min_temp := static.min_temp) is not None and min_temp.value is not None: - self._attr_min_temp = min_temp.value - if (max_temp := static.max_temp) is not None and max_temp.value is not None: - self._attr_max_temp = max_temp.value + if ( + min_temp := _resolve_temperature_bound( + static.heating_protective_setpoint, static.min_temp + ) + ) is not None: + self._attr_min_temp = min_temp + if ( + max_temp := _resolve_temperature_bound( + static.comfort_setpoint_max, static.max_temp + ) + ) is not None: + self._attr_max_temp = max_temp self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit @property diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index f0adc5f2eb8b..2403b38a5274 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["bsblan"], "quality_scale": "silver", - "requirements": ["python-bsblan==6.1.4"], + "requirements": ["python-bsblan==6.1.6"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 315320fd5b83..8f3e46c72a62 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -2,7 +2,7 @@ from datetime import time import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Final from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot import voluptuous as vol @@ -29,6 +29,16 @@ ATTR_FRIDAY_SLOTS = "friday_slots" ATTR_SATURDAY_SLOTS = "saturday_slots" ATTR_SUNDAY_SLOTS = "sunday_slots" +_DAY_NAME_SLOT_ATTR_PAIRS: tuple[tuple[str, str], ...] = ( + ("monday", ATTR_MONDAY_SLOTS), + ("tuesday", ATTR_TUESDAY_SLOTS), + ("wednesday", ATTR_WEDNESDAY_SLOTS), + ("thursday", ATTR_THURSDAY_SLOTS), + ("friday", ATTR_FRIDAY_SLOTS), + ("saturday", ATTR_SATURDAY_SLOTS), + ("sunday", ATTR_SUNDAY_SLOTS), +) + # Schema for a single time slot _SLOT_SCHEMA = vol.Schema( @@ -39,16 +49,16 @@ _SLOT_SCHEMA = vol.Schema( ) +_WEEKLY_SCHEDULE_FIELDS: Final[dict[vol.Marker, Any]] = { + vol.Optional(slot_attr): vol.All(cv.ensure_list, [_SLOT_SCHEMA]) + for _, slot_attr in _DAY_NAME_SLOT_ATTR_PAIRS +} + + SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), - vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]), + **_WEEKLY_SCHEDULE_FIELDS, } ) @@ -98,11 +108,26 @@ def _convert_time_slots_to_day_schedule( return DaySchedule(slots=time_slots) -async def set_hot_water_schedule(service_call: ServiceCall) -> None: - """Set hot water heating schedule.""" - device_id = service_call.data[ATTR_DEVICE_ID] +def _build_weekly_schedule_days( + service_call: ServiceCall, +) -> dict[str, DaySchedule | None]: + """Build day-name -> schedule values from the service call data. + + Days omitted from the service call map to None, which tells python-bsblan not to + modify that day. + """ + return { + day_name: _convert_time_slots_to_day_schedule(service_call.data.get(attr_name)) + for day_name, attr_name in _DAY_NAME_SLOT_ATTR_PAIRS + } + + +def _resolve_config_entry( + service_call: ServiceCall, +) -> tuple[BSBLanConfigEntry, dr.DeviceEntry]: + """Resolve device_id from a service call into a loaded BSBLAN config entry.""" + device_id: str = service_call.data[ATTR_DEVICE_ID] - # Get the device and config entry device_registry = dr.async_get(service_call.hass) device_entry = device_registry.async_get(device_id) @@ -137,56 +162,38 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None: translation_placeholders={"device_name": device_entry.name or device_id}, ) + return entry, device_entry + + +def _device_name(device_entry: dr.DeviceEntry) -> str: + """Return the best available display name for a device.""" + return device_entry.name_by_user or device_entry.name or device_entry.id + + +def _ensure_water_heater_device(device_entry: dr.DeviceEntry) -> None: + """Validate the service targets the water heater sub-device.""" + for domain, identifier in device_entry.identifiers: + if domain == DOMAIN and identifier.endswith("-water-heater"): + return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_a_water_heater_device", + translation_placeholders={"device_name": _device_name(device_entry)}, + ) + + +async def set_hot_water_schedule(service_call: ServiceCall) -> None: + """Set hot water heating schedule.""" + entry, device_entry = _resolve_config_entry(service_call) + _ensure_water_heater_device(device_entry) client = entry.runtime_data.client - # Convert time slots to DaySchedule objects - monday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_MONDAY_SLOTS) - ) - tuesday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_TUESDAY_SLOTS) - ) - wednesday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_WEDNESDAY_SLOTS) - ) - thursday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_THURSDAY_SLOTS) - ) - friday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_FRIDAY_SLOTS) - ) - saturday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_SATURDAY_SLOTS) - ) - sunday = _convert_time_slots_to_day_schedule( - service_call.data.get(ATTR_SUNDAY_SLOTS) - ) + days = _build_weekly_schedule_days(service_call) + dhw_schedule = DHWSchedule(**days) - # Create the DHWSchedule object - dhw_schedule = DHWSchedule( - monday=monday, - tuesday=tuesday, - wednesday=wednesday, - thursday=thursday, - friday=friday, - saturday=saturday, - sunday=sunday, - ) - - LOGGER.debug( - "Setting hot water schedule - Monday: %s, Tuesday: %s, Wednesday: %s, " - "Thursday: %s, Friday: %s, Saturday: %s, Sunday: %s", - monday, - tuesday, - wednesday, - thursday, - friday, - saturday, - sunday, - ) + LOGGER.debug("Setting hot water schedule: %s", dhw_schedule) try: - # Call the BSB-LAN API to set the schedule await client.set_hot_water_schedule(dhw_schedule) except BSBLANError as err: raise HomeAssistantError( @@ -201,45 +208,11 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None: async def async_sync_time(service_call: ServiceCall) -> None: """Synchronize BSB-LAN device time with Home Assistant.""" - device_id: str = service_call.data[ATTR_DEVICE_ID] - - # Get the device and config entry - device_registry = dr.async_get(service_call.hass) - device_entry = device_registry.async_get(device_id) - - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_device_id", - translation_placeholders={"device_id": device_id}, - ) - - # Find the config entry for this device - matching_entries: list[BSBLanConfigEntry] = [ - entry - for entry in service_call.hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ] - - if not matching_entries: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_config_entry_for_device", - translation_placeholders={"device_id": device_entry.name or device_id}, - ) - - entry = matching_entries[0] - - # Verify the config entry is loaded - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_loaded", - translation_placeholders={"device_name": device_entry.name or device_id}, - ) - + entry, device_entry = _resolve_config_entry(service_call) client = entry.runtime_data.client - await async_sync_device_time(client, device_entry.name or device_id) + await async_sync_device_time( + client, device_entry.name or service_call.data[ATTR_DEVICE_ID] + ) SYNC_TIME_SCHEMA = vol.Schema( diff --git a/homeassistant/components/bsblan/services.yaml b/homeassistant/components/bsblan/services.yaml index 0844aa35feaa..ccfb91594aa1 100644 --- a/homeassistant/components/bsblan/services.yaml +++ b/homeassistant/components/bsblan/services.yaml @@ -6,6 +6,8 @@ sync_time: selector: device: integration: bsblan + entity: + - domain: button set_hot_water_schedule: fields: @@ -15,6 +17,8 @@ set_hot_water_schedule: selector: device: integration: bsblan + entity: + - domain: water_heater monday_slots: selector: object: diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 425bfd65e97a..e97561661049 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -127,6 +127,9 @@ "no_config_entry_for_device": { "message": "No configuration entry found for device: {device_id}" }, + "not_a_water_heater_device": { + "message": "The selected device ({device_name}) is not a water heater. Please select the water heater sub-device." + }, "set_data_error": { "message": "An error occurred while sending the data to the BSB-LAN device" }, diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index 518b6e4dcc45..8c0f8b37cdf1 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -80,32 +80,49 @@ class BSBLANWaterHeater(BSBLanWaterHeaterDeviceEntity, WaterHeaterEntity): # Initialize available attribute to resolve multiple inheritance conflict self._attr_available = True - # Set temperature limits based on device capabilities from slow coordinator + @property + @override + def min_temp(self) -> float: + """Return the minimum temperature. + + Derived from the slow-coordinator DHW config, which may still be + pending when the platform is set up. Falls back to the default until + the config becomes available. + """ dhw_config = ( - data.slow_coordinator.data.dhw_config - if data.slow_coordinator.data + self.slow_coordinator.data.dhw_config + if self.slow_coordinator.data else None ) - - # For min_temp: Use reduced_setpoint from config data (slow polling) if ( dhw_config is not None and dhw_config.reduced_setpoint is not None and dhw_config.reduced_setpoint.value is not None ): - self._attr_min_temp = dhw_config.reduced_setpoint.value - else: - self._attr_min_temp = 10.0 # Default minimum + return dhw_config.reduced_setpoint.value + return 10.0 # Default minimum - # For max_temp: Use nominal_setpoint_max from config data (slow polling) + @property + @override + def max_temp(self) -> float: + """Return the maximum temperature. + + Derived from the slow-coordinator DHW config, which may still be + pending when the platform is set up. Falls back to the default until + the config becomes available. + """ + dhw_config = ( + self.slow_coordinator.data.dhw_config + if self.slow_coordinator.data + else None + ) if ( dhw_config is not None and dhw_config.nominal_setpoint_max is not None and dhw_config.nominal_setpoint_max.value is not None ): - self._attr_max_temp = dhw_config.nominal_setpoint_max.value - else: - self._attr_max_temp = 65.0 # Default maximum + return dhw_config.nominal_setpoint_max.value + return 65.0 # Default maximum @property def _dhw(self) -> HotWaterState: diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 1f575d26aeab..4850a83af4b4 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -28,7 +28,6 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, @@ -905,17 +904,15 @@ class BrSensor(SensorEntity): # update all other sensors self._attr_native_value = data.get(sensor_type) + self._attr_attribution = data.get(ATTRIBUTION) if sensor_type.startswith(PRECIPITATION_FORECAST): - result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)} + result = {} if self._timeframe is not None: result[TIMEFRAME_LABEL] = f"{self._timeframe} min" self._attr_extra_state_attributes = result - result = { - ATTR_ATTRIBUTION: data.get(ATTRIBUTION), - STATIONNAME_LABEL: data.get(STATIONNAME), - } + result = {STATIONNAME_LABEL: data.get(STATIONNAME)} if self._measured is not None: # convert datetime (Europe/Amsterdam) into local datetime local_dt = dt_util.as_local(self._measured) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index 2f91d76aba9a..b64b7fb8e734 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -1,4 +1,5 @@ """Library for working with CalDAV api.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/calendar/llm.py b/homeassistant/components/calendar/llm.py new file mode 100644 index 000000000000..d92466377f58 --- /dev/null +++ b/homeassistant/components/calendar/llm.py @@ -0,0 +1,106 @@ +"""LLM tools for the calendar integration.""" + +from datetime import timedelta +from operator import attrgetter +from typing import cast, override + +import voluptuous as vol + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType + +from . import SERVICE_GET_EVENTS +from .const import DOMAIN + + +class CalendarGetEventsTool(Tool): + """LLM Tool allowing querying a calendar.""" + + name = "calendar_get_events" + description = ( + "Get events from a calendar. " + "When asked if something happens, search the whole week. " + "Results are RFC 5545 which means 'end' is exclusive." + ) + + def __init__(self, calendars: list[str]) -> None: + """Init the get events tool.""" + self.parameters = vol.Schema( + { + vol.Required("calendar"): vol.In(calendars), + vol.Required("range"): vol.In(["today", "week"]), + } + ) + + @override + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a calendar.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["calendar"], + domains=[DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "Calendar not found"} + + entity_id = result.states[0].entity_id + if data["range"] == "today": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=1) + elif data["range"] == "week": + start = dt_util.now() + end = dt_util.start_of_local_day() + timedelta(days=7) + + service_data = { + "entity_id": entity_id, + "start_date_time": start.isoformat(), + "end_date_time": end.isoformat(), + } + + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_EVENTS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + + events = [ + event if "T" in event["start"] else {**event, "all_day": True} + for event in cast(dict, service_result)[entity_id]["events"] + ] + + return {"success": True, "result": events} + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the calendar LLM tools when a calendar is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + entity_registry = er.async_get(hass) + names: list[str] = [] + for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")): + if not async_should_expose(hass, llm_context.assistant, state.entity_id): + continue + entity_entry = entity_registry.async_get(state.entity_id) + names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state)) + + if not names: + return None + return LLMTools(tools=[CalendarGetEventsTool(names)]) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index 23315a57c662..c789884d5b4c 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -160,26 +160,32 @@ 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: + data = self.data + assert data is not None await self.coordinator.async_set_temperature( - self._ac_index, self.data, temperature, kwargs.get(ATTR_HVAC_MODE) + self._ac_index, data, temperature, kwargs.get(ATTR_HVAC_MODE) ) @override 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, self.data, hvac_mode) + data = self.data + assert data is not None + await self.coordinator.async_set_hvac_mode(self._ac_index, data, hvac_mode) @override 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, self.data, fan_mode) + data = self.data + assert data is not None + await self.coordinator.async_set_fan_mode(self._ac_index, data, fan_mode) @override async def async_set_swing_mode(self, swing_mode: str) -> None: """Set the swing mode.""" - await self.coordinator.async_set_swing_mode( - self._ac_index, self.data, swing_mode - ) + data = self.data + assert data is not None + await self.coordinator.async_set_swing_mode(self._ac_index, data, swing_mode) @override async def async_turn_off(self) -> None: diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json index 53948217186b..bc11c2924aa1 100644 --- a/homeassistant/components/ccm15/manifest.json +++ b/homeassistant/components/ccm15/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["py_ccm15==1.0.0"] + "requirements": ["py_ccm15==1.1.2"] } diff --git a/homeassistant/components/cielo_home/climate.py b/homeassistant/components/cielo_home/climate.py index 38ddd9cbf2df..37d315c108ef 100644 --- a/homeassistant/components/cielo_home/climate.py +++ b/homeassistant/components/cielo_home/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -103,28 +103,6 @@ class CieloClimate(CieloDeviceEntity, ClimateEntity): super().__init__(coordinator, device_id) self._attr_unique_id = device_id - @property - @override - def temperature_unit(self) -> str: - """Return the unit of temperature in Home Assistant format. - - It can change over time based on the device settings, - so we fetch it dynamically from the client. - """ - unit = self.client.temperature_unit() - - if not unit: - return UnitOfTemperature.CELSIUS - - normalized = unit.strip().lower() - - if normalized in {"c", "°c", "celsius"}: - return UnitOfTemperature.CELSIUS - if normalized in {"f", "°f", "fahrenheit"}: - return UnitOfTemperature.FAHRENHEIT - - return UnitOfTemperature.CELSIUS - @property @override def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/cielo_home/const.py b/homeassistant/components/cielo_home/const.py index dbc3d68d342a..1818f472b5c0 100644 --- a/homeassistant/components/cielo_home/const.py +++ b/homeassistant/components/cielo_home/const.py @@ -11,12 +11,16 @@ from homeassistant.const import Platform DOMAIN: Final = "cielo_home" PLATFORMS: Final[list[Platform]] = [ Platform.CLIMATE, + Platform.SENSOR, ] DEFAULT_NAME: Final = "Cielo Home" DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60 TIMEOUT: Final[int] = 20 LOGGER: Final = logging.getLogger(__package__) +SENSOR_TEMPERATURE: Final = "temperature" +SENSOR_HUMIDITY: Final = "humidity" + CIELO_ERRORS: Final[tuple] = ( ClientError, TimeoutError, diff --git a/homeassistant/components/cielo_home/entity.py b/homeassistant/components/cielo_home/entity.py index dfe2e9760440..fdceea79f40f 100644 --- a/homeassistant/components/cielo_home/entity.py +++ b/homeassistant/components/cielo_home/entity.py @@ -5,6 +5,7 @@ from typing import override from cieloconnectapi.device import CieloDeviceAPI from cieloconnectapi.model import CieloDevice +from homeassistant.const import UnitOfTemperature from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -12,6 +13,26 @@ from .const import DOMAIN from .coordinator import CieloDataUpdateCoordinator +def normalize_temp_unit(client: CieloDeviceAPI) -> str: + """Normalize a raw device temperature unit to a UnitOfTemperature value. + + Unrecognized or empty values fall back to Celsius. + """ + unit = client.temperature_unit() + + if not unit: + return UnitOfTemperature.CELSIUS + + normalized = unit.strip().lower() + + if normalized in {"c", "°c", "celsius"}: + return UnitOfTemperature.CELSIUS + if normalized in {"f", "°f", "fahrenheit"}: + return UnitOfTemperature.FAHRENHEIT + + return UnitOfTemperature.CELSIUS + + class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]): """Representation of a Cielo base entity.""" @@ -74,3 +95,14 @@ class CieloDeviceEntity(CieloBaseEntity): configuration_url="https://home.cielowigle.com/", suggested_area=device.name, ) + + @property + def temperature_unit(self) -> str: + """Return the unit of temperature for the device. + + The unit can change over time based on the device settings, + so it is fetched dynamically from the client. This dynamic + nature means that if a user changes the device's temperature + unit, historical statistics may be affected. + """ + return normalize_temp_unit(self.client) diff --git a/homeassistant/components/cielo_home/sensor.py b/homeassistant/components/cielo_home/sensor.py new file mode 100644 index 000000000000..b1cfe14e24d9 --- /dev/null +++ b/homeassistant/components/cielo_home/sensor.py @@ -0,0 +1,101 @@ +"""Support for Cielo Home sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from cieloconnectapi.device import CieloDeviceAPI +from cieloconnectapi.model import CieloDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SENSOR_HUMIDITY, SENSOR_TEMPERATURE +from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry +from .entity import CieloDeviceEntity, normalize_temp_unit + + +@dataclass(kw_only=True, frozen=True) +class CieloSensorEntityDescription(SensorEntityDescription): + """Describes a Cielo Home sensor entity.""" + + value_fn: Callable[[CieloDeviceAPI, CieloDevice | None], float | int | None] + unit_fn: Callable[[CieloDeviceAPI], str | None] | None = None + + +SENSOR_DESCRIPTIONS: tuple[CieloSensorEntityDescription, ...] = ( + CieloSensorEntityDescription( + key=SENSOR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client, device_data: client.current_temperature(), + # Temperature unit is dynamic; see the native_unit_of_measurement property for limitations. + unit_fn=normalize_temp_unit, + ), + CieloSensorEntityDescription( + key=SENSOR_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda client, device_data: ( + device_data.humidity if device_data else None + ), + ), +) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CieloHomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Cielo Home sensors.""" + coordinator = entry.runtime_data + + entities = [ + CieloSensor(coordinator, device_id, description) + for device_id in coordinator.data.parsed + for description in SENSOR_DESCRIPTIONS + ] + async_add_entities(entities) + + +class CieloSensor(CieloDeviceEntity, SensorEntity): + """Representation of a Cielo Home sensor.""" + + entity_description: CieloSensorEntityDescription + + def __init__( + self, + coordinator: CieloDataUpdateCoordinator, + device_id: str, + entity_description: CieloSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id) + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}-{entity_description.key}" + + @property + @override + def native_value(self) -> float | int | None: + """Return the native value of the sensor.""" + return self.entity_description.value_fn(self.client, self.device_data) + + @property + @override + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + if self.entity_description.unit_fn is not None: + return self.entity_description.unit_fn(self.client) + return super().native_unit_of_measurement diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 94f952b7737a..81d859b2a978 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -17,13 +17,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( APPLICATION_NAME, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, EVENT_HOMEASSISTANT_CLOSE, + EntityStateAttribute, UnitOfLength, __version__, ) @@ -235,8 +234,8 @@ class CityBikesStation(SensorEntity): self._attr_native_value = station.free_bikes self._attr_extra_state_attributes = { ATTR_UID: station.extra.get(ATTR_UID), - ATTR_LATITUDE: station.latitude, - ATTR_LONGITUDE: station.longitude, + EntityStateAttribute.LATITUDE: station.latitude, + EntityStateAttribute.LONGITUDE: station.longitude, ATTR_EMPTY_SLOTS: station.empty_slots, ATTR_FREE_EBIKES: station.extra.get(EXTRA_EBIKES), ATTR_TIMESTAMP: station.timestamp, diff --git a/homeassistant/components/climate/llm.py b/homeassistant/components/climate/llm.py new file mode 100644 index 000000000000..31a8e3f1e5c0 --- /dev/null +++ b/homeassistant/components/climate/llm.py @@ -0,0 +1,37 @@ +"""LLM tools for the climate integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN, INTENT_SET_TEMPERATURE + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_SET_TEMPERATURE,) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index bb324eea1b21..2dd10c9b59c9 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -46,6 +46,9 @@ PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_GOOGLE_CONNECTED = "google_connected" PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" PREF_ENABLE_CLOUD_ICE_SERVERS = "cloud_ice_servers_enabled" +PREF_ONBOARDED_ITEMS = "onboarded_items" +PREF_ONBOARDING_POSTPONED_UNTIL = "onboarding_postponed_until" +ONBOARDING_ITEMS = {"remote", "backup", "voice", "streaming"} DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index fbbfaab73d56..a962c1fb6853 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress import dataclasses +from datetime import timedelta from functools import wraps from http import HTTPStatus import json @@ -39,6 +40,7 @@ from homeassistant.loader import ( async_get_custom_components, async_get_loaded_integration, ) +from homeassistant.util import dt as dt_util from homeassistant.util.location import async_detect_location_info from homeassistant.util.package import async_get_installed_packages @@ -50,6 +52,7 @@ from .const import ( DATA_CLOUD_LOG_HANDLER, EVENT_CLOUD_EVENT, LOGIN_MFA_TIMEOUT, + ONBOARDING_ITEMS, PREF_ALEXA_REPORT_STATE, PREF_DISABLE_2FA, PREF_ENABLE_ALEXA, @@ -99,6 +102,8 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_remote_connect) websocket_api.async_register_command(hass, websocket_remote_disconnect) websocket_api.async_register_command(hass, websocket_webrtc_ice_servers) + websocket_api.async_register_command(hass, websocket_cloud_onboarding_postpone) + websocket_api.async_register_command(hass, websocket_cloud_onboarding_complete) websocket_api.async_register_command(hass, google_assistant_get) websocket_api.async_register_command(hass, google_assistant_list) @@ -844,6 +849,48 @@ async def websocket_update_prefs( connection.send_message(websocket_api.result_message(msg["id"])) +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command({vol.Required("type"): "cloud/onboarding/postpone"}) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def websocket_cloud_onboarding_postpone( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle request to postpone onboarding.""" + cloud = hass.data[DATA_CLOUD] + postponed_until = (dt_util.utcnow() + timedelta(hours=24)).isoformat() + await cloud.client.prefs.async_update(onboarding_postponed_until=postponed_until) + connection.send_result(msg["id"], await _account_data(hass, cloud)) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.websocket_command( + { + vol.Required("type"): "cloud/onboarding/complete", + vol.Required("items"): [vol.In(ONBOARDING_ITEMS)], + } +) +@websocket_api.async_response +@_ws_handle_cloud_errors +async def websocket_cloud_onboarding_complete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle request to complete onboarding items.""" + cloud = hass.data[DATA_CLOUD] + onboarded_items = list(cloud.client.prefs.onboarded_items) + new_items = [item for item in msg["items"] if item not in onboarded_items] + if new_items: + onboarded_items.extend(dict.fromkeys(new_items)) + await cloud.client.prefs.async_update(onboarded_items=onboarded_items) + connection.send_result(msg["id"], await _account_data(hass, cloud)) + + @websocket_api.require_admin @_require_cloud_login @websocket_api.websocket_command( @@ -930,6 +977,8 @@ async def _account_data( "google_local_connected": google_config.is_local_connected, "logged_in": True, "prefs": client.prefs.as_dict(), + "onboarding_completed": client.prefs.onboarding_completed, + "onboarding_postponed": client.prefs.onboarding_postponed, "remote_certificate": certificate, "remote_certificate_status": remote.certificate_status, "remote_connected": remote.is_connected, diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 72941c5a5523..bd5f79524ac4 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==2.2.0", "openai==2.21.0"], + "requirements": ["hass-nabucasa==2.2.0", "openai==2.45.0"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 3def31727c27..7fcf1fbd7d42 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -15,6 +15,7 @@ from homeassistant.components.google_assistant.http import ( # pylint: disable= from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType +from homeassistant.util import dt as dt_util from homeassistant.util.logging import async_create_catching_coro from .const import ( @@ -23,6 +24,7 @@ from .const import ( DEFAULT_GOOGLE_REPORT_STATE, DEFAULT_TTS_DEFAULT_VOICE, DOMAIN, + ONBOARDING_ITEMS, PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, @@ -41,6 +43,8 @@ from .const import ( PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SETTINGS_VERSION, PREF_INSTANCE_ID, + PREF_ONBOARDED_ITEMS, + PREF_ONBOARDING_POSTPONED_UNTIL, PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_DOMAIN, PREF_TTS_DEFAULT_VOICE, @@ -175,6 +179,8 @@ class CloudPreferences: google_settings_version: int | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, remote_domain: str | None | UndefinedType = UNDEFINED, + onboarded_items: list[str] | UndefinedType = UNDEFINED, + onboarding_postponed_until: str | None | UndefinedType = UNDEFINED, remote_enabled: bool | UndefinedType = UNDEFINED, tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, ) -> None: @@ -197,6 +203,8 @@ class CloudPreferences: (PREF_GOOGLE_REPORT_STATE, google_report_state), (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), + (PREF_ONBOARDED_ITEMS, onboarded_items), + (PREF_ONBOARDING_POSTPONED_UNTIL, onboarding_postponed_until), (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), (PREF_REMOTE_DOMAIN, remote_domain), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), @@ -247,6 +255,8 @@ class CloudPreferences: PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, + PREF_ONBOARDED_ITEMS: self.onboarded_items, + PREF_ONBOARDING_POSTPONED_UNTIL: self.onboarding_postponed_until, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, } @@ -359,6 +369,31 @@ class CloudPreferences: """Return the instance ID.""" return self._prefs.get(PREF_INSTANCE_ID) + @property + def onboarded_items(self) -> list[str]: + """Return list of completed onboarding items.""" + onboarded_items: list[str] = self._prefs.get(PREF_ONBOARDED_ITEMS, []) + return onboarded_items + + @property + def onboarding_completed(self) -> bool: + """Return if all onboarding items are completed.""" + return ONBOARDING_ITEMS.issubset(self.onboarded_items) + + @property + def onboarding_postponed_until(self) -> str | None: + """Return the datetime until which onboarding is postponed.""" + return self._prefs.get(PREF_ONBOARDING_POSTPONED_UNTIL) + + @property + def onboarding_postponed(self) -> bool: + """Return if onboarding is currently postponed.""" + if (postponed_until := self.onboarding_postponed_until) is None: + return False + if (parsed := dt_util.parse_datetime(postponed_until)) is None: + return False + return parsed > dt_util.utcnow() + @property def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice. @@ -430,6 +465,8 @@ class CloudPreferences: PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), PREF_INSTANCE_ID: uuid.uuid4().hex, PREF_GOOGLE_SECURE_DEVICES_PIN: None, + PREF_ONBOARDED_ITEMS: [], + PREF_ONBOARDING_POSTPONED_UNTIL: None, PREF_REMOTE_DOMAIN: None, PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 0f12c8856d30..ef78235ef704 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -6,14 +6,12 @@ from typing import Any, override import numpy as np from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, SensorEntity, + SensorEntityCapabilityAttribute, ) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_ATTRIBUTE, CONF_DEVICE_CLASS, CONF_MAXIMUM, @@ -24,6 +22,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -172,16 +171,18 @@ class CompensationSensor(SensorEntity): if self.native_unit_of_measurement is None and self._source_attribute is None: self._attr_native_unit_of_measurement = new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ) if self._attr_device_class is None and ( - device_class := new_state.attributes.get(ATTR_DEVICE_CLASS) + device_class := new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS) ): self._attr_device_class = device_class if self._attr_state_class is None and ( - state_class := new_state.attributes.get(ATTR_STATE_CLASS) + state_class := new_state.attributes.get( + SensorEntityCapabilityAttribute.STATE_CLASS + ) ): self._attr_state_class = state_class diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 43d398fc2994..94accda30fbd 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -52,7 +52,7 @@ class CheckConfigView(HomeAssistantView): "type": "config/core/update", vol.Optional("country"): cv.country, vol.Optional("currency"): cv.currency, - vol.Optional("elevation"): int, + vol.Optional("elevation"): vol.Coerce(int), vol.Optional("external_url"): vol.Any(cv.url_no_path, None), vol.Optional("internal_url"): vol.Any(cv.url_no_path, None), vol.Optional("language"): cv.language, diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index c8b99ed1d516..149fccc0a84e 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -14,7 +14,7 @@ from typing import Any import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME +from homeassistant.const import EntityStateAttribute from homeassistant.core import ( HassJob, HomeAssistant, @@ -181,8 +181,8 @@ class Configurator: data = { ATTR_CONFIGURE_ID: request_id, ATTR_FIELDS: fields, - ATTR_FRIENDLY_NAME: name, - ATTR_ENTITY_PICTURE: entity_picture, + EntityStateAttribute.FRIENDLY_NAME: name, + EntityStateAttribute.ENTITY_PICTURE: entity_picture, } data.update( diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 5cb8eb0cf61b..e5fe5de9fef7 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -1,7 +1,6 @@ """The Control4 integration.""" from dataclasses import dataclass -import json import logging from typing import Any @@ -84,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> config = entry.data account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) try: - await account.getAccountBearerToken() + await account.get_account_bearer_token() except client_exceptions.ClientError as exception: _LOGGER.error("Error connecting to Control4 account API: %s", exception) raise ConfigEntryNotReady( @@ -103,7 +102,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> controller_unique_id: str = config[CONF_CONTROLLER_UNIQUE_ID] director_token_dict = await call_c4_api_retry( - account.getDirectorBearerToken, controller_unique_id + account.get_director_bearer_token, controller_unique_id ) director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) @@ -111,9 +110,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - controller_href = (await call_c4_api_retry(account.getAccountControllers))["href"] + controller_href = (await call_c4_api_retry(account.get_account_controllers))["href"] director_sw_version = await call_c4_api_retry( - account.getControllerOSVersion, controller_href + account.get_controller_os_version, controller_href ) _, model, mac_address = controller_unique_id.split("_", 3) @@ -132,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> # Store all items found on controller for platforms to use try: - all_items_raw = await director.getAllItemInfo() + director_all_items: list[dict[str, Any]] = await director.get_all_item_info() except (TimeoutError, client_exceptions.ClientError) as err: _LOGGER.error( "Timeout connecting to Control4 controller at %s", @@ -142,13 +141,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> f"Timeout connecting to Control4 controller at {config[CONF_HOST]}" ) from err - director_all_items: list[dict[str, Any]] = json.loads(all_items_raw) - # Check if OS version is 3 or higher to get UI configuration ui_configuration: dict[str, Any] | None = None if int(director_sw_version.split(".")[0]) >= 3: try: - ui_config_raw = await director.getUiConfiguration() + ui_configuration = await director.get_ui_configuration() except (TimeoutError, client_exceptions.ClientError) as err: _LOGGER.error( "Timeout getting UI configuration from Control4 controller at %s", @@ -159,8 +156,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> f" Control4 controller at {config[CONF_HOST]}" ) from err - ui_configuration = json.loads(ui_config_raw) - # Load options from config entry scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index 1894498c93ed..764b8f9da14e 100644 --- a/homeassistant/components/control4/climate.py +++ b/homeassistant/components/control4/climate.py @@ -370,7 +370,7 @@ class Control4Climate(Control4Entity, ClimateEntity): """Set new target HVAC mode.""" c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode] c4_climate = self._create_api_object() - await c4_climate.setHvacMode(c4_hvac_mode) + await c4_climate.set_hvac_mode(c4_hvac_mode) await self.coordinator.async_request_refresh() @override @@ -385,26 +385,26 @@ class Control4Climate(Control4Entity, ClimateEntity): if self.hvac_mode == HVACMode.HEAT_COOL: if low_temp is not None: if self.temperature_unit == UnitOfTemperature.CELSIUS: - await c4_climate.setHeatSetpointC(low_temp) + await c4_climate.set_heat_setpoint_c(low_temp) else: - await c4_climate.setHeatSetpointF(low_temp) + await c4_climate.set_heat_setpoint_f(low_temp) if high_temp is not None: if self.temperature_unit == UnitOfTemperature.CELSIUS: - await c4_climate.setCoolSetpointC(high_temp) + await c4_climate.set_cool_setpoint_c(high_temp) else: - await c4_climate.setCoolSetpointF(high_temp) + await c4_climate.set_cool_setpoint_f(high_temp) # Handle single temperature setpoint elif temp is not None: if self.hvac_mode == HVACMode.COOL: if self.temperature_unit == UnitOfTemperature.CELSIUS: - await c4_climate.setCoolSetpointC(temp) + await c4_climate.set_cool_setpoint_c(temp) else: - await c4_climate.setCoolSetpointF(temp) + await c4_climate.set_cool_setpoint_f(temp) elif self.hvac_mode == HVACMode.HEAT: if self.temperature_unit == UnitOfTemperature.CELSIUS: - await c4_climate.setHeatSetpointC(temp) + await c4_climate.set_heat_setpoint_c(temp) else: - await c4_climate.setHeatSetpointF(temp) + await c4_climate.set_heat_setpoint_f(temp) await self.coordinator.async_request_refresh() @@ -412,5 +412,5 @@ class Control4Climate(Control4Entity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" c4_climate = self._create_api_object() - await c4_climate.setFanMode(fan_mode.title()) + await c4_climate.set_fan_mode(fan_mode.title()) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 508161ce7059..74f1c34d8224 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -64,13 +64,13 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): account_session = aiohttp_client.async_get_clientsession(self.hass) account = C4Account(username, password, account_session) try: - await account.getAccountBearerToken() + await account.get_account_bearer_token() - account_controllers = await account.getAccountControllers() + account_controllers = await account.get_account_controllers() controller_unique_id = account_controllers["controllerCommonName"] director_bearer_token = ( - await account.getDirectorBearerToken(controller_unique_id) + await account.get_director_bearer_token(controller_unique_id) )["token"] except BadCredentials, Unauthorized: errors["base"] = "invalid_auth" @@ -91,7 +91,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): ) director = C4Director(host, director_bearer_token, director_session) try: - await director.getAllItemInfo() + await director.get_all_item_info() except Unauthorized: errors["base"] = "director_auth_failed" return errors, data, description_placeholders diff --git a/homeassistant/components/control4/cover.py b/homeassistant/components/control4/cover.py index dba2efe87768..5b01ed406f1d 100644 --- a/homeassistant/components/control4/cover.py +++ b/homeassistant/components/control4/cover.py @@ -225,5 +225,5 @@ class Control4Cover(Control4Entity, CoverEntity): async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" c4_blind = self._create_api_object() - await c4_blind.setLevelTarget(kwargs[ATTR_POSITION]) + await c4_blind.set_level_target(kwargs[ATTR_POSITION]) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index a26c5f9f4138..bcb1d50e377d 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -23,7 +23,7 @@ async def _update_variables_for_config_entry( ) -> dict[int, dict[str, Any]]: """Retrieve data from the Control4 director.""" director = entry.runtime_data.director - data = await director.getAllItemVariableValue(variable_names) + data = await director.get_all_item_variable_value(variable_names) result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) for item in data: result_dict[item["id"]][item["varName"]] = item["value"] @@ -48,10 +48,10 @@ async def refresh_tokens(hass: HomeAssistant, entry: Control4ConfigEntry): account_session = aiohttp_client.async_get_clientsession(hass) account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) - await account.getAccountBearerToken() + await account.get_account_bearer_token() controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] - director_token_dict = await account.getDirectorBearerToken(controller_unique_id) + director_token_dict = await account.get_director_bearer_token(controller_unique_id) director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) director = C4Director( diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index c51909e2340c..aa40b041ac2c 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -115,7 +115,7 @@ async def async_setup_entry( item_coordinator = non_dimmer_coordinator else: director = runtime_data.director - item_variables = await director.getItemVariables(item_id) + item_variables = await director.get_item_variables(item_id) _LOGGER.warning( ( "Couldn't get light state data for %s, skipping setup. Available" @@ -229,10 +229,10 @@ class Control4Light(Control4Entity, LightEntity): brightness = (kwargs[ATTR_BRIGHTNESS] / 255) * 100 else: brightness = 100 - await c4_light.rampToLevel(brightness, transition_length) + await c4_light.ramp_to_level(brightness, transition_length) else: transition_length = 0 - await c4_light.setLevel(100) + await c4_light.set_level(100) if transition_length == 0: transition_length = 1000 delay_time = (transition_length / 1000) + 0.7 @@ -249,10 +249,10 @@ class Control4Light(Control4Entity, LightEntity): transition_length = kwargs[ATTR_TRANSITION] * 1000 else: transition_length = 0 - await c4_light.rampToLevel(0, transition_length) + await c4_light.ramp_to_level(0, transition_length) else: transition_length = 0 - await c4_light.setLevel(0) + await c4_light.set_level(0) if transition_length == 0: transition_length = 1500 delay_time = (transition_length / 1000) + 0.7 diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json index 685ae8bf05a3..865e9353c7a0 100644 --- a/homeassistant/components/control4/manifest.json +++ b/homeassistant/components/control4/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyControl4"], - "requirements": ["pyControl4==1.5.0"], + "requirements": ["pyControl4==2.0.2"], "ssdp": [ { "st": "c4:director" diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 6faea420eef8..757a84dc6ef6 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -359,9 +359,9 @@ class Control4Room(Control4Entity, MediaPlayerEntity): if avail_source.name == source: audio_only = _SourceType.VIDEO not in avail_source.source_type if audio_only: - await self._create_api_object().setAudioSource(avail_source.idx) + await self._create_api_object().set_audio_source(avail_source.idx) else: - await self._create_api_object().setVideoAndAudioSource( + await self._create_api_object().set_video_and_audio_source( avail_source.idx ) break @@ -371,50 +371,50 @@ class Control4Room(Control4Entity, MediaPlayerEntity): @override async def async_turn_off(self) -> None: """Turn off the room.""" - await self._create_api_object().setRoomOff() + await self._create_api_object().set_room_off() await self.coordinator.async_request_refresh() @override async def async_mute_volume(self, mute: bool) -> None: """Mute the room.""" if mute: - await self._create_api_object().setMuteOn() + await self._create_api_object().set_mute_on() else: - await self._create_api_object().setMuteOff() + await self._create_api_object().set_mute_off() await self.coordinator.async_request_refresh() @override async def async_set_volume_level(self, volume: float) -> None: """Set room volume, 0-1 scale.""" - await self._create_api_object().setVolume(int(volume * 100)) + await self._create_api_object().set_volume(int(volume * 100)) await self.coordinator.async_request_refresh() @override async def async_volume_up(self) -> None: """Increase the volume by 1.""" - await self._create_api_object().setIncrementVolume() + await self._create_api_object().set_increment_volume() await self.coordinator.async_request_refresh() @override async def async_volume_down(self) -> None: """Decrease the volume by 1.""" - await self._create_api_object().setDecrementVolume() + await self._create_api_object().set_decrement_volume() await self.coordinator.async_request_refresh() @override async def async_media_pause(self) -> None: """Issue a pause command.""" - await self._create_api_object().setPause() + await self._create_api_object().set_pause() await self.coordinator.async_request_refresh() @override async def async_media_play(self) -> None: """Issue a play command.""" - await self._create_api_object().setPlay() + await self._create_api_object().set_play() await self.coordinator.async_request_refresh() @override async def async_media_stop(self) -> None: """Issue a stop command.""" - await self._create_api_object().setStop() + await self._create_api_object().set_stop() await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 536abf5b3dd5..b5466dfb5f3e 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "conversation", "name": "Conversation", "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"], - "dependencies": ["http", "intent"], + "dependencies": ["http", "intent", "llm"], "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 2f73f7c33d8f..31ac35cb756c 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -151,8 +151,7 @@ async def async_attach_trigger( if isinstance( automation_result, ScriptRunResult ) and automation_result.conversation_response not in (None, UNDEFINED): - # mypy does not understand the type narrowing, unclear why - return automation_result.conversation_response # type: ignore[return-value] + return automation_result.conversation_response # It's important to return None here instead of a string. # diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 2b5bf95042f6..28c535d7223d 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -6,7 +6,6 @@ from typing import Any, Self, override import voluptuous as vol from homeassistant.const import ( - ATTR_EDITABLE, CONF_ICON, CONF_ID, CONF_MAXIMUM, @@ -20,6 +19,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import CounterEntityStateAttribute + _LOGGER = logging.getLogger(__name__) ATTR_INITIAL = "initial" @@ -205,14 +206,14 @@ class Counter(collection.CollectionEntity, RestoreEntity): def extra_state_attributes(self) -> dict: """Return the state attributes.""" ret = { - ATTR_EDITABLE: self.editable, - ATTR_INITIAL: self._config[CONF_INITIAL], - ATTR_STEP: self._config[CONF_STEP], + CounterEntityStateAttribute.EDITABLE: self.editable, + CounterEntityStateAttribute.INITIAL: self._config[CONF_INITIAL], + CounterEntityStateAttribute.STEP: self._config[CONF_STEP], } if self._config[CONF_MINIMUM] is not None: - ret[CONF_MINIMUM] = self._config[CONF_MINIMUM] + ret[CounterEntityStateAttribute.MINIMUM] = self._config[CONF_MINIMUM] if self._config[CONF_MAXIMUM] is not None: - ret[CONF_MAXIMUM] = self._config[CONF_MAXIMUM] + ret[CounterEntityStateAttribute.MAXIMUM] = self._config[CONF_MAXIMUM] return ret @property diff --git a/homeassistant/components/counter/const.py b/homeassistant/components/counter/const.py new file mode 100644 index 000000000000..5d7b14e91fdf --- /dev/null +++ b/homeassistant/components/counter/const.py @@ -0,0 +1,13 @@ +"""Constants for the counter integration.""" + +from enum import StrEnum + + +class CounterEntityStateAttribute(StrEnum): + """State attributes for counter entities.""" + + EDITABLE = "editable" + INITIAL = "initial" + STEP = "step" + MINIMUM = "minimum" + MAXIMUM = "maximum" diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py index 30d390e5588c..5423da44e58e 100644 --- a/homeassistant/components/counter/reproduce_state.py +++ b/homeassistant/components/counter/reproduce_state.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import Context, HomeAssistant, State -from . import ATTR_MAXIMUM, ATTR_MINIMUM, ATTR_STEP, DOMAIN, SERVICE_SET_VALUE, VALUE +from . import DOMAIN, SERVICE_SET_VALUE, VALUE _LOGGER = logging.getLogger(__name__) @@ -32,25 +32,13 @@ async def _async_reproduce_state( return # Return if we are already at the right state. - if ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_MAXIMUM) == state.attributes.get(ATTR_MAXIMUM) - and cur_state.attributes.get(ATTR_MINIMUM) == state.attributes.get(ATTR_MINIMUM) - and cur_state.attributes.get(ATTR_STEP) == state.attributes.get(ATTR_STEP) - ): + if cur_state.state == state.state: return service_data = {ATTR_ENTITY_ID: state.entity_id, VALUE: state.state} - service = SERVICE_SET_VALUE - if ATTR_MAXIMUM in state.attributes: - service_data[ATTR_MAXIMUM] = state.attributes[ATTR_MAXIMUM] - if ATTR_MINIMUM in state.attributes: - service_data[ATTR_MINIMUM] = state.attributes[ATTR_MINIMUM] - if ATTR_STEP in state.attributes: - service_data[ATTR_STEP] = state.attributes[ATTR_STEP] await hass.services.async_call( - DOMAIN, service, service_data, context=context, blocking=True + DOMAIN, SERVICE_SET_VALUE, service_data, context=context, blocking=True ) diff --git a/homeassistant/components/counter/trigger.py b/homeassistant/components/counter/trigger.py index 8913b703b901..c5431367b80c 100644 --- a/homeassistant/components/counter/trigger.py +++ b/homeassistant/components/counter/trigger.py @@ -2,7 +2,6 @@ from typing import override -from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -12,7 +11,8 @@ from homeassistant.helpers.trigger import ( Trigger, ) -from . import CONF_INITIAL, DOMAIN +from . import DOMAIN +from .const import CounterEntityStateAttribute def _is_integer_state(state: State) -> bool: @@ -74,7 +74,9 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger): report_not_triggered: NotTriggeredReasonReporter, ) -> bool: """Check if the new state matches the expected state(s).""" - if (max_value := state.attributes.get(CONF_MAXIMUM)) is None: + if ( + max_value := state.attributes.get(CounterEntityStateAttribute.MAXIMUM) + ) is None: return False return state.state == str(max_value) @@ -89,7 +91,9 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger): report_not_triggered: NotTriggeredReasonReporter, ) -> bool: """Check if the new state matches the expected state(s).""" - if (min_value := state.attributes.get(CONF_MINIMUM)) is None: + if ( + min_value := state.attributes.get(CounterEntityStateAttribute.MINIMUM) + ) is None: return False return state.state == str(min_value) @@ -104,7 +108,9 @@ class CounterResetTrigger(CounterValueBaseTrigger): report_not_triggered: NotTriggeredReasonReporter, ) -> bool: """Check if the new state matches the expected state(s).""" - if (init_state := state.attributes.get(CONF_INITIAL)) is None: + if ( + init_state := state.attributes.get(CounterEntityStateAttribute.INITIAL) + ) is None: return False return state.state == str(init_state) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index dfb353b8719e..ade738d2f7f9 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.18.1"], + "requirements": ["pydaikin==2.18.2"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/data_grand_lyon/__init__.py b/homeassistant/components/data_grand_lyon/__init__.py index 2f33bfe1d4f7..a7ccf285fe3d 100644 --- a/homeassistant/components/data_grand_lyon/__init__.py +++ b/homeassistant/components/data_grand_lyon/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .coordinator import ( DataGrandLyonConfigEntry, DataGrandLyonData, + DataGrandLyonParkAndRideCoordinator, DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator, ) @@ -32,13 +33,19 @@ async def async_setup_entry( tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client) velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client) + park_and_ride_coordinator = DataGrandLyonParkAndRideCoordinator(hass, entry, client) - coordinators: list[DataUpdateCoordinator] = [tcl_coordinator, velov_coordinator] + coordinators: list[DataUpdateCoordinator] = [ + tcl_coordinator, + velov_coordinator, + park_and_ride_coordinator, + ] await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators)) entry.runtime_data = DataGrandLyonData( tcl_coordinator=tcl_coordinator, velov_coordinator=velov_coordinator, + park_and_ride_coordinator=park_and_ride_coordinator, ) entry.async_on_unload(entry.add_update_listener(async_update_entry)) diff --git a/homeassistant/components/data_grand_lyon/config_flow.py b/homeassistant/components/data_grand_lyon/config_flow.py index 4cc15287542a..97b1df9f5e5b 100644 --- a/homeassistant/components/data_grand_lyon/config_flow.py +++ b/homeassistant/components/data_grand_lyon/config_flow.py @@ -7,8 +7,10 @@ from typing import Any, override from aiohttp import ClientError, ClientResponseError from data_grand_lyon_ha import ( DataGrandLyonClient, + TclParkAndRide, TclStop, VelovStation, + find_tcl_park_and_ride_by_id, find_tcl_stop_by_id, ) import voluptuous as vol @@ -32,9 +34,11 @@ from homeassistant.helpers.selector import ( from .const import ( CONF_LINE, + CONF_PARK_ID, CONF_STATION_ID, CONF_STOP_ID, DOMAIN, + SUBENTRY_TYPE_PARK_AND_RIDE, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION, ) @@ -70,6 +74,7 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN): return { SUBENTRY_TYPE_STOP: StopSubentryFlowHandler, SUBENTRY_TYPE_VELOV_STATION: VelovStationSubentryFlowHandler, + SUBENTRY_TYPE_PARK_AND_RIDE: ParkAndRideSubentryFlowHandler, } @override @@ -396,3 +401,81 @@ def _velov_station_label(station: VelovStation) -> str: label += f" - {station.number}" return label + + +class ParkAndRideSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for adding a TCL park-and-ride (P+R).""" + + def __init__(self) -> None: + """Initialize the flow.""" + self._parks: list[TclParkAndRide] = [] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Pick a park-and-ride from the list fetched from the API, or enter one.""" + if not self._parks: + if error := await self._async_load_park_and_rides(): + return self.async_abort(reason=error) + + if user_input is not None: + park_id = user_input[CONF_PARK_ID] + entry = self._get_entry() + unique_id = f"park_and_ride_{park_id}" + + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="already_configured") + + park = find_tcl_park_and_ride_by_id(self._parks, park_id) + return self.async_create_entry( + title=park.nom if park else park_id, + data={CONF_PARK_ID: park_id}, + unique_id=unique_id, + ) + + options = [ + SelectOptionDict(value=park.id, label=_park_and_ride_label(park)) + for park in sorted(self._parks, key=lambda p: (p.nom, p.id)) + ] + schema = vol.Schema( + { + vol.Required(CONF_PARK_ID): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + sort=False, + custom_value=True, + ) + ) + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + async def _async_load_park_and_rides(self) -> str | None: + """Fetch park-and-rides from the API, returning an error key on failure.""" + entry = self._get_entry() + session = async_get_clientsession(self.hass) + client = DataGrandLyonClient( + session=session, + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + try: + self._parks = await client.get_tcl_park_and_rides() + except ClientResponseError as err: + if err.status in (401, 403): + return "invalid_auth" + return "cannot_connect" + except ClientError, TimeoutError: + return "cannot_connect" + except Exception: + _LOGGER.exception( + "Unexpected error fetching Data Grand Lyon park-and-rides" + ) + return "unknown" + return None + + +def _park_and_ride_label(park: TclParkAndRide) -> str: + return f"{park.nom} - {park.id}" diff --git a/homeassistant/components/data_grand_lyon/const.py b/homeassistant/components/data_grand_lyon/const.py index 49c66613a8e0..e77e2fcda134 100644 --- a/homeassistant/components/data_grand_lyon/const.py +++ b/homeassistant/components/data_grand_lyon/const.py @@ -7,7 +7,9 @@ LOGGER = logging.getLogger(__package__) SUBENTRY_TYPE_STOP = "stop" SUBENTRY_TYPE_VELOV_STATION = "velov_station" +SUBENTRY_TYPE_PARK_AND_RIDE = "park_and_ride" CONF_LINE = "line" CONF_STOP_ID = "stop_id" CONF_STATION_ID = "station_id" +CONF_PARK_ID = "park_id" diff --git a/homeassistant/components/data_grand_lyon/coordinator.py b/homeassistant/components/data_grand_lyon/coordinator.py index e3e25dc71af2..66562a9c23e9 100644 --- a/homeassistant/components/data_grand_lyon/coordinator.py +++ b/homeassistant/components/data_grand_lyon/coordinator.py @@ -7,9 +7,11 @@ from typing import override from aiohttp import ClientError, ClientResponseError from data_grand_lyon_ha import ( DataGrandLyonClient, + TclParkAndRide, TclPassage, VelovStation, filter_tcl_passages_by_lines_stops, + find_tcl_park_and_ride_by_id, find_velov_stations_by_ids, sort_tcl_passages_by_time, ) @@ -21,10 +23,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( CONF_LINE, + CONF_PARK_ID, CONF_STATION_ID, CONF_STOP_ID, DOMAIN, LOGGER, + SUBENTRY_TYPE_PARK_AND_RIDE, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION, ) @@ -36,6 +40,7 @@ class DataGrandLyonData: tcl_coordinator: DataGrandLyonTclCoordinator velov_coordinator: DataGrandLyonVelovCoordinator + park_and_ride_coordinator: DataGrandLyonParkAndRideCoordinator type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData] @@ -169,3 +174,66 @@ class DataGrandLyonVelovCoordinator(DataUpdateCoordinator[dict[str, VelovStation subentry.subentry_id, ) return velov_stations + + +class DataGrandLyonParkAndRideCoordinator( + DataUpdateCoordinator[dict[str, TclParkAndRide]] +): + """Coordinator for TCL park-and-ride (P+R) facilities.""" + + config_entry: DataGrandLyonConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: DataGrandLyonConfigEntry, + client: DataGrandLyonClient, + ) -> None: + """Initialize the coordinator.""" + self.client = client + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_park_and_ride", + update_interval=timedelta(minutes=5), + ) + + @override + async def _async_update_data(self) -> dict[str, TclParkAndRide]: + """Fetch data for all monitored park-and-ride facilities.""" + park_subentries = list( + self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_PARK_AND_RIDE) + ) + if not park_subentries: + return {} + + try: + all_parks = await self.client.get_tcl_park_and_rides() + except ClientResponseError as err: + if err.status in (401, 403): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_park_and_ride", + ) from err + except (ClientError, TimeoutError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_park_and_ride", + ) from err + + parks: dict[str, TclParkAndRide] = {} + for subentry in park_subentries: + park = find_tcl_park_and_ride_by_id(all_parks, subentry.data[CONF_PARK_ID]) + if park is not None: + parks[subentry.subentry_id] = park + else: + LOGGER.warning( + "Park-and-ride not found for subentry %s", + subentry.subentry_id, + ) + return parks diff --git a/homeassistant/components/data_grand_lyon/diagnostics.py b/homeassistant/components/data_grand_lyon/diagnostics.py index 8e7788e4025d..3907b40a6b75 100644 --- a/homeassistant/components/data_grand_lyon/diagnostics.py +++ b/homeassistant/components/data_grand_lyon/diagnostics.py @@ -27,5 +27,9 @@ async def async_get_config_entry_diagnostics( subentry_id: asdict(station) for subentry_id, station in entry.runtime_data.velov_coordinator.data.items() }, + "park_and_rides": { + subentry_id: asdict(park) + for subentry_id, park in entry.runtime_data.park_and_ride_coordinator.data.items() + }, }, } diff --git a/homeassistant/components/data_grand_lyon/entity.py b/homeassistant/components/data_grand_lyon/entity.py index d65bb85fac7f..3f47311dc697 100644 --- a/homeassistant/components/data_grand_lyon/entity.py +++ b/homeassistant/components/data_grand_lyon/entity.py @@ -11,7 +11,11 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import DOMAIN -from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator +from .coordinator import ( + DataGrandLyonParkAndRideCoordinator, + DataGrandLyonTclCoordinator, + DataGrandLyonVelovCoordinator, +) class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator]( @@ -75,3 +79,18 @@ class DataGrandLyonVelovEntity(DataGrandLyonEntity[DataGrandLyonVelovCoordinator ) -> None: """Initialize the Vélo'v entity.""" super().__init__(coordinator, subentry, description, "JCDecaux", "Station") + + +class DataGrandLyonParkAndRideEntity( + DataGrandLyonEntity[DataGrandLyonParkAndRideCoordinator] +): + """Base entity for Data Grand Lyon park-and-ride facilities.""" + + def __init__( + self, + coordinator: DataGrandLyonParkAndRideCoordinator, + subentry: ConfigSubentry, + description: EntityDescription, + ) -> None: + """Initialize the park-and-ride entity.""" + super().__init__(coordinator, subentry, description, "TCL", "Park & Ride") diff --git a/homeassistant/components/data_grand_lyon/icons.json b/homeassistant/components/data_grand_lyon/icons.json index 893b337880de..b8351d150a72 100644 --- a/homeassistant/components/data_grand_lyon/icons.json +++ b/homeassistant/components/data_grand_lyon/icons.json @@ -9,6 +9,9 @@ } }, "sensor": { + "accessible_spaces": { + "default": "mdi:wheelchair-accessibility" + }, "available_bikes": { "default": "mdi:bike" }, @@ -18,6 +21,9 @@ "available_mechanical_bikes": { "default": "mdi:bike" }, + "available_spaces": { + "default": "mdi:parking" + }, "available_stands": { "default": "mdi:parking" }, @@ -65,6 +71,9 @@ "state": { "estimated": "mdi:clock-check-outline" } + }, + "park_and_ride_capacity": { + "default": "mdi:counter" } } } diff --git a/homeassistant/components/data_grand_lyon/sensor.py b/homeassistant/components/data_grand_lyon/sensor.py index 0da6e49a7264..4e72d87c0733 100644 --- a/homeassistant/components/data_grand_lyon/sensor.py +++ b/homeassistant/components/data_grand_lyon/sensor.py @@ -6,20 +6,29 @@ from datetime import datetime from typing import override from zoneinfo import ZoneInfo -from data_grand_lyon_ha import TclPassage, TclPassageType, VelovStation +from data_grand_lyon_ha import TclParkAndRide, TclPassage, TclPassageType, VelovStation from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION +from .const import ( + SUBENTRY_TYPE_PARK_AND_RIDE, + SUBENTRY_TYPE_STOP, + SUBENTRY_TYPE_VELOV_STATION, +) from .coordinator import DataGrandLyonConfigEntry -from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity +from .entity import ( + DataGrandLyonParkAndRideEntity, + DataGrandLyonTclEntity, + DataGrandLyonVelovEntity, +) PARALLEL_UPDATES = 0 @@ -164,6 +173,36 @@ VELOV_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonVelovSensorEntityDescription, ...] ) +@dataclass(frozen=True, kw_only=True) +class DataGrandLyonParkAndRideSensorEntityDescription(SensorEntityDescription): + """Describes a Data Grand Lyon park-and-ride sensor entity.""" + + value_fn: Callable[[TclParkAndRide], StateType] + + +PARK_AND_RIDE_SENSOR_DESCRIPTIONS: tuple[ + DataGrandLyonParkAndRideSensorEntityDescription, ... +] = ( + DataGrandLyonParkAndRideSensorEntityDescription( + key="available_spaces", + translation_key="available_spaces", + value_fn=lambda p: p.nb_tot_place_dispo, + ), + DataGrandLyonParkAndRideSensorEntityDescription( + key="capacity", + translation_key="park_and_ride_capacity", + value_fn=lambda p: p.capacite, + ), + DataGrandLyonParkAndRideSensorEntityDescription( + key="accessible_spaces", + translation_key="accessible_spaces", + value_fn=lambda p: p.place_handi, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: DataGrandLyonConfigEntry, @@ -172,6 +211,7 @@ async def async_setup_entry( """Set up Data Grand Lyon sensor entities.""" tcl_coordinator = entry.runtime_data.tcl_coordinator velov_coordinator = entry.runtime_data.velov_coordinator + park_and_ride_coordinator = entry.runtime_data.park_and_ride_coordinator for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP): async_add_entities( @@ -191,6 +231,17 @@ async def async_setup_entry( config_subentry_id=subentry.subentry_id, ) + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_PARK_AND_RIDE): + async_add_entities( + ( + DataGrandLyonParkAndRideSensor( + park_and_ride_coordinator, subentry, description + ) + for description in PARK_AND_RIDE_SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry.subentry_id, + ) + class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity): """Sensor for Data Grand Lyon stop departures.""" @@ -227,3 +278,17 @@ class DataGrandLyonVelovSensor(DataGrandLyonVelovEntity, SensorEntity): return self.entity_description.value_fn( self.coordinator.data[self._subentry_id] ) + + +class DataGrandLyonParkAndRideSensor(DataGrandLyonParkAndRideEntity, SensorEntity): + """Sensor for Data Grand Lyon park-and-ride facility.""" + + entity_description: DataGrandLyonParkAndRideSensorEntityDescription + + @property + @override + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn( + self.coordinator.data[self._subentry_id] + ) diff --git a/homeassistant/components/data_grand_lyon/strings.json b/homeassistant/components/data_grand_lyon/strings.json index 43be121570da..eb08a461a352 100644 --- a/homeassistant/components/data_grand_lyon/strings.json +++ b/homeassistant/components/data_grand_lyon/strings.json @@ -44,6 +44,28 @@ } }, "config_subentries": { + "park_and_ride": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "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%]" + }, + "entry_type": "Park & ride", + "initiate_flow": { + "user": "Add park & ride" + }, + "step": { + "user": { + "data": { + "park_id": "Park & ride" + }, + "data_description": { + "park_id": "Search by name, or enter a park & ride ID directly." + } + } + } + }, "stop": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", @@ -107,6 +129,10 @@ } }, "sensor": { + "accessible_spaces": { + "name": "Accessible parking spaces", + "unit_of_measurement": "spaces" + }, "available_bikes": { "name": "Available bikes", "unit_of_measurement": "bikes" @@ -119,6 +145,10 @@ "name": "Available mechanical bikes", "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::available_bikes::unit_of_measurement%]" }, + "available_spaces": { + "name": "Available parking spaces", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::accessible_spaces::unit_of_measurement%]" + }, "available_stands": { "name": "Available stands", "unit_of_measurement": "stands" @@ -173,6 +203,10 @@ "estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]", "theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]" } + }, + "park_and_ride_capacity": { + "name": "Capacity", + "unit_of_measurement": "[%key:component::data_grand_lyon::entity::sensor::accessible_spaces::unit_of_measurement%]" } } }, @@ -180,6 +214,9 @@ "auth_failed": { "message": "Authentication failed for Data Grand Lyon." }, + "update_failed_park_and_ride": { + "message": "Error fetching park & ride availability from Data Grand Lyon." + }, "update_failed_tcl": { "message": "Error fetching TCL departures from Data Grand Lyon." }, diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index bff21a0c69cb..c06112f820a2 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,11 +1,11 @@ """deCONZ services.""" -from typing import TYPE_CHECKING - +from pydeconz import errors from pydeconz.utils import normalize_bridge_id import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -14,14 +14,10 @@ from homeassistant.helpers import ( from homeassistant.helpers.service import async_register_admin_service from homeassistant.util.read_only_dict import ReadOnlyDict -from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER +from .const import CONF_BRIDGE_ID, DOMAIN from .hub import DeconzHub from .util import get_master_hub -if TYPE_CHECKING: - from . import DeconzConfigEntry - - DECONZ_SERVICES = "deconz_services" SERVICE_FIELD = "field" @@ -68,27 +64,34 @@ def async_setup_services(hass: HomeAssistant) -> None: service_data = service_call.data if CONF_BRIDGE_ID in service_data: - found_hub = False bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID]) - entry: DeconzConfigEntry - for entry in hass.config_entries.async_loaded_entries(DOMAIN): - possible_hub = entry.runtime_data - if possible_hub.bridgeid == bridge_id: - hub = possible_hub - found_hub = True - break + hub: DeconzHub | None = next( + ( + entry.runtime_data + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.runtime_data.bridgeid == bridge_id + ), + None, + ) + + if hub is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="gateway_not_found", + translation_placeholders={ + "bridge_id": bridge_id, + }, + ) - if not found_hub: - LOGGER.error("Could not find the gateway %s", bridge_id) - return else: try: hub = get_master_hub(hass) - # pylint: disable-next=home-assistant-action-swallowed-exception - except ValueError: - LOGGER.error("No master gateway available") - return + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_master_gateway", + ) from err if service == SERVICE_CONFIGURE_DEVICE: await async_configure_service(hub, service_data) @@ -132,19 +135,38 @@ async def async_configure_service(hub: DeconzHub, data: ReadOnlyDict) -> None: if entity_id: try: field = hub.deconz_ids[entity_id] + field - except KeyError: - LOGGER.error("Could not find the entity %s", entity_id) - return + except KeyError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={ + "entity_id": entity_id, + }, + ) from err - await hub.api.request("put", field, json=data) + try: + await hub.api.request("put", field, json=data) + except (TimeoutError, errors.pydeconzException) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="configure_failed", + ) from err async def async_refresh_devices_service(hub: DeconzHub) -> None: """Refresh available devices from deCONZ.""" hub.ignore_state_updates = True - await hub.api.refresh_state() - hub.load_ignored_devices() - hub.ignore_state_updates = False + + try: + await hub.api.refresh_state() + hub.load_ignored_devices() + except (TimeoutError, errors.pydeconzException) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_refresh_failed", + ) from err + finally: + hub.ignore_state_updates = False async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: @@ -183,6 +205,7 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: if entry.device_id in devices_to_be_removed: devices_to_be_removed.remove(entry.device_id) continue + # Remove entities that are not available entities_to_be_removed.append(entry.entity_id) @@ -195,7 +218,9 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: if ( len( er.async_entries_for_device( - entity_registry, device_id, include_disabled_entities=True + entity_registry, + device_id, + include_disabled_entities=True, ) ) == 0 diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 2d21faa073b3..9789b4b464a0 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -96,6 +96,23 @@ "remote_turned_counter_clockwise": "Device turned counterclockwise" } }, + "exceptions": { + "configure_failed": { + "message": "Failed to configure device" + }, + "device_refresh_failed": { + "message": "Failed to refresh devices" + }, + "entity_not_found": { + "message": "Could not find entity {entity_id}" + }, + "gateway_not_found": { + "message": "Could not find gateway {bridge_id}" + }, + "no_master_gateway": { + "message": "No master gateway available" + } + }, "options": { "step": { "deconz_devices": { diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index df35a749ee20..f0731f8f0a78 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -407,9 +407,9 @@ class DemoTVShowPlayer(AbstractDemoPlayer): class DemoBrowsePlayer(AbstractDemoPlayer): - """A Demo media player that supports browse.""" + """A Demo media player that supports browse and search.""" - _attr_supported_features = BROWSE_PLAYER_SUPPORT + _attr_supported_features = BROWSE_PLAYER_SUPPORT | SEARCH_PLAYER_SUPPORT @override async def async_browse_media( @@ -421,6 +421,13 @@ class DemoBrowsePlayer(AbstractDemoPlayer): return await media_source.async_browse_media(self.hass, media_content_id) + @override + async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia: + """Implement the websocket media search helper by delegating to media source.""" + return await media_source.async_search_media( + self.hass, query.media_content_id, query + ) + class DemoGroupPlayer(AbstractDemoPlayer): """A Demo media player that supports grouping.""" diff --git a/homeassistant/components/denon_rs232/manifest.json b/homeassistant/components/denon_rs232/manifest.json index e50677a5f4f6..d8a0c98e96d4 100644 --- a/homeassistant/components/denon_rs232/manifest.json +++ b/homeassistant/components/denon_rs232/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["denon_rs232"], "quality_scale": "bronze", - "requirements": ["denon-rs232==4.1.0"] + "requirements": ["denon-rs232==4.2.1"] } diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index ce593e5f8f8c..9814bb80b6d9 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 53e4e5dfa727..437eb92e336c 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -8,12 +8,7 @@ import voluptuous as vol from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_NAME, - CONF_SOURCE, - UnitOfTime, -) +from homeassistant.const import CONF_NAME, CONF_SOURCE, EntityStateAttribute, UnitOfTime from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( @@ -59,13 +54,16 @@ def entity_selector_compatible( """Return an entity selector which compatible entities.""" current = handler.hass.states.get(handler.options[CONF_SOURCE]) unit_of_measurement = ( - current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None + current.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + if current + else None ) entities = [ ent.entity_id for ent in handler.hass.states.async_all(ALLOWED_DOMAINS) - if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement + if ent.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + == unit_of_measurement and ent.domain in ALLOWED_DOMAINS ] diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ab7431fbef2c..0e25e72ea1b4 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -8,23 +8,22 @@ from typing import override import voluptuous as vol from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, DEVICE_CLASS_UNITS, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, RestoreSensor, SensorDeviceClass, SensorEntity, + SensorEntityCapabilityAttribute, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_SOURCE, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, Platform, UnitOfTime, ) @@ -243,7 +242,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if not source_state: return - source_class_raw = source_state.attributes.get(ATTR_DEVICE_CLASS) + source_class_raw = source_state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) source_class: SensorDeviceClass | None = None if isinstance(source_class_raw, str): try: @@ -252,7 +253,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity): source_class = None if self._string_unit_prefix is not None and self._string_unit_time is not None: original_unit = self._attr_native_unit_of_measurement - source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + source_unit = source_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) if ( ( source_class @@ -366,7 +369,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity): last_state = await self.async_get_last_state() if last_state: - self._attr_device_class = last_state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_device_class = last_state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) @override async def async_added_to_hass(self) -> None: @@ -540,7 +545,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # A negative derivative for a total increasing sensor likely indicates the # sensor has been reset. To prevent inaccurate data, discard this sample. if ( - new_state.attributes.get(ATTR_STATE_CLASS) + new_state.attributes.get(SensorEntityCapabilityAttribute.STATE_CLASS) == SensorStateClass.TOTAL_INCREASING and new_derivative < 0 ): diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index b2fa4bbb06f0..f7c5bfc32b5c 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -43,6 +43,32 @@ ENTITY_PLATFORMS = { } +def _resolve_device_id(hass: HomeAssistant, device_id: str, domain: str) -> str: + """Resolve a device automation device id, following a composite device id. + + A device automation created when a device could be connected to more than one + config entry stores the id of the (now removed) composite device. When the + automation's domain owns one of the split devices' config entries, resolve to that + device - an integration may look the device up in its own registry, which only + knows the current device id, not the removed composite id. + """ + device_registry = dr.async_get(hass) + if device_id in device_registry.devices: + return device_id + if not ( + split_devices := device_registry.async_get_devices_for_composite_device_id( + device_id + ) + ): + return device_id + # Resolve to the device owned by a config entry of the automation's domain + for split_device in split_devices: + entry = hass.config_entries.async_get_entry(split_device.config_entry_id) + if entry is not None and entry.domain == domain: + return split_device.id + return device_id + + async def async_validate_device_automation_config( hass: HomeAssistant, config: ConfigType, @@ -51,6 +77,17 @@ async def async_validate_device_automation_config( ) -> ConfigType: """Validate config.""" validated_config: ConfigType = automation_schema(config) + + # A device automation may reference a pre-migration composite device id; resolve it + # to the split device for its domain so the device and its entities exist and the + # integration platform (validation and attach/call) receives a live device id + resolved_device_id = _resolve_device_id( + hass, validated_config[CONF_DEVICE_ID], validated_config[CONF_DOMAIN] + ) + if resolved_device_id != validated_config[CONF_DEVICE_ID]: + config = {**config, CONF_DEVICE_ID: resolved_device_id} + validated_config = {**validated_config, CONF_DEVICE_ID: resolved_device_id} + platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 8f1489256a6a..6e50faa06eb2 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -36,7 +36,11 @@ from .const import ( # noqa: F401 LOGGER, PLATFORM_TYPE_LEGACY, SCAN_INTERVAL, + DeviceTrackerEntityCapabilityAttribute, + DeviceTrackerEntityStateAttribute, + ScannerEntityStateAttribute, SourceType, + TrackerEntityStateAttribute, TrackingType, ) from .entity import ( # noqa: F401 diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 920e0f5994e6..7fcbb74ca3d0 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -5,6 +5,7 @@ from enum import StrEnum import logging from typing import Final +from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers from homeassistant.util.signal_type import SignalType LOGGER: Final = logging.getLogger(__package__) @@ -50,11 +51,18 @@ class DeviceTrackerEntityStateAttribute(StrEnum): IN_ZONES = "in_zones" -class TrackerEntityStateAttribute(StrEnum): +class TrackerEntityStateAttribute( + StrEnum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "LATITUDE": ("EntityStateAttribute.LATITUDE", "2027.2.0"), + "LONGITUDE": ("EntityStateAttribute.LONGITUDE", "2027.2.0"), + }, +): """State attributes set by TrackerEntity.""" - LATITUDE = "latitude" - LONGITUDE = "longitude" + LATITUDE = "latitude" # Deprecated, replaced with EntityStateAttribute.LATITUDE + LONGITUDE = "longitude" # Deprecated, replaced with EntityStateAttribute.LONGITUDE GPS_ACCURACY = "gps_accuracy" diff --git a/homeassistant/components/device_tracker/entity.py b/homeassistant/components/device_tracker/entity.py index 7f1b77b37198..ffa8a1197fc6 100644 --- a/homeassistant/components/device_tracker/entity.py +++ b/homeassistant/components/device_tracker/entity.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, final, override from propcache.api import cached_property from homeassistant.components import zone -from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS +from homeassistant.components.zone import ZoneEntityStateAttribute from homeassistant.const import ( # noqa: F401 ATTR_BATTERY_LEVEL, ATTR_GPS_ACCURACY, @@ -16,6 +16,7 @@ from homeassistant.const import ( # noqa: F401 STATE_HOME, STATE_NOT_HOME, EntityCategory, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -368,10 +369,14 @@ class TrackerEntity( for entity_id in zones if (zone_state := self.hass.states.get(entity_id)) is not None ), - key=lambda z: z.attributes[ATTR_RADIUS], + key=lambda z: z.attributes[ZoneEntityStateAttribute.RADIUS], ) self.__active_zone = next( - (z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)), + ( + z + for z in zone_states + if not z.attributes.get(ZoneEntityStateAttribute.PASSIVE) + ), None, ) self.__in_zones = [z.entity_id for z in zone_states] @@ -418,8 +423,8 @@ class TrackerEntity( attr.update(super().state_attributes) if self.latitude is not None and self.longitude is not None: - attr[TrackerEntityStateAttribute.LATITUDE] = self.latitude - attr[TrackerEntityStateAttribute.LONGITUDE] = self.longitude + attr[EntityStateAttribute.LATITUDE] = self.latitude + attr[EntityStateAttribute.LONGITUDE] = self.longitude attr[TrackerEntityStateAttribute.GPS_ACCURACY] = self.location_accuracy return attr @@ -702,17 +707,29 @@ class ScannerEntity( await super().async_internal_added_to_hass() return - # Attach entry to device - if self.registry_entry.device_id != device_entry.id: - self.registry_entry = er.async_get(self.hass).async_update_entity( - self.entity_id, device_id=device_entry.id + dev_reg = dr.async_get(self.hass) + # find_device_entry may return a synthesized pre-migration composite whose id is + # not a real device and can't be assigned to an entity; resolve it to the split + # owned by this config entry so we attach to a concrete device. + if device_entry.id not in dev_reg.devices: + device_entry = next( + ( + split + for split in dev_reg.async_get_devices_for_composite_device_id( + device_entry.id + ) + if split.config_entry_id == self.platform.config_entry.entry_id + ), + None, ) - # Attach device to config entry - if self.platform.config_entry.entry_id not in device_entry.config_entries: - dr.async_get(self.hass).async_update_device( - device_entry.id, - add_config_entry_id=self.platform.config_entry.entry_id, + # Attach entry to device + if ( + device_entry is not None + and self.registry_entry.device_id != device_entry.id + ): + self.registry_entry = er.async_get(self.hass).async_update_entity( + self.entity_id, device_id=device_entry.id ) # Do this last or else the entity registry update listener has been installed diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 5fedb6686515..a3fb9fdb4457 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -24,8 +24,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, ATTR_ICON, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_NAME, CONF_ICON, CONF_MAC, @@ -34,6 +32,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_HOME, STATE_NOT_HOME, + EntityStateAttribute, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError @@ -77,7 +76,9 @@ from .const import ( LOGGER, PLATFORM_TYPE_LEGACY, SCAN_INTERVAL, + DeviceTrackerEntityStateAttribute, SourceType, + TrackerEntityStateAttribute, ) _LOGGER = logging.getLogger(__name__) @@ -509,8 +510,8 @@ def async_setup_scanner_platform( zone_home = hass.states.get(ENTITY_ID_HOME) if zone_home is not None: kwargs["gps"] = [ - zone_home.attributes[ATTR_LATITUDE], - zone_home.attributes[ATTR_LONGITUDE], + zone_home.attributes[EntityStateAttribute.LATITUDE], + zone_home.attributes[EntityStateAttribute.LONGITUDE], ] kwargs["gps_accuracy"] = 0 @@ -840,12 +841,14 @@ class Device(RestoreEntity): @override def state_attributes(self) -> dict[str, StateType]: """Return the device state attributes.""" - attributes: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type} + attributes: dict[str, StateType] = { + DeviceTrackerEntityStateAttribute.SOURCE_TYPE: self.source_type + } if self.gps is not None: - attributes[ATTR_LATITUDE] = self.gps[0] - attributes[ATTR_LONGITUDE] = self.gps[1] - attributes[ATTR_GPS_ACCURACY] = self.gps_accuracy + attributes[EntityStateAttribute.LATITUDE] = self.gps[0] + attributes[EntityStateAttribute.LONGITUDE] = self.gps[1] + attributes[TrackerEntityStateAttribute.GPS_ACCURACY] = self.gps_accuracy if self.battery is not None: attributes[ATTR_BATTERY] = self.battery @@ -952,17 +955,17 @@ class Device(RestoreEntity): self.last_seen = dt_util.utcnow() for attribute, var in ( - (ATTR_SOURCE_TYPE, "source_type"), - (ATTR_GPS_ACCURACY, "gps_accuracy"), + (DeviceTrackerEntityStateAttribute.SOURCE_TYPE, "source_type"), + (TrackerEntityStateAttribute.GPS_ACCURACY, "gps_accuracy"), (ATTR_BATTERY, "battery"), ): if attribute in state.attributes: setattr(self, var, state.attributes[attribute]) - if ATTR_LONGITUDE in state.attributes: + if EntityStateAttribute.LONGITUDE in state.attributes: self.gps = ( - state.attributes[ATTR_LATITUDE], - state.attributes[ATTR_LONGITUDE], + state.attributes[EntityStateAttribute.LATITUDE], + state.attributes[EntityStateAttribute.LONGITUDE], ) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index f497b273bcc9..48f6077d5371 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -61,8 +61,13 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp from . import websocket_api from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from .helpers import async_discovered_service_info from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers +__all__ = [ + "async_discovered_service_info", +] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) REGISTERED_DEVICES: Final = "registered_devices" diff --git a/homeassistant/components/dhcp/helpers.py b/homeassistant/components/dhcp/helpers.py index 7acf26f76fde..9c81fce1405c 100644 --- a/homeassistant/components/dhcp/helpers.py +++ b/homeassistant/components/dhcp/helpers.py @@ -4,7 +4,9 @@ from collections.abc import Callable from functools import partial from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from .const import HOSTNAME, IP_ADDRESS from .models import DATA_DHCP, DHCPAddressData @@ -33,3 +35,16 @@ def async_get_address_data_internal( This is not intended for use by integrations. """ return hass.data[DATA_DHCP].address_data + + +@callback +def async_discovered_service_info(hass: HomeAssistant) -> list[DhcpServiceInfo]: + """Return the discovered DHCP devices.""" + return [ + DhcpServiceInfo( + ip=data[IP_ADDRESS], + hostname=data[HOSTNAME].lower(), + macaddress=mac_address, + ) + for mac_address, data in async_get_address_data_internal(hass).items() + ] diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 9d4b53093055..bca2cc73fd9e 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -36,9 +36,14 @@ from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType -from .util import async_redact_data, entity_entry_as_dict +from .util import async_redact_data, device_entry_as_dict, entity_entry_as_dict -__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"] +__all__ = [ + "REDACTED", + "async_redact_data", + "device_entry_as_dict", + "entity_entry_as_dict", +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index 5dd6085e2df0..9326961c5d8c 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -6,6 +6,7 @@ from typing import Any, cast, overload import attr from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry from .const import REDACTED @@ -45,6 +46,33 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T: return cast(_T, redacted) +# DeviceEntry attributes that are internal bookkeeping and must not be exposed in +# diagnostics. Underscore attributes (_cache, _suggested_area, and the transient +# _pending_move / _composite_subentries) are excluded separately by _device_entry_filter. +# The composite-device migration attributes below can be removed in HA Core 2027.8. +_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = ( + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", +) + + +def _device_entry_filter(a: attr.Attribute, _: Any) -> bool: + return ( + not a.name.startswith("_") and a.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES + ) + + +@callback +def device_entry_as_dict(entry: DeviceEntry) -> dict[str, Any]: + """Convert a device registry entry to a dict for diagnostics. + + This excludes internal fields that should not be exposed in diagnostics. + """ + return attr.asdict(entry, filter=_device_entry_filter) + + def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool: return a.name not in ( "_cache", diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py index 8c1e80527f8c..dd577fb3c72c 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -37,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: finally: await discord_bot.close() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data - hass.async_create_task( discovery.async_load_platform( hass, Platform.NOTIFY, DOMAIN, dict(entry.data), hass.data[DATA_HASS_CONFIG] diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index dc218ac32a48..d1290658b04c 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -9,7 +9,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.47.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index f92582336f47..09e75ff3b0ca 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.46.2"], + "requirements": ["async-upnp-client==0.47.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index bee7cb77b297..1694a024a833 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==12.2.0"] + "requirements": ["pydoods==1.0.2", "Pillow==12.3.0"] } diff --git a/homeassistant/components/doorbell/trigger.py b/homeassistant/components/doorbell/trigger.py index 6d68ddb8889b..6e54e1ad8ba1 100644 --- a/homeassistant/components/doorbell/trigger.py +++ b/homeassistant/components/doorbell/trigger.py @@ -3,10 +3,10 @@ from typing import override from homeassistant.components.event import ( - ATTR_EVENT_TYPE, DOMAIN as EVENT_DOMAIN, DoorbellEventType, EventDeviceClass, + EventEntityStateAttribute, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec @@ -29,7 +29,10 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase): report_not_triggered: NotTriggeredReasonReporter, ) -> bool: """Check if the event type is ring.""" - return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING + return ( + state.attributes.get(EventEntityStateAttribute.EVENT_TYPE) + == DoorbellEventType.RING + ) TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 4bfe4791d247..439307d8dbac 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -5,11 +5,8 @@ from functools import partial from typing import Any, override from dsmr_parser import obis_references as obis_ref -from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader -from dsmr_parser.clients.rfxtrx_protocol import ( - create_rfxtrx_dsmr_reader, - create_rfxtrx_tcp_dsmr_reader, -) +from dsmr_parser.clients.protocol import create_dsmr_reader +from dsmr_parser.clients.rfxtrx_protocol import create_rfxtrx_dsmr_reader from dsmr_parser.objects import DSMRObject import voluptuous as vol @@ -19,7 +16,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE +from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import SerialPortSelector @@ -41,11 +38,8 @@ from .const import ( class DSMRConnection: """Test the connection to DSMR and receive telegram to read serial ids.""" - def __init__( - self, host: str | None, port: int, dsmr_version: str, protocol: str - ) -> None: + def __init__(self, port: str, dsmr_version: str, protocol: str) -> None: """Initialize.""" - self._host = host self._port = port self._dsmr_version = dsmr_version self._protocol = protocol @@ -86,34 +80,20 @@ class DSMRConnection: self._telegram = telegram transport.close() - if self._host is None: - if self._protocol == DSMR_PROTOCOL: - create_reader = create_dsmr_reader - else: - create_reader = create_rfxtrx_dsmr_reader - reader_factory = partial( - create_reader, - self._port, - self._dsmr_version, - update_telegram, - loop=hass.loop, - ) + if self._protocol == DSMR_PROTOCOL: + create_reader = create_dsmr_reader else: - if self._protocol == DSMR_PROTOCOL: - create_reader = create_tcp_dsmr_reader - else: - create_reader = create_rfxtrx_tcp_dsmr_reader - reader_factory = partial( - create_reader, - self._host, - self._port, - self._dsmr_version, - update_telegram, - loop=hass.loop, - ) + create_reader = create_rfxtrx_dsmr_reader + reader_factory = partial( + create_reader, + self._port, + self._dsmr_version, + update_telegram, + loop=hass.loop, + ) try: - transport, protocol = await asyncio.create_task(reader_factory()) + transport, protocol = await reader_factory() except OSError: LOGGER.exception("Error connecting to DSMR") return False @@ -136,7 +116,6 @@ async def _validate_dsmr_connection( ) -> dict[str, str | None]: """Validate the user input allows us to connect.""" conn = DSMRConnection( - data.get(CONF_HOST), data[CONF_PORT], data[CONF_DSMR_VERSION], protocol, @@ -176,48 +155,12 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Step when user initializes a integration.""" - if user_input is not None: - user_selection = user_input[CONF_TYPE] - if user_selection == "Serial": - return await self.async_step_setup_serial() + """Step when user initializes an integration. - return await self.async_step_setup_network() - - list_of_types = ["Serial", "Network"] - - schema = vol.Schema({vol.Required(CONF_TYPE): vol.In(list_of_types)}) - return self.async_show_form(step_id="user", data_schema=schema) - - async def async_step_setup_network( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step when setting up network configuration.""" - errors: dict[str, str] = {} - if user_input is not None: - data = await self.async_validate_dsmr(user_input, errors) - if not errors: - return self.async_create_entry( - title=f"{data[CONF_HOST]}:{data[CONF_PORT]}", data=data - ) - - schema = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT): int, - vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS), - } - ) - return self.async_show_form( - step_id="setup_network", - data_schema=schema, - errors=errors, - ) - - async def async_step_setup_serial( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step when setting up serial configuration.""" + A single serial port selector handles both local serial devices and + network connections; a network meter can be reached by entering a URL + such as ``socket://host:port``. + """ errors: dict[str, str] = {} if user_input is not None: data = await self.async_validate_dsmr(user_input, errors) @@ -231,7 +174,7 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): } ) return self.async_show_form( - step_id="setup_serial", + step_id="user", data_schema=schema, errors=errors, ) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index c32d947e610c..dba3a4d6e314 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.9.0"] + "requirements": ["dsmr-parser==1.11.1"] } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 01407013cce7..e4340404a1b4 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -9,8 +9,9 @@ from datetime import timedelta from enum import IntEnum from functools import partial from typing import override +from urllib.parse import urlparse -from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader +from dsmr_parser.clients.protocol import create_dsmr_reader from dsmr_parser.clients.rfxtrx_protocol import ( create_rfxtrx_dsmr_reader, create_rfxtrx_tcp_dsmr_reader, @@ -61,6 +62,7 @@ from .const import ( DOMAIN, DSMR_PROTOCOL, LOGGER, + RFXTRX_DSMR_PROTOCOL, ) EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}" @@ -770,34 +772,54 @@ async def async_setup_entry( hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), telegram ) - # Creates an asyncio.Protocol factory for reading DSMR telegrams from - # serial and calls update_entities_telegram to update entities on arrival - protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL) + # Legacy network entries stored host and port separately; combine them into + # the single socket://host:port form newer entries already use, in memory + # only, so the stored entry stays untouched and rolling back to an older + # Home Assistant version keeps working. + port = entry.data[CONF_PORT] if CONF_HOST in entry.data: - if protocol == DSMR_PROTOCOL: - create_reader = create_tcp_dsmr_reader + port = f"socket://{entry.data[CONF_HOST]}:{port}" + + # Creates an asyncio.Protocol factory for reading DSMR telegrams and calls + # update_entities_telegram to update entities on arrival. A port starting + # with "/" is a local serial device, which doesn't need a liveness check; + # anything else is a network connection that can drop silently, so it gets a + # keep-alive watchdog that closes the connection (triggering a reconnect) + # when no telegram arrives in time. + protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL) + if protocol == RFXTRX_DSMR_PROTOCOL: + if port.startswith("/"): + reader_factory = partial( + create_rfxtrx_dsmr_reader, + port, + dsmr_version, + update_entities_telegram, + loop=hass.loop, + ) else: - create_reader = create_rfxtrx_tcp_dsmr_reader - reader_factory = partial( - create_reader, - entry.data[CONF_HOST], - entry.data[CONF_PORT], - dsmr_version, - update_entities_telegram, - loop=hass.loop, - keep_alive_interval=60, - ) + # The RFXtrx serial reader has no keep-alive support, so the network + # host and port are fed to the dedicated TCP reader instead. + address = urlparse(port) + reader_factory = partial( + create_rfxtrx_tcp_dsmr_reader, + address.hostname, + address.port, + dsmr_version, + update_entities_telegram, + loop=hass.loop, + keep_alive_interval=60, + ) else: - if protocol == DSMR_PROTOCOL: - create_reader = create_dsmr_reader - else: - create_reader = create_rfxtrx_dsmr_reader + # create_dsmr_reader opens both local devices and any URL (socket://, + # esphome://, ...); the only difference is the keep-alive watchdog. + keep_alive = {} if port.startswith("/") else {"keep_alive_interval": 60} reader_factory = partial( - create_reader, - entry.data[CONF_PORT], + create_dsmr_reader, + port, dsmr_version, update_entities_telegram, loop=hass.loop, + **keep_alive, ) async def connect_and_reconnect() -> None: @@ -814,7 +836,7 @@ async def async_setup_entry( update_entities_telegram({}) try: - transport, protocol = await hass.loop.create_task(reader_factory()) + transport, protocol = await reader_factory() if transport: # Register listener to close transport on HA shutdown diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 519c23cc5694..f8fe86fa4368 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -11,26 +11,15 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { - "setup_network": { - "data": { - "dsmr_version": "Select DSMR version", - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]" - }, - "title": "Select connection address" - }, - "setup_serial": { - "data": { - "dsmr_version": "[%key:component::dsmr::config::step::setup_network::data::dsmr_version%]", - "port": "Select device" - }, - "title": "[%key:common::config_flow::data::device%]" - }, "user": { "data": { - "type": "Connection type" + "dsmr_version": "Select DSMR version", + "port": "Select device" }, - "title": "Select connection type" + "data_description": { + "port": "Select a serial device, or enter a network address such as socket://host:port" + }, + "title": "[%key:common::config_flow::data::device%]" } } }, diff --git a/homeassistant/components/duco/__init__.py b/homeassistant/components/duco/__init__.py index bc75a4597117..09c79893ad5f 100644 --- a/homeassistant/components/duco/__init__.py +++ b/homeassistant/components/duco/__init__.py @@ -17,8 +17,8 @@ _REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$") async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: """Set up Duco from a config entry.""" - # Remove entity registry entries for the temperature and box_temperature - # sensors that were removed when migrating to python-duco-connectivity. + # Clean up stale temperature registry entries so removed entities from the + # python-duco-connectivity migration do not linger after upgrade. entity_registry = er.async_get(hass) for entity_entry in er.async_entries_for_config_entry( entity_registry, entry.entry_id @@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool ) coordinator = DucoCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index 4e8ef4993a0f..10cbf619c96e 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -1,7 +1,7 @@ """Data update coordinator for the Duco integration.""" from contextlib import suppress -from dataclasses import dataclass +from dataclasses import dataclass, replace import logging from typing import cast, override @@ -11,7 +11,7 @@ from duco_connectivity.exceptions import ( DucoError, DucoResponseError, ) -from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList +from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList, NodeName from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -42,6 +42,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): config_entry: DucoConfigEntry board_info: BoardInfo _supports_time_filter_remain: bool + _configured_node_names: dict[int, str] def __init__( self, @@ -58,8 +59,25 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): update_interval=SCAN_INTERVAL, ) self.client = client + self._configured_node_names = {} self._supports_time_filter_remain = True + async def _async_load_node_names(self) -> None: + """Load configured Duco node names during setup.""" + try: + configured_node_names = await self.client.async_get_node_configs( + parameter="Name" + ) + except DucoError as err: + _LOGGER.debug("Could not fetch Duco node names", exc_info=err) + return + + self._configured_node_names = { + node.node_id: node.name.value + for node in configured_node_names.nodes + if node.name is not None + } + @override async def _async_setup(self) -> None: """Fetch board info once during initial setup.""" @@ -86,6 +104,8 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): translation_key="api_error", ) from err + await self._async_load_node_names() + @override async def _async_update_data(self) -> DucoData: """Fetch node data from the Duco box.""" @@ -102,6 +122,22 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): translation_key="api_error", ) from err + if self._configured_node_names: + nodes = [ + replace( + node, + general=replace( + node.general, + name=NodeName( + self._configured_node_names.get( + node.node_id, node.general.name + ) + ), + ), + ) + for node in nodes + ] + try: node_actions = await self.client.async_get_node_actions() except DucoError as err: diff --git a/homeassistant/components/duco/entity.py b/homeassistant/components/duco/entity.py index 851c99d2d0d4..b14332791f88 100644 --- a/homeassistant/components/duco/entity.py +++ b/homeassistant/components/duco/entity.py @@ -1,6 +1,6 @@ """Base entity for the Duco integration.""" -from typing import override +from typing import TYPE_CHECKING, override from duco_connectivity.models import Node, NodeType @@ -22,7 +22,8 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]): super().__init__(coordinator) self._node_id = node.node_id mac = coordinator.config_entry.unique_id - assert mac is not None + if TYPE_CHECKING: + assert mac is not None device_info = DeviceInfo( identifiers={(DOMAIN, f"{mac}_{node.node_id}")}, manufacturer="Duco", diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index 85cc883e0536..ee7222fe9c28 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.7.1"], + "requirements": ["python-duco-connectivity==0.10.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 7945f39aeb29..67818456dbe3 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -13,7 +13,7 @@ async def async_setup_entry( """Set up a config entry.""" device_registry = dr.async_get(hass) if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}): - device_registry.async_clear_config_entry(entry.entry_id) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) coordinator = DwdWeatherWarningsCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/dwd_weather_warnings/util.py b/homeassistant/components/dwd_weather_warnings/util.py index 01398ef595c6..1f35d7ecfadd 100644 --- a/homeassistant/components/dwd_weather_warnings/util.py +++ b/homeassistant/components/dwd_weather_warnings/util.py @@ -1,6 +1,6 @@ """Util functions for the dwd_weather_warnings integration.""" -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,19 +20,20 @@ def get_position_data( if entity is None: raise EntityNotFoundError(f"Failed to find entity {registry_entry.entity_id}") - latitude = entity.attributes.get(ATTR_LATITUDE) + latitude = entity.attributes.get(EntityStateAttribute.LATITUDE) if not latitude: raise AttributeError( - f"Failed to find attribute '{ATTR_LATITUDE}' in {registry_entry.entity_id}", - ATTR_LATITUDE, + f"Failed to find attribute '{EntityStateAttribute.LATITUDE}'" + f" in {registry_entry.entity_id}", + EntityStateAttribute.LATITUDE, ) - longitude = entity.attributes.get(ATTR_LONGITUDE) + longitude = entity.attributes.get(EntityStateAttribute.LONGITUDE) if not longitude: raise AttributeError( - f"Failed to find attribute '{ATTR_LONGITUDE}'" + f"Failed to find attribute '{EntityStateAttribute.LONGITUDE}'" f" in {registry_entry.entity_id}", - ATTR_LONGITUDE, + EntityStateAttribute.LONGITUDE, ) return (latitude, longitude) diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 672201b2c37b..d6040a427161 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -3,9 +3,9 @@ from typing import Any, override from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, CoverDeviceClass, CoverEntity, + CoverEntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -89,7 +89,7 @@ class DynaliteCover(DynaliteBase, CoverEntity): @override def initialize_state(self, state): """Initialize the state from cache.""" - target_level = state.attributes.get(ATTR_CURRENT_POSITION) + target_level = state.attributes.get(CoverEntityStateAttribute.CURRENT_POSITION) if target_level is not None: self._device.init_level(target_level) diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index 33a8c23c547d..c818f51b3602 100644 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -2,7 +2,11 @@ from typing import Any, override -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ( + ColorMode, + LightEntity, + LightEntityStateAttribute, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -52,6 +56,6 @@ class DynaliteLight(DynaliteBase, LightEntity): @override def initialize_state(self, state): """Initialize the state from cache.""" - target_level = state.attributes.get(ATTR_BRIGHTNESS) + target_level = state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) if target_level is not None: self._device.init_level(target_level) diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index e7462b40143a..1b0a4c031143 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -22,15 +22,26 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import _LOGGER, CONF_REFRESH_TOKEN, DOMAIN, PLATFORMS +from .services import async_setup_services MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + type EcobeeConfigEntry = ConfigEntry[EcobeeData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the ecobee integration.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" api_key = entry.data.get(CONF_API_KEY) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 81902a2cd2cd..dcc1e8f38416 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -20,7 +20,6 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, @@ -28,7 +27,7 @@ from homeassistant.const import ( STATE_ON, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -49,18 +48,20 @@ from .const import ( ECOBEE_MODEL_TO_NAME, MANUFACTURER, ) -from .util import ecobee_date, ecobee_time, is_indefinite_hold +from .services import ( + ATTR_COOL_TEMP, + ATTR_END_DATE, + ATTR_END_TIME, + ATTR_FAN_MIN_ON_TIME, + ATTR_FAN_MODE, + ATTR_HEAT_TEMP, + ATTR_START_DATE, + ATTR_START_TIME, + ATTR_VACATION_NAME, + _async_get_thermostats, +) +from .util import is_indefinite_hold -ATTR_COOL_TEMP = "cool_temp" -ATTR_END_DATE = "end_date" -ATTR_END_TIME = "end_time" -ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" -ATTR_FAN_MODE = "fan_mode" -ATTR_HEAT_TEMP = "heat_temp" -ATTR_RESUME_ALL = "resume_all" -ATTR_START_DATE = "start_date" -ATTR_START_TIME = "start_time" -ATTR_VACATION_NAME = "vacation_name" ATTR_DST_ENABLED = "dst_enabled" ATTR_MIC_ENABLED = "mic_enabled" ATTR_AUTO_AWAY = "auto_away" @@ -68,7 +69,6 @@ ATTR_FOLLOW_ME = "follow_me" ATTR_SENSOR_LIST = "device_ids" ATTR_PRESET_MODE = "preset_mode" -DEFAULT_RESUME_ALL = False PRESET_AWAY_INDEFINITELY = "away_indefinitely" PRESET_TEMPERATURE = "temp" PRESET_VACATION = "vacation" @@ -127,69 +127,11 @@ PRESET_TO_ECOBEE_HOLD = { PRESET_HOLD_INDEFINITE: "indefinite", } -SERVICE_CREATE_VACATION = "create_vacation" -SERVICE_DELETE_VACATION = "delete_vacation" -SERVICE_RESUME_PROGRAM = "resume_program" -SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" SERVICE_SET_DST_MODE = "set_dst_mode" SERVICE_SET_MIC_MODE = "set_mic_mode" SERVICE_SET_OCCUPANCY_MODES = "set_occupancy_modes" SERVICE_SET_SENSORS_USED_IN_CLIMATE = "set_sensors_used_in_climate" -DTGROUP_START_INCLUSIVE_MSG = ( - f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" -) - -DTGROUP_END_INCLUSIVE_MSG = ( - f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together" -) - -CREATE_VACATION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), - vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), - vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), - vol.Inclusive( - ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG - ): ecobee_date, - vol.Inclusive( - ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG - ): ecobee_time, - vol.Inclusive( - ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG - ): ecobee_date, - vol.Inclusive( - ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG - ): ecobee_time, - vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), - vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( - int, vol.Range(min=0, max=60) - ), - } -) - -DELETE_VACATION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), - } -) - -RESUME_PROGRAM_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, - } -) - -SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), - } -) - SUPPORT_FLAGS = ( ClimateEntityFeature.TARGET_TEMPERATURE @@ -226,96 +168,10 @@ async def async_setup_entry( entities.append(Thermostat(data, index, thermostat, hass)) async_add_entities(entities, True) + _async_get_thermostats(hass).extend(entities) platform = entity_platform.async_get_current_platform() - def create_vacation_service(service: ServiceCall) -> None: - """Create a vacation on the target thermostat.""" - entity_id = service.data[ATTR_ENTITY_ID] - - for thermostat in entities: - if thermostat.entity_id == entity_id: - thermostat.create_vacation(service.data) - thermostat.schedule_update_ha_state(True) - break - - def delete_vacation_service(service: ServiceCall) -> None: - """Delete a vacation on the target thermostat.""" - entity_id = service.data[ATTR_ENTITY_ID] - vacation_name = service.data[ATTR_VACATION_NAME] - - for thermostat in entities: - if thermostat.entity_id == entity_id: - thermostat.delete_vacation(vacation_name) - thermostat.schedule_update_ha_state(True) - break - - def fan_min_on_time_set_service(service: ServiceCall) -> None: - """Set the minimum fan on time on the target thermostats.""" - entity_id = service.data.get(ATTR_ENTITY_ID) - fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME] - - if entity_id: - target_thermostats = [ - entity for entity in entities if entity.entity_id in entity_id - ] - else: - target_thermostats = entities - - for thermostat in target_thermostats: - thermostat.set_fan_min_on_time(str(fan_min_on_time)) - - thermostat.schedule_update_ha_state(True) - - def resume_program_set_service(service: ServiceCall) -> None: - """Resume the program on the target thermostats.""" - entity_id = service.data.get(ATTR_ENTITY_ID) - resume_all = service.data.get(ATTR_RESUME_ALL) - - if entity_id: - target_thermostats = [ - entity for entity in entities if entity.entity_id in entity_id - ] - else: - target_thermostats = entities - - for thermostat in target_thermostats: - thermostat.resume_program(resume_all) - - thermostat.schedule_update_ha_state(True) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_VACATION, - create_vacation_service, - schema=CREATE_VACATION_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_DELETE_VACATION, - delete_vacation_service, - schema=DELETE_VACATION_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_FAN_MIN_ON_TIME, - fan_min_on_time_set_service, - schema=SET_FAN_MIN_ON_TIME_SCHEMA, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - resume_program_set_service, - schema=RESUME_PROGRAM_SCHEMA, - ) - platform.async_register_entity_service( SERVICE_SET_DST_MODE, {vol.Required(ATTR_DST_ENABLED): cv.boolean}, diff --git a/homeassistant/components/ecobee/services.py b/homeassistant/components/ecobee/services.py new file mode 100644 index 000000000000..86cdabe32055 --- /dev/null +++ b/homeassistant/components/ecobee/services.py @@ -0,0 +1,178 @@ +"""Services for the ecobee integration.""" + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .util import ecobee_date, ecobee_time + +if TYPE_CHECKING: + from .climate import Thermostat + +ATTR_COOL_TEMP = "cool_temp" +ATTR_END_DATE = "end_date" +ATTR_END_TIME = "end_time" +ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" +ATTR_FAN_MODE = "fan_mode" +ATTR_HEAT_TEMP = "heat_temp" +ATTR_RESUME_ALL = "resume_all" +ATTR_START_DATE = "start_date" +ATTR_START_TIME = "start_time" +ATTR_VACATION_NAME = "vacation_name" + +DEFAULT_RESUME_ALL = False + +DATA_THERMOSTATS = "thermostats" + +SERVICE_CREATE_VACATION = "create_vacation" +SERVICE_DELETE_VACATION = "delete_vacation" +SERVICE_RESUME_PROGRAM = "resume_program" +SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" + +DTGROUP_START_INCLUSIVE_MSG = ( + f"{ATTR_START_DATE} and {ATTR_START_TIME} must be specified together" +) + +DTGROUP_END_INCLUSIVE_MSG = ( + f"{ATTR_END_DATE} and {ATTR_END_TIME} must be specified together" +) + +CREATE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), + vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), + vol.Inclusive( + ATTR_START_DATE, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_START_TIME, "dtgroup_start", msg=DTGROUP_START_INCLUSIVE_MSG + ): ecobee_time, + vol.Inclusive( + ATTR_END_DATE, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_END_TIME, "dtgroup_end", msg=DTGROUP_END_INCLUSIVE_MSG + ): ecobee_time, + vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), + vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( + int, vol.Range(min=0, max=60) + ), + } +) + +DELETE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + } +) + +RESUME_PROGRAM_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, + } +) + +SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int), + } +) + + +@callback +def _async_get_thermostats(hass: HomeAssistant) -> list[Thermostat]: + """Return loaded ecobee thermostat entities.""" + # pylint: disable-next=home-assistant-use-runtime-data + return hass.data[DOMAIN][DATA_THERMOSTATS] + + +def _create_vacation_service(call: ServiceCall) -> None: + """Create a vacation on the target thermostat.""" + for thermostat in _async_get_thermostats(call.hass): + if thermostat.entity_id == call.data[ATTR_ENTITY_ID]: + thermostat.create_vacation(call.data) + thermostat.schedule_update_ha_state(True) + break + + +def _delete_vacation_service(call: ServiceCall) -> None: + """Delete a vacation on the target thermostat.""" + for thermostat in _async_get_thermostats(call.hass): + if thermostat.entity_id == call.data[ATTR_ENTITY_ID]: + thermostat.delete_vacation(call.data[ATTR_VACATION_NAME]) + thermostat.schedule_update_ha_state(True) + break + + +def _fan_min_on_time_set_service(call: ServiceCall) -> None: + """Set the minimum fan on time on the target thermostats.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + thermostats = _async_get_thermostats(call.hass) + if entity_id: + thermostats = [ + thermostat + for thermostat in thermostats + if thermostat.entity_id in entity_id + ] + + for thermostat in thermostats: + thermostat.set_fan_min_on_time(str(call.data[ATTR_FAN_MIN_ON_TIME])) + thermostat.schedule_update_ha_state(True) + + +def _resume_program_set_service(call: ServiceCall) -> None: + """Resume the program on the target thermostats.""" + entity_id = call.data.get(ATTR_ENTITY_ID) + thermostats = _async_get_thermostats(call.hass) + if entity_id: + thermostats = [ + thermostat + for thermostat in thermostats + if thermostat.entity_id in entity_id + ] + + for thermostat in thermostats: + thermostat.resume_program(call.data.get(ATTR_RESUME_ALL)) + thermostat.schedule_update_ha_state(True) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register ecobee services.""" + # pylint: disable-next=home-assistant-use-runtime-data + hass.data.setdefault(DOMAIN, {})[DATA_THERMOSTATS] = [] + + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_VACATION, + _create_vacation_service, + schema=CREATE_VACATION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_VACATION, + _delete_vacation_service, + schema=DELETE_VACATION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_FAN_MIN_ON_TIME, + _fan_min_on_time_set_service, + schema=SET_FAN_MIN_ON_TIME_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + _resume_program_set_service, + schema=RESUME_PROGRAM_SCHEMA, + ) diff --git a/homeassistant/components/edifier_infrared/button.py b/homeassistant/components/edifier_infrared/button.py index 8240e357cdcb..10cd8d2e481e 100644 --- a/homeassistant/components/edifier_infrared/button.py +++ b/homeassistant/components/edifier_infrared/button.py @@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.infrared import InfraredEmitterConsumerEntity @@ -141,6 +142,48 @@ COMMAND_SET_BUTTONS: dict[ command_code=EdifierRC20GCode.COAX, ), ), + EdifierCommandSet.S3000PRO: ( + EdifierIrButtonEntityDescription( + key="usb", + translation_key="usb", + command_code=EdifierS3000ProCode.USB, + ), + EdifierIrButtonEntityDescription( + key="bluetooth", + translation_key="bluetooth", + command_code=EdifierS3000ProCode.BLUETOOTH, + ), + EdifierIrButtonEntityDescription( + key="line_bal", + translation_key="line_bal", + command_code=EdifierS3000ProCode.LINE_BAL, + ), + EdifierIrButtonEntityDescription( + key="opt_coax", + translation_key="opt_coax", + command_code=EdifierS3000ProCode.OPT_COAX, + ), + EdifierIrButtonEntityDescription( + key="eq_monitor", + translation_key="eq_monitor", + command_code=EdifierS3000ProCode.EQ_MONITOR, + ), + EdifierIrButtonEntityDescription( + key="eq_dynamic", + translation_key="eq_dynamic", + command_code=EdifierS3000ProCode.EQ_DYNAMIC, + ), + EdifierIrButtonEntityDescription( + key="eq_classic", + translation_key="eq_classic", + command_code=EdifierS3000ProCode.EQ_CLASSIC, + ), + EdifierIrButtonEntityDescription( + key="eq_vocal", + translation_key="eq_vocal", + command_code=EdifierS3000ProCode.EQ_VOCAL, + ), + ), } diff --git a/homeassistant/components/edifier_infrared/const.py b/homeassistant/components/edifier_infrared/const.py index 057f71a7c510..4fd4b959d7b2 100644 --- a/homeassistant/components/edifier_infrared/const.py +++ b/homeassistant/components/edifier_infrared/const.py @@ -5,6 +5,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode DOMAIN = "edifier_infrared" CONF_INFRARED_ENTITY_ID = "infrared_entity_id" @@ -16,4 +17,5 @@ type EdifierCode = ( | EdifierR1280TCode | EdifierS360DBCode | EdifierRC20GCode + | EdifierS3000ProCode ) diff --git a/homeassistant/components/edifier_infrared/media_player.py b/homeassistant/components/edifier_infrared/media_player.py index 6944e05b9156..8c9963e80e9d 100644 --- a/homeassistant/components/edifier_infrared/media_player.py +++ b/homeassistant/components/edifier_infrared/media_player.py @@ -8,6 +8,7 @@ from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode +from infrared_protocols.codes.edifier.s3000pro import EdifierS3000ProCode from homeassistant.components.infrared import InfraredEmitterConsumerEntity from homeassistant.components.media_player import ( @@ -92,6 +93,19 @@ COMMAND_SET_COMMANDS: dict[ MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,), MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,), }, + EdifierCommandSet.S3000PRO: { + MediaPlayerEntityFeature.TURN_ON: (EdifierS3000ProCode.POWER,), + MediaPlayerEntityFeature.TURN_OFF: (EdifierS3000ProCode.POWER,), + MediaPlayerEntityFeature.VOLUME_STEP: ( + (EdifierS3000ProCode.VOLUME_UP,), + (EdifierS3000ProCode.VOLUME_DOWN,), + ), + MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierS3000ProCode.MUTE,), + MediaPlayerEntityFeature.PLAY: (EdifierS3000ProCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.PAUSE: (EdifierS3000ProCode.PLAY_PAUSE,), + MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS3000ProCode.NEXT,), + MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS3000ProCode.PREVIOUS,), + }, } diff --git a/homeassistant/components/edifier_infrared/strings.json b/homeassistant/components/edifier_infrared/strings.json index 28235e17699b..0ed3c9e2c2a8 100644 --- a/homeassistant/components/edifier_infrared/strings.json +++ b/homeassistant/components/edifier_infrared/strings.json @@ -30,6 +30,18 @@ "coax": { "name": "Coaxial" }, + "eq_classic": { + "name": "Classic EQ" + }, + "eq_dynamic": { + "name": "Dynamic EQ" + }, + "eq_monitor": { + "name": "Monitor EQ" + }, + "eq_vocal": { + "name": "Vocal EQ" + }, "fx_off": { "name": "FX off" }, @@ -42,11 +54,20 @@ "line_2": { "name": "Line 2" }, + "line_bal": { + "name": "Line / Balanced" + }, + "opt_coax": { + "name": "Optical / Coaxial" + }, "optical": { "name": "Optical" }, "pc": { "name": "PC" + }, + "usb": { + "name": "USB" } } } diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 4970b73838cc..d5e675fc7760 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -3,6 +3,7 @@ "name": "EDL21", "codeowners": [], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/edl21", "integration_type": "device", "iot_class": "local_push", diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index e85ff73a8751..3d5ba506df96 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -35,7 +35,7 @@ class ElgatoLight(ElgatoEntity, LightEntity): _attr_name = None _attr_min_color_temp_kelvin = 2900 # 344 Mireds - _attr_max_color_temp_kelvin = 7000 # 143 Mireds + _attr_max_color_temp_kelvin = 6993 # 143 Mireds def __init__(self, coordinator: ElgatoDataUpdateCoordinator) -> None: """Initialize Elgato Light.""" @@ -100,7 +100,7 @@ class ElgatoLight(ElgatoEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" await self.coordinator.client.light(on=False) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() @elgato_exception_handler @override @@ -143,7 +143,7 @@ class ElgatoLight(ElgatoEntity, LightEntity): saturation=saturation, temperature=temperature, ) - await self.coordinator.async_request_refresh() + await self.coordinator.async_refresh() @elgato_exception_handler async def async_identify(self) -> None: diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 289de2c7133a..e85a9a303b6c 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -10,9 +10,9 @@ from elkm1_lib.keypads import Keypad import voluptuous as vol from homeassistant.components.alarm_control_panel import ( - ATTR_CHANGED_BY, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, + AlarmControlPanelEntityStateAttribute, AlarmControlPanelState, CodeFormat, ) @@ -136,8 +136,10 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): self._changed_by_time = last_state.attributes[ATTR_CHANGED_BY_TIME] if ATTR_CHANGED_BY_ID in last_state.attributes: self._changed_by_id = last_state.attributes[ATTR_CHANGED_BY_ID] - if ATTR_CHANGED_BY in last_state.attributes: - self._changed_by = last_state.attributes[ATTR_CHANGED_BY] + if AlarmControlPanelEntityStateAttribute.CHANGED_BY in last_state.attributes: + self._changed_by = last_state.attributes[ + AlarmControlPanelEntityStateAttribute.CHANGED_BY + ] def _watch_keypad(self, keypad: Element, changeset: dict[str, Any]) -> None: assert isinstance(keypad, Keypad) diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 2e920cf6cadd..0e214728c82d 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -18,7 +18,6 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_SSL, - DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) @@ -179,7 +178,7 @@ class EmbyDevice(MediaPlayerEntity): @override def name(self): """Return the name of the device.""" - return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME + return f"Emby {self.device.name}" @property @override diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 103ab28f480e..ba0de3a35ac0 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -50,7 +50,6 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, @@ -62,6 +61,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + EntityStateAttribute, ) from homeassistant.core import Event, EventStateChangedData, State from homeassistant.helpers.event import async_track_state_change_event @@ -382,9 +382,16 @@ class HueOneLightChangeView(HomeAssistantView): return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) # Get the entity's supported features - entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + entity_features = entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if entity.domain == light.DOMAIN: - color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or [] + color_modes = ( + entity.attributes.get( + light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES + ) + or [] + ) # Parse the request parsed: dict[str, Any] = { @@ -769,7 +776,10 @@ def _entity_unique_id(entity_id: str) -> str: def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) or [] + color_modes = ( + state.attributes.get(light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES) + or [] + ) unique_id = _entity_unique_id(state.entity_id) state_dict = get_entity_state_dict(config, state) @@ -865,7 +875,7 @@ def state_supports_hue_brightness( return light.brightness_supported(color_modes) if not (required_feature := DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN.get(domain)): return False - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + features = state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) enum = ENTITY_FEATURES_BY_DOMAIN[domain] features = enum(features) if type(features) is int else features return required_feature in features diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index bc7ed9de5822..c551a3149eb8 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.14.1"] + "requirements": ["sense-energy==0.14.3"] } diff --git a/homeassistant/components/energieleser/__init__.py b/homeassistant/components/energieleser/__init__.py index f9167316eca0..5533dd6e91dc 100644 --- a/homeassistant/components/energieleser/__init__.py +++ b/homeassistant/components/energieleser/__init__.py @@ -4,8 +4,10 @@ from energieleser import EnergieleserClient from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN from .coordinator import EnergieleserConfigEntry, EnergieleserCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -30,4 +32,5 @@ async def async_unload_entry( hass: HomeAssistant, entry: EnergieleserConfigEntry ) -> bool: """Unload an energieleser config entry.""" + ir.async_delete_issue(hass, DOMAIN, f"pin_locked_{entry.entry_id}") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/energieleser/config_flow.py b/homeassistant/components/energieleser/config_flow.py index 7a3a38ed1b81..4fccd3beec17 100755 --- a/homeassistant/components/energieleser/config_flow.py +++ b/homeassistant/components/energieleser/config_flow.py @@ -141,6 +141,43 @@ class EnergieleserConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + client = EnergieleserClient( + host=host, session=async_get_clientsession(self.hass) + ) + try: + device = await client.get_device() + except EnergieleserConnectionError: + errors["base"] = "cannot_connect" + except EnergieleserUnknownDeviceError: + errors["base"] = "unknown_device_type" + except EnergieleserError: + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device.device_id) + self._abort_if_unique_id_mismatch(reason="wrong_device") + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: host}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_SCHEMA, + suggested_values=entry.data | (user_input or {}), + ), + errors=errors, + ) + def _create_entry( self, host: str, title: str, device_id: str, sw_version: str | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/energieleser/coordinator.py b/homeassistant/components/energieleser/coordinator.py index bb4a06e5a4e6..6c026a69fa47 100755 --- a/homeassistant/components/energieleser/coordinator.py +++ b/homeassistant/components/energieleser/coordinator.py @@ -9,11 +9,13 @@ from energieleser import ( EnergieleserDevice, EnergieleserError, EnergieleserUnknownDeviceError, + StromleserOneDevice, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER @@ -52,14 +54,45 @@ class EnergieleserCoordinator(DataUpdateCoordinator[EnergieleserDevice]): device = await self.client.get_device() except EnergieleserUnknownDeviceError as err: raise UpdateFailed( - f"Unknown or unsupported device type for {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="unknown_device", + translation_placeholders={ + "device_id": self.device_id, + }, ) from err except EnergieleserConnectionError as err: raise UpdateFailed( - f"Cannot connect to energieleser device {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={ + "device_id": self.device_id, + }, ) from err except EnergieleserError as err: raise UpdateFailed( - f"Error communicating with energieleser device {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={ + "device_id": self.device_id, + }, ) from err + if isinstance(device, StromleserOneDevice): + issue_id = f"pin_locked_{self.config_entry.entry_id}" + if device.pin_locked: + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + learn_more_url="https://docs.energieleser.de/en/docs/stromleser-one/installation/preparation", + severity=ir.IssueSeverity.WARNING, + translation_key="meter_locked", + translation_placeholders={ + "device_name": self.config_entry.title, + }, + ) + else: + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + return device diff --git a/homeassistant/components/energieleser/diagnostics.py b/homeassistant/components/energieleser/diagnostics.py new file mode 100755 index 000000000000..792297e8c58b --- /dev/null +++ b/homeassistant/components/energieleser/diagnostics.py @@ -0,0 +1,25 @@ +"""Diagnostics support for energieleser.""" + +import dataclasses +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_DEVICE_ID +from homeassistant.core import HomeAssistant + +from .coordinator import EnergieleserConfigEntry + +TO_REDACT = {CONF_DEVICE_ID, "fabrication_number"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: EnergieleserConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + device_data = coordinator.data + + device_data_dict = dataclasses.asdict(device_data) + + return async_redact_data(device_data_dict, TO_REDACT) diff --git a/homeassistant/components/energieleser/manifest.json b/homeassistant/components/energieleser/manifest.json index b87071078e1a..160cffb2c9d3 100644 --- a/homeassistant/components/energieleser/manifest.json +++ b/homeassistant/components/energieleser/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["energieleser==0.1.4"], + "requirements": ["energieleser==0.1.5"], "zeroconf": [ { "type": "_stromleser._tcp.local." diff --git a/homeassistant/components/energieleser/quality_scale.yaml b/homeassistant/components/energieleser/quality_scale.yaml index 64f37f0fcd8c..ea4d7d13f731 100644 --- a/homeassistant/components/energieleser/quality_scale.yaml +++ b/homeassistant/components/energieleser/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo @@ -66,10 +66,10 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: todo - reconfiguration-flow: todo - repair-issues: todo + reconfiguration-flow: done + repair-issues: done stale-devices: status: exempt comment: One device per config entry; the device is removed when the entry is removed. diff --git a/homeassistant/components/energieleser/strings.json b/homeassistant/components/energieleser/strings.json index 2138e95ee459..370ec96f4b9b 100755 --- a/homeassistant/components/energieleser/strings.json +++ b/homeassistant/components/energieleser/strings.json @@ -3,8 +3,10 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "unknown_device_type": "This isn't a supported energieleser product. Please check that the device is a stromleser, gasleser, wasserleser, or wärmeleser." + "unknown_device_type": "This isn't a supported energieleser product. Please check that the device is a stromleser, gasleser, wasserleser, or wärmeleser.", + "wrong_device": "The device at this IP address does not match the originally configured device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -13,6 +15,16 @@ }, "flow_title": "{device_type} · {host}", "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::energieleser::config::step::user::data_description::host%]" + }, + "description": "Update the IP address or hostname of your energieleser device.", + "title": "Reconfigure energieleser" + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -80,5 +92,22 @@ "name": "Water today" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the device {device_id}" + }, + "connection_error": { + "message": "An error occurred while connecting to the device {device_id}" + }, + "unknown_device": { + "message": "The device type for {device_id} is unknown or unsupported" + } + }, + "issues": { + "meter_locked": { + "description": "The electricity meter connected to {device_name} is not providing high-resolution data. You need to unlock the physical meter by entering the PIN (provided by your electricity company or grid operator) directly on the meter. Once the meter is unlocked, high-resolution data will be provided and this issue will resolve itself automatically. See the linked instructions for details on how to enter the PIN.", + "title": "Meter PIN Required" + } } } diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index b893ed097880..85fd93d59fda 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -8,17 +8,17 @@ import logging from typing import Any, Final, Literal, cast, override from homeassistant.components.sensor import ( - ATTR_LAST_RESET, - ATTR_STATE_CLASS, SensorDeviceClass, SensorEntity, + SensorEntityCapabilityAttribute, + SensorEntityStateAttribute, SensorStateClass, ) from homeassistant.components.sensor.recorder import ( # pylint: disable=home-assistant-component-root-import reset_detected, ) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, + EntityStateAttribute, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -436,7 +436,9 @@ class EnergyCostSensor(SensorEntity): if energy_state is None: return - state_class = energy_state.attributes.get(ATTR_STATE_CLASS) + state_class = energy_state.attributes.get( + SensorEntityCapabilityAttribute.STATE_CLASS + ) if state_class not in SUPPORTED_STATE_CLASSES: if not self._wrong_state_class_reported: self._wrong_state_class_reported = True @@ -450,7 +452,7 @@ class EnergyCostSensor(SensorEntity): # last_reset must be set if the sensor is SensorStateClass.MEASUREMENT if ( state_class == SensorStateClass.MEASUREMENT - and ATTR_LAST_RESET not in energy_state.attributes + and SensorEntityStateAttribute.LAST_RESET not in energy_state.attributes ): return @@ -478,22 +480,28 @@ class EnergyCostSensor(SensorEntity): if energy_price is None: return - energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + energy_unit: str | None = energy_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) if energy_unit is None or energy_unit not in valid_units: if not self._wrong_unit_reported: self._wrong_unit_reported = True _LOGGER.warning( "Found unexpected unit %s for %s", - energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + energy_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ), energy_state.entity_id, ) return if ( state_class != SensorStateClass.TOTAL_INCREASING - and energy_state.attributes.get(ATTR_LAST_RESET) - != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) + and energy_state.attributes.get(SensorEntityStateAttribute.LAST_RESET) + != self._last_energy_sensor_state.attributes.get( + SensorEntityStateAttribute.LAST_RESET + ) ) or ( state_class == SensorStateClass.TOTAL_INCREASING and reset_detected( @@ -544,7 +552,7 @@ class EnergyCostSensor(SensorEntity): energy_price = float(energy_price_state.state) energy_price_unit: str | None = energy_price_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, "" + EntityStateAttribute.UNIT_OF_MEASUREMENT, "" ).partition("/")[2] # For backwards compatibility we don't validate the unit of the price @@ -731,7 +739,7 @@ class EnergyPowerSensor(SensorEntity): return self._attr_native_unit_of_measurement = source_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ) self._attr_native_value = value * -1 @@ -756,8 +764,12 @@ class EnergyPowerSensor(SensorEntity): return # Get units from state attributes - discharge_unit = discharge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - charge_unit = charge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + discharge_unit = discharge_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) + charge_unit = charge_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) # Convert to Watts if units are present if discharge_unit: diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index fe8eee2ba108..3c323c7884ea 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -6,9 +6,9 @@ import functools from homeassistant.components import recorder, sensor from homeassistant.const import ( - ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -231,13 +231,13 @@ def _async_validate_stat_common( if check_negative and current_value is not None and current_value < 0: issues.add_issue(hass, "entity_negative_state", entity_id, current_value) - device_class = state.attributes.get(ATTR_DEVICE_CLASS) + device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class not in allowed_device_classes: issues.add_issue( hass, "entity_unexpected_device_class", entity_id, device_class ) else: - unit = state.attributes.get("unit_of_measurement") + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if device_class and unit not in allowed_units.get(device_class, []): issues.add_issue(hass, unit_error, entity_id, unit) @@ -272,7 +272,9 @@ def _async_validate_usage_stat( state = hass.states.get(entity_id) assert state is not None - state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) + state_class = state.attributes.get( + sensor.SensorEntityCapabilityAttribute.STATE_CLASS + ) allowed_state_classes = [ sensor.SensorStateClass.MEASUREMENT, @@ -310,7 +312,7 @@ def _async_validate_price_entity( issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state) return - unit = state.attributes.get("unit_of_measurement") + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if unit is None or not unit.endswith(allowed_units): issues.add_issue(hass, unit_error, entity_id, unit) @@ -343,7 +345,9 @@ def _async_validate_power_stat( state = hass.states.get(entity_id) assert state is not None - state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) + state_class = state.attributes.get( + sensor.SensorEntityCapabilityAttribute.STATE_CLASS + ) if state_class != sensor.SensorStateClass.MEASUREMENT: issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class) @@ -372,7 +376,9 @@ def _async_validate_cost_stat( issues.add_issue(hass, "entity_not_defined", stat_id) return - state_class = state.attributes.get("state_class") + state_class = state.attributes.get( + sensor.SensorEntityCapabilityAttribute.STATE_CLASS + ) supported_state_classes = [ sensor.SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 0babd95a2073..a317cfb121bf 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -5,7 +5,8 @@ import logging from typing import TYPE_CHECKING, Any, override from awesomeversion import AwesomeVersion -from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError +import jwt +from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError, EnvoyTokenAuth import voluptuous as vol from homeassistant.config_entries import ( @@ -25,8 +26,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType +from homeassistant.util import dt as dt_util from .const import ( + ACCESS_TOKEN_LOGIN_URL, + CONF_MANUAL_TOKEN, DOMAIN, INVALID_AUTH_ERRORS, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, @@ -46,17 +50,44 @@ INSTALLER_AUTH_USERNAME = "installer" AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN} +UNKNOWN_TOKEN_TEXT = "?" + def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]: """Return a dictionary without AVOID_REFLECT_KEYS.""" return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS} +def token_lifetime(token: str) -> str: + """Return token lifetime in days.""" + days_left = UNKNOWN_TOKEN_TEXT + try: + jwt_payload = jwt.decode(token, options={"verify_signature": False}) + exp = jwt_payload.get("exp") + if exp is not None: + days_left = str(int((int(exp) - dt_util.utcnow().timestamp()) / 86400)) + except jwt.PyJWTError, KeyError, TypeError, ValueError: + days_left = UNKNOWN_TOKEN_TEXT + return days_left + + +def descriptions( + serial: str, token_days_left: str = UNKNOWN_TOKEN_TEXT +) -> dict[str, str]: + """Build description placeholders.""" + return { + CONF_SERIAL: serial, + "enphase_url": ACCESS_TOKEN_LOGIN_URL, + "token_life": token_days_left, + } + + async def validate_input( hass: HomeAssistant, host: str, username: str, password: str, + token: str | None, errors: dict[str, str], description_placeholders: dict[str, str], ) -> Envoy: @@ -64,7 +95,7 @@ async def validate_input( envoy = Envoy(host, async_get_clientsession(hass, verify_ssl=False)) try: await envoy.setup() - await envoy.authenticate(username=username, password=password) + await envoy.authenticate(username=username, password=password, token=token) except INVALID_AUTH_ERRORS as e: errors["base"] = "invalid_auth" description_placeholders["reason"] = str(e) @@ -88,6 +119,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): self.ip_address: str | None = None self.username = None self.protovers: str | None = None + self.manual_token: bool = False @staticmethod @callback @@ -118,10 +150,18 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): ): default_username = INSTALLER_AUTH_USERNAME - schema[ - vol.Optional(CONF_USERNAME, default=self.username or default_username) - ] = str - schema[vol.Optional(CONF_PASSWORD, default="")] = str + if self.manual_token: + # in manual token entry mode show token input field + schema[vol.Optional(CONF_TOKEN, default="")] = str + else: + # in automatic token mode show username and password inputs + schema[ + vol.Optional(CONF_USERNAME, default=self.username or default_username) + ] = str + schema[vol.Optional(CONF_PASSWORD, default="")] = str + + # option to switch between automatic and manual token entry modes + schema[vol.Optional(CONF_MANUAL_TOKEN, default=self.manual_token)] = bool return vol.Schema(schema) @@ -194,28 +234,48 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} + token_days_left: str = UNKNOWN_TOKEN_TEXT - if user_input is not None: - await validate_input( + if token := reauth_entry.data.get(CONF_TOKEN, ""): + token_days_left = token_lifetime(token) + + if user_input is None: + # remember current manual_token setting to detect switch between modes + self.manual_token = reauth_entry.data.get(CONF_MANUAL_TOKEN, False) + elif user_input.get(CONF_MANUAL_TOKEN) != self.manual_token: + # user is switching between manual and automatic token entry mode + # display the form in the other mode, no configuration update yet + self.manual_token = user_input[CONF_MANUAL_TOKEN] + else: + envoy = await validate_input( self.hass, reauth_entry.data[CONF_HOST], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], + user_input.get(CONF_USERNAME, ""), + user_input.get(CONF_PASSWORD, ""), + token := user_input.get(CONF_TOKEN, "") or None, errors, description_placeholders, ) if not errors: + # successful authentication, update config return self.async_update_reload_and_abort( reauth_entry, - data_updates=user_input, + data_updates=user_input + | ( + {CONF_TOKEN: envoy.auth.token} + if isinstance(envoy.auth, EnvoyTokenAuth) + else {} + ), ) + if token: + token_days_left = token_lifetime(token) serial = reauth_entry.unique_id or "-" self.context["title_placeholders"] = { CONF_SERIAL: serial, CONF_HOST: reauth_entry.data[CONF_HOST], } - description_placeholders["serial"] = serial + description_placeholders.update(descriptions(serial, token_days_left)) return self.async_show_form( step_id="reauth_confirm", data_schema=self.add_suggested_values_to_schema( @@ -238,44 +298,66 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" + token_days_left: str = UNKNOWN_TOKEN_TEXT + + if user_input and (token := user_input.get(CONF_TOKEN, "")): + token_days_left = token_lifetime(token) if user_input is not None: - envoy = await validate_input( - self.hass, - host, - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - errors, - description_placeholders, - ) - if not errors: - name = self._async_envoy_name() - - if not self.unique_id: - await self.async_set_unique_id(envoy.serial_number) + if ( + manual_mode := user_input.get(CONF_MANUAL_TOKEN, False) + ) != self.manual_token: + # for new config self.manual_token starts default as false + # user is switching between manual and automatic token entry mode + # show form again in other mode, no configuration update yet + self.manual_token = manual_mode + else: + envoy = await validate_input( + self.hass, + host, + user_input.get(CONF_USERNAME, ""), + user_input.get(CONF_PASSWORD, ""), + token := user_input.get(CONF_TOKEN, "") or None, + errors, + description_placeholders, + ) + if not errors: name = self._async_envoy_name() - - if self.unique_id: - # If envoy exists in configuration update fields and exit - self._abort_if_unique_id_configured( - { - CONF_HOST: host, - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - }, - error="reauth_successful", + # successful authentication, store token in config + token_update = ( + {CONF_TOKEN: envoy.auth.token} + if isinstance(envoy.auth, EnvoyTokenAuth) + else {} ) - # CONF_NAME is still set for legacy backwards compatibility - return self.async_create_entry( - title=name, data={CONF_HOST: host, CONF_NAME: name} | user_input - ) + if not self.unique_id: + await self.async_set_unique_id(envoy.serial_number) + name = self._async_envoy_name() + + if self.unique_id: + # If envoy exists in configuration update fields and exit + self._abort_if_unique_id_configured( + { + CONF_HOST: host, + CONF_USERNAME: user_input.get(CONF_USERNAME, ""), + CONF_PASSWORD: user_input.get(CONF_PASSWORD, ""), + CONF_MANUAL_TOKEN: self.manual_token, + } + | token_update, + error="reauth_successful", + ) + + # CONF_NAME is still set for legacy backwards compatibility + return self.async_create_entry( + title=name, data={CONF_NAME: name} | user_input | token_update + ) if self.unique_id: self.context["title_placeholders"] = { CONF_SERIAL: self.unique_id, CONF_HOST: host, } + description_placeholders.update(descriptions("", token_days_left)) return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( @@ -293,20 +375,29 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): reconfigure_entry = self._get_reconfigure_entry() errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} + token_days_left: str = UNKNOWN_TOKEN_TEXT - if user_input is not None: - host: str = user_input[CONF_HOST] - username: str = user_input[CONF_USERNAME] - password: str = user_input[CONF_PASSWORD] + if token := reconfigure_entry.data.get(CONF_TOKEN, ""): + token_days_left = token_lifetime(token) + if user_input is None: + # remember current manual_token setting to detect switch between modes + self.manual_token = reconfigure_entry.data.get(CONF_MANUAL_TOKEN, False) + elif user_input.get(CONF_MANUAL_TOKEN) != self.manual_token: + # user switches between manual and automatic token entry mode + # show form again on other mode, no configuration update yet + self.manual_token = user_input[CONF_MANUAL_TOKEN] + else: envoy = await validate_input( self.hass, - host, - username, - password, + host := user_input[CONF_HOST], + username := user_input.get(CONF_USERNAME, ""), + password := user_input.get(CONF_PASSWORD, ""), + token := user_input.get(CONF_TOKEN, "") or None, errors, description_placeholders, ) if not errors: + # successful authentication, store token in config await self.async_set_unique_id(envoy.serial_number) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( @@ -315,16 +406,23 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: host, CONF_USERNAME: username, CONF_PASSWORD: password, - }, + CONF_MANUAL_TOKEN: self.manual_token, + } + | ( + {CONF_TOKEN: envoy.auth.token} + if isinstance(envoy.auth, EnvoyTokenAuth) + else {} + ), ) + if token: + token_days_left = token_lifetime(token) serial = reconfigure_entry.unique_id or "-" self.context["title_placeholders"] = { CONF_SERIAL: serial, CONF_HOST: reconfigure_entry.data[CONF_HOST], } - description_placeholders["serial"] = serial - + description_placeholders.update(descriptions(serial, token_days_left)) return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index d5f46a66650a..95b66c9fede7 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -16,6 +16,9 @@ PLATFORMS = [ INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) +ACCESS_TOKEN_LOGIN_URL = "https://entrez.enphaseenergy.com" +CONF_MANUAL_TOKEN = "use_manual_token" + SETUP_RETRY_TIMEOUT = 50 OPERATIONAL_RETRY_TIMEOUT = 200 diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index a280e4833cfd..3549c2f7c3c8 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -4,6 +4,7 @@ import contextlib import datetime from datetime import timedelta import logging +import math from typing import Any, override from pyenphase import Envoy, EnvoyError, EnvoyTokenAuth @@ -13,12 +14,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ( + CONF_MANUAL_TOKEN, DOMAIN, INVALID_AUTH_ERRORS, OPERATIONAL_RETRY_TIMEOUT, @@ -28,8 +30,8 @@ from .const import ( SCAN_INTERVAL = timedelta(seconds=60) TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) -STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() -NOTIFICATION_ID = "enphase_envoy_notification" +STALE_TOKEN_THRESHOLD = 30 # days +TOKEN_REPAIR_ID = "enphase_envoy_token_expiry" FIRMWARE_REFRESH_INTERVAL = timedelta(hours=4) MAC_VERIFICATION_DELAY = timedelta(seconds=34) _LOGGER = logging.getLogger(__name__) @@ -45,6 +47,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): envoy_firmware: str config_entry: EnphaseConfigEntry interface: EnvoyInterfaceInformation | None + token_lifetime: int # days of token life left def __init__( self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry @@ -52,8 +55,9 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize DataUpdateCoordinator for the envoy.""" self.envoy = envoy entry_data = entry.data - self.username = entry_data[CONF_USERNAME] - self.password = entry_data[CONF_PASSWORD] + self.username = entry_data.get(CONF_USERNAME) + self.password = entry_data.get(CONF_PASSWORD) + self.manual_token = entry_data.get(CONF_MANUAL_TOKEN, False) self._setup_complete = False self._operational_timeout = False self.envoy_firmware = "" @@ -61,6 +65,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._cancel_token_refresh: CALLBACK_TYPE | None = None self._cancel_firmware_refresh: CALLBACK_TYPE | None = None self._cancel_mac_verification: CALLBACK_TYPE | None = None + self.token_lifetime = 0 super().__init__( hass, _LOGGER, @@ -70,19 +75,69 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) + def _track_token_lifetime(self) -> bool: + """Update tokenlifetime and return if still fresh.""" + assert isinstance(self.envoy.auth, EnvoyTokenAuth) + self.token_lifetime = max( + 0, + math.ceil( + (self.envoy.auth.expire_timestamp - dt_util.utcnow().timestamp()) + / 86400 + ), + ) + return self.token_lifetime > STALE_TOKEN_THRESHOLD + @callback def _async_refresh_token_if_needed(self, now: datetime.datetime) -> None: """Proactively refresh token if its stale in case cloud services goes down.""" assert isinstance(self.envoy.auth, EnvoyTokenAuth) - expire_time = self.envoy.auth.expire_timestamp - remain = expire_time - now.timestamp() - fresh = remain > STALE_TOKEN_THRESHOLD + fresh = self._track_token_lifetime() name = self.name - _LOGGER.debug("%s: %s seconds remaining on token fresh=%s", name, remain, fresh) + _LOGGER.debug( + "%s: %s days remaining on token, fresh=%s, manual token mode=%s", + name, + self.token_lifetime, + fresh, + self.manual_token, + ) if not fresh: - self.hass.async_create_background_task( - self._async_try_refresh_token(), "{name} token refresh" + if not self.manual_token: + self.hass.async_create_background_task( + self._async_try_refresh_token(), f"{name} token refresh" + ) + return + + # User configured manual token entry, warn for upcoming expiry by issuing a repair + _LOGGER.debug( + "Create repair issue for %s token expiry in %s days", + self.name, + self.token_lifetime, ) + # Force issue rering each day until resolved by user + ir.async_delete_issue( + self.hass, DOMAIN, f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}" + ) + ir.async_create_issue( + self.hass, + domain=DOMAIN, + issue_id=f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="token_expiry", + translation_placeholders={ + "token_lifetime": str(self.token_lifetime), + "name": self.name, + }, + learn_more_url="https://www.home-assistant.io/integrations/enphase_envoy", + ) + return + if not self.manual_token: + return + # remove any repair that warned user to refresh manual token + ir.async_delete_issue( + self.hass, DOMAIN, f"{TOKEN_REPAIR_ID}_{self.envoy_serial_number}" + ) async def _async_try_refresh_token(self) -> None: """Try to refresh token.""" @@ -256,6 +311,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # startup without hitting the Cloud API # as long as the token is valid _LOGGER.debug("%s: Updating token in config entry from auth", self.name) + self._track_token_lifetime() self.hass.config_entries.async_update_entry( self.config_entry, data={ diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 77d7c2a4dc97..7806ec781a23 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -5,11 +5,14 @@ from datetime import datetime from typing import TYPE_CHECKING, Any from aiohttp import ClientResponse -from attr import asdict from pyenphase.envoy import Envoy from pyenphase.exceptions import EnvoyError -from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -119,10 +122,7 @@ async def async_get_config_entry_diagnostics( state_dict.pop("context", None) entity_dict = entity_entry_as_dict(entity) entities.append({"entity": entity_dict, "state": state_dict}) - device_dict = asdict(device) - device_dict.pop("_cache", None) - # This can be removed when suggested_area is removed from DeviceEntry - device_dict.pop("_suggested_area") + device_dict = device_entry_as_dict(device) device_entities.append({"device": device_dict, "entities": entities}) # remove envoy serial diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 12ce059967b8..02ff75664d9f 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -16,10 +16,14 @@ "reauth_confirm": { "data": { "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:component::enphase_envoy::config::step::user::data::token%]", + "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data::use_manual_token%]", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]", + "token": "[%key:component::enphase_envoy::config::step::user::data_description::token%]", + "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data_description::use_manual_token%]", "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]" }, "description": "[%key:component::enphase_envoy::config::step::user::description%]" @@ -28,11 +32,15 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:component::enphase_envoy::config::step::user::data::token%]", + "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data::use_manual_token%]", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]", "password": "[%key:component::enphase_envoy::config::step::user::data_description::password%]", + "token": "[%key:component::enphase_envoy::config::step::user::data_description::token%]", + "use_manual_token": "[%key:component::enphase_envoy::config::step::user::data_description::use_manual_token%]", "username": "[%key:component::enphase_envoy::config::step::user::data_description::username%]" }, "description": "[%key:component::enphase_envoy::config::step::user::description%]" @@ -41,11 +49,15 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", + "token": "Envoy access token", + "use_manual_token": "Enter the Envoy access token manually", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { "host": "The hostname or IP address of your Enphase Envoy gateway.", "password": "Blank or Enphase Cloud password", + "token": "Go to the [Enphase login]({enphase_url}) to get a new token. Current token lifetime: {token_life} days.", + "use_manual_token": "If your Enphase Cloud account has multi-factor authentication enabled, check this option to add or update your token manually.", "username": "Installer or Enphase Cloud username" }, "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models, enter username `installer` without a password." @@ -723,10 +735,28 @@ "envoy_error": { "message": "Error communicating with Envoy API on {host}: {args}" }, + "envoy_token_lifetime_service_envoy_not_found": { + "message": "No Envoy found by token lifetime action for device ID {device_id}." + }, + "envoy_token_lifetime_service_no_device_id": { + "message": "No Envoy found by token lifetime action. Configure an Envoy or specify a `device_id` if more than 1 Envoy is configured." + }, + "no_token_auth": { + "message": "{service}: Envoy token authorization is only used with firmware version 7.0 and later." + }, + "not_initialized": { + "message": "{service}: Enphase Envoy is not yet initialized" + }, "unexpected_device": { "message": "Unexpected Envoy serial number found at {host}; expected {expected_serial}, found {actual_serial}" } }, + "issues": { + "token_expiry": { + "description": "The {name} access token expires in {token_lifetime} days and is configured for manual token entry. Make sure to enter a new token before the final expiry day. To update the token, go to the Home Assistant Enphase Envoy integration page and use the reconfigure menu option.", + "title": "{name} access token expires in {token_lifetime} days." + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 2572c735fa74..a43e350a45fc 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -12,12 +12,16 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, CONF_RADAR_TIMESTAMP, CONF_STATION, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -75,6 +79,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP), layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)), radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)), + loop_minutes=int(options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION)), + fps=int(options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS)), ) radar_coordinator = ECDataUpdateCoordinator( hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index c5aadd3bd3e9..3a56dd576f6b 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -30,6 +30,8 @@ from homeassistant.helpers.selector import ( ) from .const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, @@ -37,6 +39,8 @@ from .const import ( CONF_RADAR_TIMESTAMP, CONF_STATION, CONF_TITLE, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -210,6 +214,22 @@ class OptionsFlowHandler(OptionsFlowWithReload): min=10, max=2000, step=10, unit_of_measurement="km" ) ), + vol.Required( + CONF_RADAR_DURATION, + default=options.get(CONF_RADAR_DURATION, DEFAULT_RADAR_DURATION), + ): NumberSelector( + NumberSelectorConfig( + min=0, max=180, step=5, unit_of_measurement="min" + ) + ), + vol.Required( + CONF_RADAR_FPS, + default=options.get(CONF_RADAR_FPS, DEFAULT_RADAR_FPS), + ): NumberSelector( + NumberSelectorConfig( + min=1, max=30, step=1, unit_of_measurement="fps" + ) + ), } ) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py index 39a9f3a949a7..52cf4b6564e4 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -12,6 +12,8 @@ CONF_RADAR_LEGEND = "radar_legend" CONF_RADAR_TIMESTAMP = "radar_timestamp" CONF_RADAR_OPACITY = "radar_opacity" CONF_RADAR_RADIUS = "radar_radius" +CONF_RADAR_DURATION = "radar_duration" +CONF_RADAR_FPS = "radar_fps" RADAR_LAYERS = ["rain", "snow", "precip_type"] @@ -22,3 +24,6 @@ DEFAULT_RADAR_LEGEND = False DEFAULT_RADAR_TIMESTAMP = True DEFAULT_RADAR_OPACITY = 65 DEFAULT_RADAR_RADIUS = 200 +# 0 means use the full range of images Environment Canada reports as available. +DEFAULT_RADAR_DURATION = 0 +DEFAULT_RADAR_FPS = 5 diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 93acc0ecc558..18491e942b25 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -121,6 +121,8 @@ "step": { "init": { "data": { + "radar_duration": "Loop duration", + "radar_fps": "Loop frame rate", "radar_layer": "Radar type", "radar_legend": "Show legend", "radar_opacity": "Radar opacity", @@ -128,6 +130,8 @@ "radar_timestamp": "Show timestamp" }, "data_description": { + "radar_duration": "How far back the radar animation goes, in minutes (0 for the full available history)", + "radar_fps": "Frame rate of the radar animation", "radar_opacity": "Opacity of the radar layer overlay (0-100)", "radar_radius": "Radius of the radar map in kilometres" }, diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 5d329b61974f..b7c2eb8352e8 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -2,7 +2,7 @@ import logging -from aioesphomeapi import APIClient, APIConnectionError +from aioesphomeapi import APIConnectionError from homeassistant.components import zeroconf from homeassistant.components.bluetooth import async_remove_scanner @@ -11,13 +11,7 @@ from homeassistant.components.usb import ( USBDevice, async_register_serial_port_scanner, ) -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - EVENT_HOMEASSISTANT_STOP, - __version__ as ha_version, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import async_delete_issue @@ -29,15 +23,18 @@ from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN from .domain_data import DomainData from .encryption_key_storage import async_get_encryption_key_storage from .entry_data import ESPHomeConfigEntry, RuntimeEntryData -from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance +from .manager import ( + DEVICE_CONFLICT_ISSUE_FORMAT, + ESPHomeManager, + async_create_api_client, + cleanup_instance, +) from .websocket_api import async_setup as async_setup_websocket_api _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -CLIENT_INFO = f"Home Assistant {ha_version}" - @callback def _async_scan_serial_ports( @@ -90,20 +87,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool: """Set up the esphome component.""" host: str = entry.data[CONF_HOST] - port: int = entry.data[CONF_PORT] password: str | None = entry.data[CONF_PASSWORD] - noise_psk: str | None = entry.data.get(CONF_NOISE_PSK) zeroconf_instance = await zeroconf.async_get_instance(hass) - cli = APIClient( - host, - port, - password, - client_info=CLIENT_INFO, - zeroconf_instance=zeroconf_instance, - noise_psk=noise_psk, - timezone=hass.config.time_zone, + cli = async_create_api_client( + hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) ) domain_data = DomainData.get(hass) @@ -159,21 +148,10 @@ async def _async_clear_dynamic_encryption_key( if await storage.async_get_key(entry.unique_id) is None: return - host: str = entry.data[CONF_HOST] - port: int = entry.data[CONF_PORT] - password: str | None = entry.data[CONF_PASSWORD] - noise_psk: str | None = entry.data.get(CONF_NOISE_PSK) - zeroconf_instance = await zeroconf.async_get_instance(hass) - cli = APIClient( - host, - port, - password, - client_info=CLIENT_INFO, - zeroconf_instance=zeroconf_instance, - noise_psk=noise_psk, - timezone=hass.config.time_zone, + cli = async_create_api_client( + hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) ) try: diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index ce7c3037c9e6..0182cacf9cfb 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -4,14 +4,12 @@ import asyncio from collections.abc import AsyncIterable from functools import partial import hashlib -import io from itertools import chain import json import logging from pathlib import Path import socket from typing import Any, cast, override -import wave from aioesphomeapi import ( MediaPlayerFormatPurpose, @@ -53,6 +51,7 @@ from .entity import EsphomeAssistEntity, convert_api_error_ha_error from .entry_data import ESPHomeConfigEntry from .enum_mapper import EsphomeEnumMapper from .ffmpeg_proxy import async_create_proxy_url +from .wav_parser import stream_wav PARALLEL_UPDATES = 0 @@ -726,36 +725,41 @@ class EsphomeAssistSatellite( ) return - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + seconds_in_chunk = samples_per_chunk / sample_rate + start_time: float | None = None + audio_duration_sent = 0.0 - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - if ( - (wav_file.getframerate() != sample_rate) - or (wav_file.getsampwidth() != sample_width) - or (wav_file.getnchannels() != sample_channels) - ): - _LOGGER.error("Can only stream 16Khz 16-bit mono WAV") - return + async for chunk, is_last in stream_wav( + tts_result.async_stream_result(), + expected_format="pcm", + expected_channels=sample_channels, + expected_width=sample_width, + expected_sample_rate=sample_rate, + samples_per_chunk=samples_per_chunk, + ): + if not self._is_running: + break # type: ignore[unreachable] - _LOGGER.debug("Streaming %s audio samples", wav_file.getnframes()) + if start_time is None: + start_time = asyncio.get_running_loop().time() - while self._is_running: - chunk = wav_file.readframes(samples_per_chunk) - if not chunk: - break + self._send_tts_audio(chunk) - if self._udp_server is not None: - self._udp_server.send_audio_bytes(chunk) - else: - self.cli.send_voice_assistant_audio(chunk) + audio_duration_sent += seconds_in_chunk - # Wait for 90% of the duration of the audio that was - # sent for it to be played. This will overrun the - # device's buffer for very long audio, so using a media - # player is preferred. - samples_in_chunk = len(chunk) // (sample_width * sample_channels) - seconds_in_chunk = samples_in_chunk / sample_rate - await asyncio.sleep(seconds_in_chunk * 0.9) + if is_last: + break + + # The ring buffer in the remote device is fixed at 512ms. + # We want to keep it at around 384ms (75% full) to prevent + # the buffer from overflowing or underflowing. + assert start_time is not None + elapsed = asyncio.get_running_loop().time() - start_time + if (wait_time := (audio_duration_sent - 0.384) - elapsed) > 0: + await asyncio.sleep(wait_time) + + except ValueError as err: + _LOGGER.error("Error streaming WAV: %s", err) except asyncio.CancelledError: return # Don't trigger state change finally: @@ -767,6 +771,13 @@ class EsphomeAssistSatellite( self.tts_response_finished() self._entry_data.async_set_assist_pipeline_state(False) + def _send_tts_audio(self, payload: bytes) -> None: + """Send TTS audio via API or UDP.""" + if self._udp_server is not None: + self._udp_server.send_audio_bytes(payload) + else: + self.cli.send_voice_assistant_audio(payload) + async def _wrap_audio_stream(self) -> AsyncIterable[bytes]: """Yield audio chunks from the queue until None.""" while True: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 71b99ced5fea..1d3488d3cd00 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -74,7 +74,11 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" ERROR_INVALID_PASSWORD_AUTH = "invalid_auth" _LOGGER = logging.getLogger(__name__) -ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" +# A deliberately wrong key (base64 of thirty two ASCII zero characters, not +# zero bytes) used only to elicit the server hello so the device name can be +# read. Not to be confused with aioesphomeapi.ZERO_NOISE_PSK, the well known +# all zeros provisioning key. +PROBE_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" DEFAULT_NAME = "ESPHome" _BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector( @@ -271,7 +275,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # to get the device name which will allow us to populate # the device name and hopefully get the encryption key # from the dashboard. - self._noise_psk = ZERO_NOISE_PSK + self._noise_psk = PROBE_NOISE_PSK response = await self.fetch_device_info() self._noise_psk = None diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index b10995ac27cc..508065b091c8 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final from awesomeversion import AwesomeVersion from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.const import __version__ as ha_version from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: @@ -12,6 +13,8 @@ if TYPE_CHECKING: DOMAIN = "esphome" +CLIENT_INFO = f"Home Assistant {ha_version}" + ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN) CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py index dfe4741a0d6e..ec4e2ad9cc43 100644 --- a/homeassistant/components/esphome/coordinator.py +++ b/homeassistant/components/esphome/coordinator.py @@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") +MIN_VERSION_SUPPORTS_BUILD_QUEUE = AwesomeVersion("2026.6.0") REFRESH_INTERVAL = timedelta(minutes=5) @@ -34,6 +35,7 @@ class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevi self.url = url self.api = ESPHomeDashboardAPI(url, async_get_clientsession(hass)) self.supports_update: bool | None = None + self.supports_build_queue = False @override async def _async_update_data(self) -> dict[str, ConfiguredDevice]: @@ -41,13 +43,14 @@ class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevi devices = await self.api.get_devices() configured_devices = devices["configured"] - if ( - self.supports_update is None - and configured_devices - and (current_version := configured_devices[0].get("current_version")) + if configured_devices and ( + current_version := configured_devices[0].get("current_version") ): - self.supports_update = ( - AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE - ) + version = AwesomeVersion(current_version) + if self.supports_update is None: + self.supports_update = version > MIN_VERSION_SUPPORTS_UPDATE + # The dashboard has its own build queue since 2026.6.0 + # and can accept multiple compile requests at once + self.supports_build_queue = version >= MIN_VERSION_SUPPORTS_BUILD_QUEUE return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index eb6f2772d2cb..2d5ae2161d13 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, + build_device_unique_id, ) import voluptuous as vol @@ -31,12 +32,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN # Import config flow so that it's added to the registry -from .entry_data import ( - DeviceEntityKey, - ESPHomeConfigEntry, - RuntimeEntryData, - build_device_unique_id, -) +from .entry_data import DeviceEntityKey, ESPHomeConfigEntry, RuntimeEntryData from .enum_mapper import EsphomeEnumMapper _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 7dd004c2af83..8ee0527661a3 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -45,7 +45,7 @@ from aioesphomeapi import ( UserService, ValveInfo, WaterHeaterInfo, - build_unique_id, + build_device_unique_id, ) from aioesphomeapi.model import ButtonInfo from bleak_esphome.backend.device import ESPHomeBluetoothDevice @@ -59,7 +59,7 @@ 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 +from .const import CONF_NOISE_PSK, DOMAIN from .dashboard import async_get_dashboard type ESPHomeConfigEntry = ConfigEntry[RuntimeEntryData] @@ -103,22 +103,6 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { } -def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str: - """Build unique ID for entity, appending @device_id if it belongs to a sub-device. - - This wrapper around build_unique_id ensures that entities belonging to sub-devices - have their device_id appended to the unique_id to handle proper migration when - entities move between devices. - """ - base_unique_id = build_unique_id(mac, entity_info) - - # If entity belongs to a sub-device, append @device_id - if entity_info.device_id: - return f"{base_unique_id}@{entity_info.device_id}" - - return base_unique_id - - class StoreData(TypedDict, total=False): """ESPHome storage data.""" @@ -310,11 +294,31 @@ class RuntimeEntryData: infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict( list ) + ent_reg = er.async_get(hass) + registry_get_entity = ent_reg.async_get_entity_id for info in infos: info_type = type(info) if platform := info_types_to_platform.get(info_type): needed_platforms.add(platform) infos_by_type[info_type].append(info) + # Migrate legacy unique ids to the version 3 format that fixes + # UTF-8 collisions. Skip when a version 3 id already exists so a + # downgrade then upgrade keeps the original entity. When two + # legacy ids collided (the bug this fixes) only one registry + # entry exists for it, so the first iterated info claims it and + # the rest get fresh version 3 ids. + old_unique_id = build_device_unique_id(mac, info, version=1) + new_unique_id = build_device_unique_id(mac, info, version=3) + if ( + old_unique_id != new_unique_id + and ( + old_entry := registry_get_entity( + platform, DOMAIN, old_unique_id + ) + ) + and not registry_get_entity(platform, DOMAIN, new_unique_id) + ): + ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) else: _LOGGER.warning( "Entity type %s is not supported in this version of Home Assistant", @@ -513,6 +517,8 @@ class RuntimeEntryData: ) -> None: """Create a zwave_js config flow for a Z-Wave JS Proxy device.""" assert self.client.connected_address is not None + entry = hass.config_entries.async_get_entry(self.entry_id) + noise_psk = entry.data.get(CONF_NOISE_PSK) if entry else None discovery_flow.async_create_flow( hass, "zwave_js", @@ -522,7 +528,7 @@ class RuntimeEntryData: zwave_home_id=zwave_home_id, ip_address=self.client.connected_address, port=self.client.port, - noise_psk=self.client.noise_psk, + noise_psk=noise_psk or None, ), discovery_key=discovery_flow.DiscoveryKey( domain=DOMAIN, diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 3eeaed77be5e..90e0760da46b 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -9,6 +9,7 @@ import struct from typing import TYPE_CHECKING, Any, Final, NamedTuple from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, @@ -34,7 +35,10 @@ import voluptuous as vol from homeassistant.components import bluetooth, tag, zeroconf from homeassistant.const import ( ATTR_DEVICE_ID, + CONF_HOST, CONF_MODE, + CONF_PASSWORD, + CONF_PORT, EVENT_HOMEASSISTANT_CLOSE, EVENT_LOGGING_CHANGED, Platform, @@ -77,6 +81,7 @@ from homeassistant.util.json import json_loads_object from .bluetooth import async_connect_scanner from .const import ( + CLIENT_INFO, CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, @@ -101,6 +106,26 @@ DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" UNPACK_UINT32_BE = struct.Struct(">I").unpack_from +@callback +def async_create_api_client( + hass: HomeAssistant, + entry: ESPHomeConfigEntry, + zeroconf_instance: zeroconf.HaZeroconf, + *, + noise_psk: str | None, +) -> APIClient: + """Create an APIClient for a config entry.""" + return APIClient( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_PASSWORD], + client_info=CLIENT_INFO, + zeroconf_instance=zeroconf_instance, + noise_psk=noise_psk, + timezone=hass.config.time_zone, + ) + + if TYPE_CHECKING: from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001 @@ -812,6 +837,51 @@ class ESPHomeManager: if self.reconnect_logic: await self.reconnect_logic.stop() + async def _async_provision_key_over_noise(self, new_key: bytes) -> bool: + """Send the encryption key over a short lived zero PSK Noise connection. + + The well known all zeros PSK still runs a fresh ephemeral X25519 + exchange, so the key cannot be read by a passive listener on the + network. This protects against sniffing only; it does not + authenticate either side against an active man in the middle. + + Returns True if the device accepted the key. On failure the caller + simply returns; provisioning runs again on the next connect cycle. + """ + unique_id = self.entry.unique_id + cli = async_create_api_client( + self.hass, self.entry, self.zeroconf_instance, noise_psk=ZERO_NOISE_PSK + ) + device_name = self.entry.data.get(CONF_DEVICE_NAME, self.host) + try: + await cli.connect() + if await cli.noise_encryption_set_key(new_key): + return True + _LOGGER.error( + "Device %s (%s) rejected the encryption key", + device_name, + unique_id, + ) + except InvalidEncryptionKeyAPIError: + _LOGGER.error( + "Device %s (%s) rejected the zero PSK handshake; it appears " + "to already have an encryption key set", + device_name, + unique_id, + ) + except APIConnectionError as ex: + # Whatever went wrong, we never downgrade to a plaintext push; + # provisioning simply runs again on the next connect cycle + _LOGGER.error( + "Error provisioning encryption key for device %s (%s): %s", + device_name, + unique_id, + ex, + ) + finally: + await cli.disconnect(force=True) + return False + async def _handle_dynamic_encryption_key( self, device_info: EsphomeDeviceInfo ) -> None: @@ -853,18 +923,24 @@ class ESPHomeManager: new_key = base64.b64encode(secrets.token_bytes(32)) new_key_str = new_key.decode() - try: - # Store the key on the device using the existing connection - result = await self.cli.noise_encryption_set_key(new_key) - except APIConnectionError as ex: - _LOGGER.error( - "Connection error while storing encryption key for device %s (%s): %s", - self.entry.data.get(CONF_DEVICE_NAME, self.host), - self.entry.unique_id, - ex, - ) - return + if device_info.api_encryption_provisionable: + # New firmware: send the key over an encrypted zero PSK Noise + # connection so it cannot be sniffed off the network + if not await self._async_provision_key_over_noise(new_key): + return else: + # Old firmware only accepts the key over the existing plaintext + # connection. Deprecated; will be removed after the usual window. + try: + result = await self.cli.noise_encryption_set_key(new_key) + except APIConnectionError as ex: + _LOGGER.error( + "Connection error while storing encryption key for device %s (%s): %s", + self.entry.data.get(CONF_DEVICE_NAME, self.host), + self.entry.unique_id, + ex, + ) + return if not result: _LOGGER.error( "Failed to set dynamic encryption key on device %s (%s)", @@ -917,8 +993,8 @@ class ESPHomeManager: # Remove this after 2026.4 if not ( stale_entry_entity_id := ent_reg.async_get_entity_id( - DOMAIN, Platform.BINARY_SENSOR, + DOMAIN, f"{self.entry_data.device_info.mac_address}-assist_in_progress", ) ): @@ -1417,13 +1493,14 @@ async def async_replace_device( upper_mac = new_mac.upper() old_upper_mac = old_mac.upper() for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id): - # -- - old_unique_id = entity.unique_id.split("-") - new_unique_id = "-".join([upper_mac, *old_unique_id[1:]]) - if entity.unique_id != new_unique_id and entity.unique_id.startswith( - old_upper_mac - ): - ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + # The mac is the leading segment of the unique id in every format, + # so swap the prefix without parsing the rest. + if entity.unique_id.startswith(old_upper_mac): + new_unique_id = upper_mac + entity.unique_id[len(old_upper_mac) :] + if new_unique_id != entity.unique_id: + ent_reg.async_update_entity( + entity.entity_id, new_unique_id=new_unique_id + ) domain_data = DomainData.get(hass) store = domain_data.get_or_create_store(hass, entry) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 6328164acb87..7eb5b9744daf 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==45.5.2", + "aioesphomeapi==45.6.0", "esphome-dashboard-api==1.3.0", - "bleak-esphome==3.9.4" + "bleak-esphome==3.9.7" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index eff6c6ab4b73..2b6f49272c04 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -233,21 +233,27 @@ class ESPHomeDashboardUpdateEntity( # Ensure only one OTA per device at a time async with self._install_lock: - # Ensure only one compile at a time for ALL devices - async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): - coordinator = self.coordinator - api = coordinator.api - device = coordinator.data.get(self._device_info.name) - assert device is not None - configuration = device["configuration"] - if not await api.compile(configuration): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="error_compiling", - translation_placeholders={ - "configuration": configuration, - }, - ) + coordinator = self.coordinator + api = coordinator.api + device = coordinator.data.get(self._device_info.name) + assert device is not None + configuration = device["configuration"] + if coordinator.supports_build_queue: + # The dashboard has its own build queue + # and can handle concurrent compile requests + compiled = await api.compile(configuration) + else: + # Ensure only one compile at a time for ALL devices + async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): + compiled = await api.compile(configuration) + if not compiled: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_compiling", + translation_placeholders={ + "configuration": configuration, + }, + ) # If the device uses deep sleep, there's a small chance it goes # to sleep right after the dashboard connects but before the OTA diff --git a/homeassistant/components/esphome/wav_parser.py b/homeassistant/components/esphome/wav_parser.py new file mode 100644 index 000000000000..892c89287f9f --- /dev/null +++ b/homeassistant/components/esphome/wav_parser.py @@ -0,0 +1,146 @@ +"""Helper to parse and stream WAV files.""" + +from collections.abc import AsyncIterable, AsyncIterator +import struct + + +class WAVHeaderParser: + """Helper to parse WAV headers from a byte buffer.""" + + def __init__( + self, + expected_channels: int, + expected_width: int, + expected_sample_rate: int, + ) -> None: + """Initialize the WAV header parser.""" + self.expected_channels = expected_channels + self.expected_width = expected_width + self.expected_sample_rate = expected_sample_rate + self.riff_checked = False + self.fmt_validated = False + self.data_bytes_remaining = 0 + self.found_data = False + + def parse(self, bytes_buffer: bytearray) -> bool: + """Parse headers from the buffer. Returns True if headers are fully parsed.""" + while True: + if not self.riff_checked: + if len(bytes_buffer) < 12: + return False + riff, _, wave_fmt = struct.unpack("<4sI4s", bytes_buffer[:12]) + if riff != b"RIFF" or wave_fmt != b"WAVE": + raise ValueError("Invalid WAV format: missing RIFF/WAVE header") + self.riff_checked = True + del bytes_buffer[:12] + + if len(bytes_buffer) < 8: + return False + + chunk_id, chunk_size = struct.unpack("<4sI", bytes_buffer[:8]) + + if chunk_id == b"fmt ": + if len(bytes_buffer) < 8 + chunk_size + (chunk_size & 1): + return False + + if chunk_size < 16: + raise ValueError(f"WAV fmt chunk too small: {chunk_size} bytes") + + ( + audio_format, + num_channels, + chunk_sample_rate, + _, + _, + bits_per_sample, + ) = struct.unpack(" AsyncIterator[tuple[bytes, bool]]: + """Parse a WAV stream, validate its header, and yield chunks of audio data.""" + if expected_format != "pcm": + raise ValueError(f"Unsupported expected format: {expected_format}") + + parser = WAVHeaderParser(expected_channels, expected_width, expected_sample_rate) + bytes_buffer = bytearray() + bytes_per_chunk_payload = samples_per_chunk * expected_width * expected_channels + pending_chunk: bytes | None = None + + async for chunk in stream: + bytes_buffer.extend(chunk) + + if not parser.found_data and not parser.parse(bytes_buffer): + continue + + while ( + parser.data_bytes_remaining >= bytes_per_chunk_payload + and len(bytes_buffer) >= bytes_per_chunk_payload + ): + payload = bytes(bytes_buffer[:bytes_per_chunk_payload]) + del bytes_buffer[:bytes_per_chunk_payload] + parser.data_bytes_remaining -= bytes_per_chunk_payload + + if pending_chunk is not None: + yield pending_chunk, False + + pending_chunk = payload + + if parser.data_bytes_remaining == 0: + yield pending_chunk, True + pending_chunk = None + return + + if not parser.found_data: + raise ValueError("Invalid WAV format: incomplete or missing data chunk") + + remaining_bytes_to_read = min(parser.data_bytes_remaining, len(bytes_buffer)) + if remaining_bytes_to_read > 0: + remaining = bytes(bytes_buffer[:remaining_bytes_to_read]) + if pending_chunk is not None: + yield pending_chunk, False + pending_chunk = remaining + + if pending_chunk is not None: + yield pending_chunk, True diff --git a/homeassistant/components/etherscan/__init__.py b/homeassistant/components/etherscan/__init__.py index 0e983bd6bead..b7665204e229 100644 --- a/homeassistant/components/etherscan/__init__.py +++ b/homeassistant/components/etherscan/__init__.py @@ -1 +1 @@ -"""The etherscan component.""" +"""The Etherscan integration.""" diff --git a/homeassistant/components/evohome/button.py b/homeassistant/components/evohome/button.py index 9e27cb74de73..d6453be98fdd 100644 --- a/homeassistant/components/evohome/button.py +++ b/homeassistant/components/evohome/button.py @@ -30,14 +30,14 @@ async def async_setup_platform( coordinator = hass.data[EVOHOME_DATA].coordinator tcs = hass.data[EVOHOME_DATA].tcs - entities: list[EvoResetButtonBase] = [EvoResetSystemButton(coordinator, tcs)] + entities: list[EvoResetButtonBase] = [EvoSystemResetButton(coordinator, tcs)] entities.extend( - [EvoResetZoneButton(coordinator, z) for z in tcs.zones if is_valid_zone(z)] + EvoZoneResetButton(coordinator, z) for z in tcs.zones if is_valid_zone(z) ) if tcs.hotwater: - entities.append(EvoResetDhwButton(coordinator, tcs.hotwater)) + entities.append(EvoDhwResetButton(coordinator, tcs.hotwater)) async_add_entities(entities) @@ -45,6 +45,7 @@ async def async_setup_platform( class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEntity): """Base for Evohome's Button entities.""" + # for _attr_device_class, ButtonDeviceClass.RESET is not available _attr_entity_category = EntityCategory.CONFIG _evo_device: evo.ControlSystem | evo.HotWater | evo.Zone @@ -56,15 +57,18 @@ class EvoResetButtonBase(CoordinatorEntity[EvoDataUpdateCoordinator], ButtonEnti ) -> None: """Initialize an Evohome reset button entity.""" super().__init__(coordinator, context=evo_device.id) + self._evo_device = evo_device + self._attr_unique_id = f"{evo_device.id}_reset" + @override async def async_press(self) -> None: """Reset the Evohome entity to its base operating mode.""" await self.coordinator.call_client_api(self._evo_device.reset()) -class EvoResetSystemButton(EvoResetButtonBase): +class EvoSystemResetButton(EvoResetButtonBase): """Button entity for system reset.""" _evo_device: evo.ControlSystem @@ -77,28 +81,26 @@ class EvoResetSystemButton(EvoResetButtonBase): """Initialize the system reset button.""" super().__init__(coordinator, evo_device) - self._attr_unique_id = f"{evo_device.id}_reset" - self._attr_name = f"Reset {evo_device.location.name}" + @property + @override + def name(self) -> str: + """Return the entity name (follows location renames).""" + return f"Reset {self._evo_device.location.name}" -class EvoResetDhwButton(EvoResetButtonBase): +class EvoDhwResetButton(EvoResetButtonBase): """Button entity for DHW override reset.""" _evo_device: evo.HotWater - def __init__( - self, - coordinator: EvoDataUpdateCoordinator, - evo_device: evo.HotWater, - ) -> None: - """Initialize the DHW reset button.""" - super().__init__(coordinator, evo_device) - - self._attr_unique_id = f"{evo_device.id}_reset" - self._attr_name = f"Reset {evo_device.name}" + @property + @override + def name(self) -> str: + """Return the entity name (follows location renames).""" + return f"Reset {self._evo_device.location.name} DHW" -class EvoResetZoneButton(EvoResetButtonBase): +class EvoZoneResetButton(EvoResetButtonBase): """Button entity for zone override reset.""" _evo_device: evo.Zone @@ -110,10 +112,11 @@ class EvoResetZoneButton(EvoResetButtonBase): ) -> None: """Initialize the zone reset button.""" super().__init__(coordinator, evo_device) + self._attr_unique_id = f"{unique_zone_id(evo_device)}_reset" @property @override def name(self) -> str: - """Return the name, dynamically following any zone rename.""" + """Return the entity name (follows zone renames).""" return f"Reset {self._evo_device.name}" diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 032eef86b9ff..534e64ae5a7a 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -6,10 +6,14 @@ from typing import Any, override import evohomeasync2 as evo from evohomeasync2.const import ( + SZ_DURATION, + SZ_MODE, + SZ_PERIOD, SZ_SETPOINT_STATUS, SZ_SYSTEM_MODE, SZ_SYSTEM_MODE_STATUS, SZ_TEMPERATURE_STATUS, + SZ_UNTIL, SystemMode as EvoSystemMode, ZoneMode as EvoZoneMode, ) @@ -23,12 +27,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import ( - ATTR_MODE, - ATTR_TEMPERATURE, - PRECISION_TENTHS, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -36,14 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import ( - ATTR_DURATION, - ATTR_PERIOD, - DOMAIN, - EVOHOME_DATA, - RESET_BREAKS_IN_HA_VERSION, - EvoService, -) +from .const import DOMAIN, EVOHOME_DATA, RESET_BREAKS_IN_HA_VERSION, EvoService from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild, EvoEntity, is_valid_zone, unique_zone_id from .helpers import async_create_deprecation_issue_once @@ -270,7 +262,7 @@ class EvoZone(EvoChild, EvoClimateEntity): temperature = kwargs[ATTR_TEMPERATURE] - if (until := kwargs.get("until")) is None: + if (until := kwargs.get(SZ_UNTIL)) is None: if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE: until = self._evo_device.until if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: @@ -395,14 +387,14 @@ class EvoController(EvoClimateEntity): await self.coordinator.call_client_api(self._evo_device.reset()) return - mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE + mode = data[SZ_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE - if ATTR_PERIOD in data: + if SZ_PERIOD in data: until = dt_util.start_of_local_day() - until += data[ATTR_PERIOD] + until += data[SZ_PERIOD] - elif ATTR_DURATION in data: - until = dt_util.now() + data[ATTR_DURATION] + elif SZ_DURATION in data: + until = dt_util.now() + data[SZ_DURATION] else: until = None diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 06baf09cfc4c..3023b9081455 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -20,10 +20,6 @@ CONF_LOCATION_IDX: Final = "location_idx" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) -ATTR_DURATION: Final = "duration" # number of minutes, <24h -ATTR_PERIOD: Final = "period" # number of days -ATTR_SETPOINT: Final = "setpoint" - # Support for the refresh_system service is being deprecated REFRESH_BREAKS_IN_HA_VERSION: Final = "2027.1.0" # Support for the reset service calls/presets is being deprecated diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index dcfa47602df3..3435bf0e19b2 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -8,6 +8,9 @@ from typing import Any, override import evohomeasync2 as evo from evohomeasync2.const import ( + SZ_SINCE, + SZ_TIME_UNTIL, + SZ_UNTIL, ZoneModelType as EvoZoneModelType, ZoneType as EvoZoneType, ) @@ -28,7 +31,7 @@ def _recurse_and_revert(val: Any, _key: str | None = None) -> Any: return {k: _recurse_and_revert(v, k) for k, v in val.items()} if isinstance(val, (list, tuple)): return type(val)(_recurse_and_revert(v) for v in val) - if isinstance(val, datetime) and _key in ("since", "time_until", "until"): + if isinstance(val, datetime) and _key in (SZ_SINCE, SZ_TIME_UNTIL, SZ_UNTIL): return val.isoformat() if isinstance(val, StrEnum): return "".join(word.capitalize() for word in val.value.split("_")) diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index a95d528b7d53..1c4e1344bd18 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -8,7 +8,10 @@ from evohomeasync2 import ControlSystem from evohomeasync2.const import ( SZ_CAN_BE_TEMPORARY, SZ_DURATION, + SZ_MODE, SZ_PERIOD, + SZ_SETPOINT, + SZ_STATE, SZ_SYSTEM_MODE, SZ_TIMING_MODE, ) @@ -16,7 +19,7 @@ import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( @@ -28,9 +31,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control from .const import ( - ATTR_DURATION, - ATTR_PERIOD, - ATTR_SETPOINT, DOMAIN, REFRESH_BREAKS_IN_HA_VERSION, RESET_BREAKS_IN_HA_VERSION, @@ -49,12 +49,12 @@ def _as_snake_case(mode: str) -> str: # System service schemas (registered as domain services) SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { # unsupported modes are rejected at runtime with ServiceValidationError - vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here - vol.Exclusive(ATTR_DURATION, "temporary"): vol.All( + vol.Required(SZ_MODE): cv.string, # ... so, don't use SystemMode enum here + vol.Exclusive(SZ_DURATION, "temporary"): vol.All( cv.time_period, vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), ), - vol.Exclusive(ATTR_PERIOD, "temporary"): vol.All( + vol.Exclusive(SZ_PERIOD, "temporary"): vol.All( cv.time_period, vol.Range(min=timedelta(days=1), max=timedelta(days=99)), ), @@ -63,10 +63,8 @@ SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { # Zone service schemas (registered as entity services) SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { - vol.Required(ATTR_SETPOINT): vol.All( - vol.Coerce(float), vol.Range(min=4.0, max=35.0) - ), - vol.Optional(ATTR_DURATION): vol.All( + vol.Required(SZ_SETPOINT): vol.All(vol.Coerce(float), vol.Range(min=4.0, max=35.0)), + vol.Optional(SZ_DURATION): vol.All( cv.time_period, vol.Range(min=timedelta(days=0), max=timedelta(days=1)), ), @@ -74,8 +72,8 @@ SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { # DHW service schemas (registered as entity services) SET_DHW_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { - vol.Required(ATTR_STATE): cv.boolean, - vol.Optional(ATTR_DURATION): vol.All( + vol.Required(SZ_STATE): cv.boolean, + vol.Optional(SZ_DURATION): vol.All( cv.time_period, vol.Range(min=timedelta(days=0), max=timedelta(days=1)), ), @@ -163,7 +161,7 @@ def _register_dhw_entity_services(hass: HomeAssistant) -> None: def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None: """Validate that a set_system_mode service call is properly formed.""" - mode = data[ATTR_MODE] + mode = data[SZ_MODE] tcs_modes = {m[SZ_SYSTEM_MODE].value: m for m in tcs.allowed_system_modes} # Validation occurs here, instead of in the library, because it uses a slightly @@ -174,34 +172,34 @@ def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) - raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_not_supported", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) # voluptuous schema ensures that duration and period are not both present if not mode_info[SZ_CAN_BE_TEMPORARY]: - if ATTR_DURATION in data or ATTR_PERIOD in data: + if SZ_DURATION in data or SZ_PERIOD in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_be_temporary", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) return timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary - if timing_mode == SZ_DURATION and ATTR_PERIOD in data: + if timing_mode == SZ_DURATION and SZ_PERIOD in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_have_period", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) - if timing_mode == SZ_PERIOD and ATTR_DURATION in data: + if timing_mode == SZ_PERIOD and SZ_DURATION in data: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="mode_cant_have_duration", - translation_placeholders={ATTR_MODE: mode}, + translation_placeholders={SZ_MODE: mode}, ) @@ -252,8 +250,8 @@ def setup_service_functions( payload = { "unique_id": unique_id, "service": call.service, - "data": {**call.data, ATTR_MODE: _as_snake_case(call.data[ATTR_MODE])} - if ATTR_MODE in call.data + "data": {**call.data, SZ_MODE: _as_snake_case(call.data[SZ_MODE])} + if SZ_MODE in call.data else call.data, } async_dispatcher_send(hass, DOMAIN, payload) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 1aa8a2ccb285..dfeaa4d9d58a 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -14,6 +14,7 @@ from pyezvizapi.exceptions import ( from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from .const import ( ATTR_TYPE_CAMERA, @@ -110,6 +111,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo entry, PLATFORMS_BY_TYPE[sensor_type] ) + # Remove any existing last_alarm_pic sensor entities that were migrated away. + if sensor_type == ATTR_TYPE_CLOUD: + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + for entity_entry in entries: + unique_id = entity_entry.unique_id + if ( + entity_entry.domain == "sensor" + and unique_id is not None + and unique_id.endswith(".last_alarm_pic") + ): + entity_registry.async_remove(entity_entry.entity_id) + _LOGGER.debug( + "Removed legacy last_alarm_pic sensor entity: %s", + entity_entry.entity_id, + ) + return True diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 19e2701477d6..3e29b9c5f683 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -1,5 +1,6 @@ """Support EZVIZ last motion image.""" +from datetime import datetime import logging from typing import override @@ -26,6 +27,13 @@ IMAGE_TYPE = ImageEntityDescription( ) +def _parse_last_alarm_time(last_alarm_time: object) -> datetime | None: + """Parse the last alarm time from the coordinator payload.""" + if not isinstance(last_alarm_time, str): + return None + return dt_util.parse_datetime(last_alarm_time) + + async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, @@ -51,9 +59,12 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): ImageEntity.__init__(self, hass) self._attr_unique_id = f"{serial}_{IMAGE_TYPE.key}" self.entity_description = IMAGE_TYPE - self._attr_image_url = self.data["last_alarm_pic"] - self._attr_image_last_updated = dt_util.parse_datetime( - str(self.data["last_alarm_time"]) + last_alarm_pic = self.data.get("last_alarm_pic") + self._attr_image_url = ( + last_alarm_pic if isinstance(last_alarm_pic, str) else None + ) + self._attr_image_last_updated = _parse_last_alarm_time( + self.data.get("last_alarm_time") ) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) self.alarm_image_password = ( @@ -71,6 +82,8 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): @override async def _async_load_image_from_url(self, url: str) -> Image | None: """Load an image by url.""" + if not url: + return None if response := await self._fetch_url(url): image_data = response.content if self.data["encrypted"] and self.alarm_image_password is not None: @@ -96,16 +109,14 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): @override def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if ( - self.data["last_alarm_pic"] - and self.data["last_alarm_pic"] != self._attr_image_url - ): - _LOGGER.debug("Image url changed to %s", self.data["last_alarm_pic"]) + last_alarm_pic = self.data.get("last_alarm_pic") + if last_alarm_pic and last_alarm_pic != self._attr_image_url: + _LOGGER.debug("Image url changed to %s", last_alarm_pic) - self._attr_image_url = self.data["last_alarm_pic"] + self._attr_image_url = last_alarm_pic self._cached_image = None - self._attr_image_last_updated = dt_util.parse_datetime( - str(self.data["last_alarm_time"]) + self._attr_image_last_updated = _parse_last_alarm_time( + self.data.get("last_alarm_time") ) super()._handle_coordinator_update() diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 64b409e4c80f..25db9826faee 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -37,11 +37,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { translation_key="seconds_last_trigger", entity_registry_enabled_default=False, ), - "last_alarm_pic": SensorEntityDescription( - key="last_alarm_pic", - translation_key="last_alarm_pic", - entity_registry_enabled_default=False, - ), "supported_channels": SensorEntityDescription( key="supported_channels", translation_key="supported_channels", diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 818d41c102ce..2cedcd3c78a2 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -111,9 +111,6 @@ "alarm_sound_mod": { "name": "Alarm sound level" }, - "last_alarm_pic": { - "name": "Last alarm picture URL" - }, "last_alarm_time": { "name": "Last alarm time" }, diff --git a/homeassistant/components/fan/llm.py b/homeassistant/components/fan/llm.py new file mode 100644 index 000000000000..5296e634f099 --- /dev/null +++ b/homeassistant/components/fan/llm.py @@ -0,0 +1,38 @@ +"""LLM tools for the fan integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from . import DOMAIN +from .intent import INTENT_FAN_SET_SPEED + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_FAN_SET_SPEED,) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 7cbd89cd261c..067e87f559f7 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -36,7 +36,11 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Generator[Path]: """Get an uploaded file. - File is removed at the end of the context. + File is removed at the end of the context. Should be run on the executor thread pool. + Create a wrapper function and call that wrapper function using + hass.async_add_executor_job. Running this function directly by scheduling an executor + job will result in loop blocking teardown code not running on the executor but + rather in the loop. """ if DOMAIN not in hass.data: raise ValueError("File does not exist") diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 6ed1a61eaab3..f96884724983 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -16,23 +16,21 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.recorder import get_instance, history from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, + SensorEntityCapabilityAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -307,17 +305,21 @@ class SensorFilter(SensorEntity): self._state = temp_state.state - self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) - self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) - self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) + self._attr_icon = new_state.attributes.get(EntityStateAttribute.ICON, ICON) + self._attr_device_class = new_state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) + self._attr_state_class = new_state.attributes.get( + SensorEntityCapabilityAttribute.STATE_CLASS + ) if self._attr_native_unit_of_measurement != new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ): for filt in self._filters: filt.reset() self._attr_native_unit_of_measurement = new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ) if update_ha: diff --git a/homeassistant/components/fints/__init__.py b/homeassistant/components/fints/__init__.py index 0113fa752346..030236752ad1 100644 --- a/homeassistant/components/fints/__init__.py +++ b/homeassistant/components/fints/__init__.py @@ -1 +1 @@ -"""The fints component.""" +"""The FinTS integration.""" diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py index 8f84da1c1cf6..f3684930bef0 100644 --- a/homeassistant/components/firefly_iii/config_flow.py +++ b/homeassistant/components/firefly_iii/config_flow.py @@ -15,7 +15,6 @@ 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 @@ -174,13 +173,13 @@ class FireflyConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" -class FireflyClientTimeout(HomeAssistantError): +class FireflyClientTimeout(Exception): """Error to indicate a timeout occurred.""" diff --git a/homeassistant/components/flock/__init__.py b/homeassistant/components/flock/__init__.py index 1b58d21cff88..cb299b9f4217 100644 --- a/homeassistant/components/flock/__init__.py +++ b/homeassistant/components/flock/__init__.py @@ -1 +1 @@ -"""The flock component.""" +"""The Flock integration.""" diff --git a/homeassistant/components/fluss/quality_scale.yaml b/homeassistant/components/fluss/quality_scale.yaml index 371598661704..83aa1eba9f52 100644 --- a/homeassistant/components/fluss/quality_scale.yaml +++ b/homeassistant/components/fluss/quality_scale.yaml @@ -32,6 +32,7 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done + entity-unavailable: todo integration-owner: done log-when-unavailable: done parallel-updates: done diff --git a/homeassistant/components/foobot/__init__.py b/homeassistant/components/foobot/__init__.py index 92edde9a5e1c..8afa3abd2348 100644 --- a/homeassistant/components/foobot/__init__.py +++ b/homeassistant/components/foobot/__init__.py @@ -1 +1 @@ -"""The foobot component.""" +"""The Foobot integration.""" diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 684b5b3cbeb9..139db1a604fd 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -87,7 +87,7 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: domain=DOMAIN, issue_id="deprecated_cleanup_button", is_fixable=False, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_cleanup_button", translation_placeholders={"removal_version": "2026.11.0"}, @@ -114,7 +114,7 @@ def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) - domain=DOMAIN, issue_id="deprecated_firmware_update_button", is_fixable=False, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_firmware_update_button", translation_placeholders={"removal_version": "2026.11.0"}, diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index fcad98ef9f9e..aa043e825993 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -743,9 +743,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ): if not any(con in device.connections for con in valid_connections): _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(device.id) fritz_data = self.hass.data[FRITZ_DATA_KEY] diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 496c04f2e055..1518ccfaa4df 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -121,9 +121,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat ): if not set(device.identifiers) & identifiers: LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) def _update_fritz_devices(self) -> FritzboxCoordinatorData: """Update all fritzbox device data.""" diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index e88227fe33ca..ea147ee2276e 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -35,7 +35,7 @@ from .coordinator import ( ) _LOGGER: Final = logging.getLogger(__name__) -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR] type FroniusConfigEntry = ConfigEntry[FroniusSolarNet] diff --git a/homeassistant/components/fronius/binary_sensor.py b/homeassistant/components/fronius/binary_sensor.py new file mode 100644 index 000000000000..4db1bced11ca --- /dev/null +++ b/homeassistant/components/fronius/binary_sensor.py @@ -0,0 +1,85 @@ +"""Support for Fronius binary sensors.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, override + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import FroniusEntity, FroniusEntityDescription + +if TYPE_CHECKING: + from . import FroniusConfigEntry + from .coordinator import FroniusPowerFlowUpdateCoordinator + + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True) +class FroniusBinarySensorEntityDescription( + FroniusEntityDescription, BinarySensorEntityDescription +): + """Describes Fronius binary sensor entity.""" + + +POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS: list[FroniusBinarySensorEntityDescription] = [ + FroniusBinarySensorEntityDescription( + key="backup_mode", + ), + FroniusBinarySensorEntityDescription( + key="battery_standby", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FroniusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Fronius binary sensor entities based on a config entry.""" + solar_net = config_entry.runtime_data + if solar_net.power_flow_coordinator is not None: + solar_net.power_flow_coordinator.add_entities_for_seen_keys( + async_add_entities, Platform.BINARY_SENSOR, PowerFlowBinarySensor + ) + + +class PowerFlowBinarySensor(FroniusEntity, BinarySensorEntity): + """Defines a Fronius power flow binary sensor entity.""" + + entity_description: FroniusBinarySensorEntityDescription + + def __init__( + self, + coordinator: FroniusPowerFlowUpdateCoordinator, + description: FroniusBinarySensorEntityDescription, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius power flow binary sensor.""" + super().__init__(coordinator, description, solar_net_id) + self._attr_is_on = self._device_data()[self.response_key]["value"] + # SolarNet device is already created in FroniusSolarNet._create_solar_net_device + self._attr_device_info = coordinator.solar_net.system_device_info + self._attr_unique_id = ( + f"{coordinator.solar_net.solar_net_device_id}-power_flow-{description.key}" + ) + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + self._attr_is_on = self._device_data()[self.response_key]["value"] + except KeyError: + # KeyError: raised when omitted in response, e.g. when backup power + # is deactivated after the entity was created + self._attr_is_on = None + self.async_write_ha_state() diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 302d278880da..17f70f8e4d4e 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -1,15 +1,18 @@ """DataUpdateCoordinators for the Fronius integration.""" from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence from datetime import timedelta from typing import TYPE_CHECKING, Any, override from pyfronius import BadStatusError, FroniusError +from homeassistant.const import Platform from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .binary_sensor import POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS from .const import ( DOMAIN, SOLAR_NET_ID_POWER_FLOW, @@ -17,6 +20,7 @@ from .const import ( FroniusDeviceInfo, SolarNetId, ) +from .entity import FroniusEntity, FroniusEntityDescription from .sensor import ( INVERTER_ENTITY_DESCRIPTIONS, LOGGER_ENTITY_DESCRIPTIONS, @@ -24,12 +28,10 @@ from .sensor import ( OHMPILOT_ENTITY_DESCRIPTIONS, POWER_FLOW_ENTITY_DESCRIPTIONS, STORAGE_ENTITY_DESCRIPTIONS, - FroniusSensorEntityDescription, ) if TYPE_CHECKING: from . import FroniusSolarNet - from .sensor import _FroniusSensorEntity class FroniusCoordinatorBase( @@ -39,7 +41,7 @@ class FroniusCoordinatorBase( default_interval: timedelta error_interval: timedelta - valid_descriptions: list[FroniusSensorEntityDescription] + valid_descriptions: Mapping[Platform, Sequence[FroniusEntityDescription]] MAX_FAILED_UPDATES = 3 @@ -49,7 +51,7 @@ class FroniusCoordinatorBase( self.solar_net = solar_net # unregistered_descriptors are used to create entities in platform module self.unregistered_descriptors: dict[ - SolarNetId, list[FroniusSensorEntityDescription] + SolarNetId, dict[Platform, list[FroniusEntityDescription]] ] = {} super().__init__(*args, update_interval=self.default_interval, **kwargs) @@ -80,15 +82,17 @@ class FroniusCoordinatorBase( for solar_net_id in data: if solar_net_id not in self.unregistered_descriptors: # id seen for the first time - self.unregistered_descriptors[solar_net_id] = ( - self.valid_descriptions.copy() - ) + self.unregistered_descriptors[solar_net_id] = { + platform: list(descriptions) + for platform, descriptions in self.valid_descriptions.items() + } return data @callback - def add_entities_for_seen_keys[_FroniusEntityT: _FroniusSensorEntity]( + def add_entities_for_seen_keys[_FroniusEntityT: FroniusEntity]( self, async_add_entities: AddEntitiesCallback, + platform: Platform, entity_constructor: type[_FroniusEntityT], ) -> None: """Add entities for received keys and registers listener for future seen keys. @@ -102,7 +106,9 @@ class FroniusCoordinatorBase( new_entities: list[_FroniusEntityT] = [] for solar_net_id, device_data in self.data.items(): remaining_unregistered_descriptors = [] - for description in self.unregistered_descriptors[solar_net_id]: + for description in self.unregistered_descriptors[solar_net_id][ + platform + ]: key = description.response_key or description.key if key not in device_data: remaining_unregistered_descriptors.append(description) @@ -117,7 +123,7 @@ class FroniusCoordinatorBase( solar_net_id=solar_net_id, ) ) - self.unregistered_descriptors[solar_net_id] = ( + self.unregistered_descriptors[solar_net_id][platform] = ( remaining_unregistered_descriptors ) async_add_entities(new_entities) @@ -133,7 +139,7 @@ class FroniusInverterUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(minutes=1) error_interval = timedelta(minutes=10) - valid_descriptions = INVERTER_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: INVERTER_ENTITY_DESCRIPTIONS} SILENT_RETRIES = 3 @@ -170,7 +176,7 @@ class FroniusLoggerUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(hours=1) error_interval = timedelta(hours=1) - valid_descriptions = LOGGER_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: LOGGER_ENTITY_DESCRIPTIONS} @override async def _update_method(self) -> dict[SolarNetId, Any]: @@ -184,7 +190,7 @@ class FroniusMeterUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(minutes=1) error_interval = timedelta(minutes=10) - valid_descriptions = METER_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: METER_ENTITY_DESCRIPTIONS} @override async def _update_method(self) -> dict[SolarNetId, Any]: @@ -198,7 +204,7 @@ class FroniusOhmpilotUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(minutes=1) error_interval = timedelta(minutes=10) - valid_descriptions = OHMPILOT_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: OHMPILOT_ENTITY_DESCRIPTIONS} @override async def _update_method(self) -> dict[SolarNetId, Any]: @@ -212,7 +218,10 @@ class FroniusPowerFlowUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(seconds=10) error_interval = timedelta(minutes=3) - valid_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS + valid_descriptions = { + Platform.SENSOR: POWER_FLOW_ENTITY_DESCRIPTIONS, + Platform.BINARY_SENSOR: POWER_FLOW_BINARY_SENSOR_DESCRIPTIONS, + } @override async def _update_method(self) -> dict[SolarNetId, Any]: @@ -226,7 +235,7 @@ class FroniusStorageUpdateCoordinator(FroniusCoordinatorBase): default_interval = timedelta(minutes=1) error_interval = timedelta(minutes=10) - valid_descriptions = STORAGE_ENTITY_DESCRIPTIONS + valid_descriptions = {Platform.SENSOR: STORAGE_ENTITY_DESCRIPTIONS} @override async def _update_method(self) -> dict[SolarNetId, Any]: diff --git a/homeassistant/components/fronius/entity.py b/homeassistant/components/fronius/entity.py new file mode 100644 index 000000000000..daa7bac527bc --- /dev/null +++ b/homeassistant/components/fronius/entity.py @@ -0,0 +1,42 @@ +"""Base entity for the Fronius integration.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +if TYPE_CHECKING: + from .coordinator import FroniusCoordinatorBase + + +@dataclass(frozen=True) +class FroniusEntityDescription(EntityDescription): + """Base class for Fronius entity descriptions.""" + + response_key: str | None = None + + +class FroniusEntity(CoordinatorEntity["FroniusCoordinatorBase"]): + """Defines a Fronius coordinator entity.""" + + entity_description: FroniusEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FroniusCoordinatorBase, + description: FroniusEntityDescription, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius coordinator entity.""" + super().__init__(coordinator) + self.entity_description = description + self.response_key = description.response_key or description.key + self.solar_net_id = solar_net_id + self._attr_translation_key = description.translation_key or description.key + + def _device_data(self) -> dict[str, Any]: + """Extract information for SolarNet device from coordinator data.""" + return self.coordinator.data[self.solar_net_id] diff --git a/homeassistant/components/fronius/icons.json b/homeassistant/components/fronius/icons.json index e899dcf32822..40d598b2a528 100644 --- a/homeassistant/components/fronius/icons.json +++ b/homeassistant/components/fronius/icons.json @@ -1,5 +1,13 @@ { "entity": { + "binary_sensor": { + "backup_mode": { + "default": "mdi:home-battery" + }, + "battery_standby": { + "default": "mdi:power-standby" + } + }, "sensor": { "cash_factor": { "default": "mdi:cash-plus" diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 8a32acaee6aa..6bcbde7cd72c 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( PERCENTAGE, EntityCategory, + Platform, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -27,7 +28,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, @@ -40,6 +40,7 @@ from .const import ( get_meter_location_description, get_ohmpilot_state_message, ) +from .entity import FroniusEntity, FroniusEntityDescription if TYPE_CHECKING: from . import FroniusConfigEntry @@ -69,33 +70,35 @@ async def async_setup_entry( for inverter_coordinator in solar_net.inverter_coordinators: inverter_coordinator.add_entities_for_seen_keys( - async_add_entities, InverterSensor + async_add_entities, Platform.SENSOR, InverterSensor ) if solar_net.logger_coordinator is not None: solar_net.logger_coordinator.add_entities_for_seen_keys( - async_add_entities, LoggerSensor + async_add_entities, Platform.SENSOR, LoggerSensor ) if solar_net.meter_coordinator is not None: solar_net.meter_coordinator.add_entities_for_seen_keys( - async_add_entities, MeterSensor + async_add_entities, Platform.SENSOR, MeterSensor ) if solar_net.ohmpilot_coordinator is not None: solar_net.ohmpilot_coordinator.add_entities_for_seen_keys( - async_add_entities, OhmpilotSensor + async_add_entities, Platform.SENSOR, OhmpilotSensor ) if solar_net.power_flow_coordinator is not None: solar_net.power_flow_coordinator.add_entities_for_seen_keys( - async_add_entities, PowerFlowSensor + async_add_entities, Platform.SENSOR, PowerFlowSensor ) if solar_net.storage_coordinator is not None: solar_net.storage_coordinator.add_entities_for_seen_keys( - async_add_entities, StorageSensor + async_add_entities, Platform.SENSOR, StorageSensor ) @callback def async_add_new_entities(coordinator: FroniusInverterUpdateCoordinator) -> None: """Add newly found inverter entities.""" - coordinator.add_entities_for_seen_keys(async_add_entities, InverterSensor) + coordinator.add_entities_for_seen_keys( + async_add_entities, Platform.SENSOR, InverterSensor + ) config_entry.async_on_unload( async_dispatcher_connect( @@ -107,14 +110,13 @@ async def async_setup_entry( @dataclass(frozen=True) -class FroniusSensorEntityDescription(SensorEntityDescription): +class FroniusSensorEntityDescription(FroniusEntityDescription, SensorEntityDescription): """Describes Fronius sensor entity.""" default_value: StateType | None = None # Gen24 devices may report 0 for total energy while doing firmware updates. # Handling such values shall mitigate spikes in delta calculations. invalid_when_falsy: bool = False - response_key: str | None = None value_fn: Callable[[StateType], StateType] | None = None @@ -746,13 +748,11 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ ] -class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEntity): - """Defines a Fronius coordinator entity.""" +class _FroniusSensorEntity(FroniusEntity, SensorEntity): + """Defines a Fronius coordinator sensor entity.""" entity_description: FroniusSensorEntityDescription - _attr_has_entity_name = True - def __init__( self, coordinator: FroniusCoordinatorBase, @@ -760,16 +760,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn solar_net_id: str, ) -> None: """Set up an individual Fronius meter sensor.""" - super().__init__(coordinator) - self.entity_description = description - self.response_key = description.response_key or description.key - self.solar_net_id = solar_net_id + super().__init__(coordinator, description, solar_net_id) self._attr_native_value = self._get_entity_value() - self._attr_translation_key = description.translation_key or description.key - - def _device_data(self) -> dict[str, Any]: - """Extract information for SolarNet device from coordinator data.""" - return self.coordinator.data[self.solar_net_id] def _get_entity_value(self) -> Any: """Extract entity value from coordinator. diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 2c742d0d89c9..44358e1d1845 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -36,6 +36,14 @@ } }, "entity": { + "binary_sensor": { + "backup_mode": { + "name": "Backup mode" + }, + "battery_standby": { + "name": "Battery standby" + } + }, "sensor": { "capacity_designed": { "name": "Designed capacity" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 14996cd487e2..19724c285043 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260624.4"] + "requirements": ["home-assistant-frontend==20260624.6"] } diff --git a/homeassistant/components/fuelprices_dk/__init__.py b/homeassistant/components/fuelprices_dk/__init__.py new file mode 100644 index 000000000000..a26a8552e228 --- /dev/null +++ b/homeassistant/components/fuelprices_dk/__init__.py @@ -0,0 +1,53 @@ +"""Initialize the Fuelprices.dk component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_COMPANY, CONF_STATION, SUBENTRY_TYPE_STATION +from .coordinator import FuelPricesDKCoordinator + +PLATFORMS = [Platform.SENSOR] + +type FuelpricesDkConfigEntry = ConfigEntry[dict[str, FuelPricesDKCoordinator]] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: FuelpricesDkConfigEntry +) -> bool: + """Set up Fuelprices.dk from a config entry.""" + config_entry.async_on_unload(config_entry.add_update_listener(_update_listener)) + api_key = config_entry.data[CONF_API_KEY] + runtime_data: dict[str, FuelPricesDKCoordinator] = {} + + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_STATION): + subentry_id = subentry.subentry_id + company = subentry.data[CONF_COMPANY] + station = subentry.data[CONF_STATION] + + coordinator = FuelPricesDKCoordinator( + hass, + api_key, + company, + station, + subentry_id, + config_entry, + ) + runtime_data[subentry_id] = coordinator + await coordinator.async_config_entry_first_refresh() + + config_entry.runtime_data = runtime_data + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def _update_listener(hass: HomeAssistant, entry: FuelpricesDkConfigEntry) -> None: + """Handle options or subentry updates by reloading the entry.""" + hass.config_entries.async_schedule_reload(entry.entry_id) + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: FuelpricesDkConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/fuelprices_dk/config_flow.py b/homeassistant/components/fuelprices_dk/config_flow.py new file mode 100644 index 000000000000..e3acf6563beb --- /dev/null +++ b/homeassistant/components/fuelprices_dk/config_flow.py @@ -0,0 +1,369 @@ +"""Config flow for the Fuelprices.dk integration.""" + +from collections.abc import Mapping +from typing import Any, override + +from aiohttp import ClientResponseError +from pybraendstofpriser import Braendstofpriser +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback + +from .const import CONF_COMPANY, CONF_STATION, DOMAIN, WEBSITE_URL + + +def _get_api_error_key(exc: ClientResponseError) -> str: + """Map API errors to config flow errors.""" + if exc.status == 401: + return "invalid_api_key" + if exc.status == 429: + return "rate_limit_exceeded" + return "cannot_connect" + + +class FuelpricesDkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fuelprices.dk.""" + + VERSION = 1 + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"station": FuelpricesDkStationSubentryFlow} + + def __init__(self) -> None: + """Initialize the config flow.""" + self.api: Braendstofpriser + self.companies: list[dict[str, Any]] = [] + self.stations: Any = {} + self.company_name = "" + self.user_input: dict[str, Any] = {} + + async def _async_validate_api_key( + self, api_key: str + ) -> tuple[Braendstofpriser | None, list[dict[str, Any]], str | None]: + """Validate the API key and fetch available companies.""" + api = Braendstofpriser(api_key) + try: + companies = await api.list_companies() + except ClientResponseError as exc: + return None, [], _get_api_error_key(exc) + + if not companies: + return None, [], "cannot_connect" + + return api, companies, None + + async def _async_fetch_stations(self, company_name: str) -> tuple[Any, str | None]: + """Fetch stations for a company.""" + try: + stations = await self.api.list_stations(company_name=company_name) + except ClientResponseError as exc: + return None, _get_api_error_key(exc) + + if not stations: + return None, "cannot_connect" + + return stations, None + + def _show_company_selection_form(self, errors: dict[str, str]) -> ConfigFlowResult: + """Show the company selection form.""" + return self.async_show_form( + step_id="company_selection", + data_schema=vol.Schema( + { + vol.Required(CONF_COMPANY, default=self.company_name): vol.In( + [c["company"] for c in self.companies] + ), + } + ), + errors=errors, + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - Enter API key.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match(user_input) + api, companies, error = await self._async_validate_api_key( + user_input[CONF_API_KEY] + ) + if error is None: + assert api is not None + self.api = api + self.companies = companies + self.user_input = dict(user_input) + return await self.async_step_company_selection() + + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + description_placeholders={"website_url": WEBSITE_URL}, + ) + + async def async_step_company_selection( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the company selection step.""" + if user_input is not None: + self.company_name = user_input[CONF_COMPANY] + self.user_input.update(user_input) + self.stations = {} + return await self.async_step_station_selection() + + return self._show_company_selection_form({}) + + async def async_step_station_selection( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the station selection step.""" + if not self.stations: + stations, error = await self._async_fetch_stations(self.company_name) + if error is not None: + return self._show_company_selection_form({"base": error}) + self.stations = stations + + if user_input is not None: + user_input[CONF_STATION] = self.stations.find( + "name", user_input[CONF_STATION] + ) + + # Create the main config entry with the first station subentry + self.user_input.update(user_input) + unique_id = ( + f"{self.user_input[CONF_COMPANY]}_{self.user_input[CONF_STATION]['id']}" + ) + title = ( + f"{self.user_input[CONF_COMPANY]} - " + f"{self.user_input[CONF_STATION]['name']}" + ) + return self.async_create_entry( + title="Fuelprices.dk", + data={CONF_API_KEY: self.user_input[CONF_API_KEY]}, + subentries=[ + { + "subentry_type": "station", + "data": { + CONF_COMPANY: self.user_input[CONF_COMPANY], + CONF_STATION: self.user_input[CONF_STATION], + }, + "title": title, + "unique_id": unique_id, + } + ], + ) + + stations = [s["name"] for s in self.stations] + + return self.async_show_form( + step_id="station_selection", + data_schema=vol.Schema( + { + vol.Required(CONF_STATION): vol.In(stations), + } + ), + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle a reauth flow when API key is invalid/expired.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm a new API key.""" + errors: dict[str, str] = {} + + if user_input is not None: + api = Braendstofpriser(user_input[CONF_API_KEY]) + try: + await api.list_companies() + except ClientResponseError as exc: + errors["base"] = _get_api_error_key(exc) + + if not errors: + entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + if entry is not None: + self.hass.config_entries.async_update_entry( + entry, + data={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + + +class FuelpricesDkStationSubentryFlow(ConfigSubentryFlow): + """Handle station subentries for Fuelprices.dk.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.api: Braendstofpriser + self.companies: list[dict[str, Any]] = [] + self.stations: Any = {} + self.company_name = "" + self._errors: dict[str, str] = {} + self.user_input: dict[str, Any] = {} + + async def _async_fetch_stations(self, company_name: str) -> tuple[Any, str | None]: + """Fetch stations for a company.""" + try: + stations = await self.api.list_stations(company_name=company_name) + except ClientResponseError as exc: + return None, _get_api_error_key(exc) + + if not stations: + return None, "cannot_connect" + + return stations, None + + def _show_company_selection_form( + self, errors: dict[str, str] | None = None + ) -> SubentryFlowResult: + """Show the company selection form.""" + default_company = self.user_input.get(CONF_COMPANY) + company_field = ( + vol.Required(CONF_COMPANY, default=default_company) + if default_company + else vol.Required(CONF_COMPANY) + ) + + return self.async_show_form( + step_id="company_selection", + data_schema=vol.Schema( + { + company_field: vol.In([c["company"] for c in self.companies]), + } + ), + errors=errors or {}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the initial step for adding a station subentry.""" + await self._async_init_api() + return await self.async_step_company_selection(user_input) + + async def _async_init_api(self) -> None: + """Initialize API client and fetch companies.""" + entry = self._get_entry() + api_key = entry.data[CONF_API_KEY] + self.api = Braendstofpriser(api_key) + try: + self.companies = await self.api.list_companies() + except ClientResponseError as exc: + self._errors["base"] = _get_api_error_key(exc) + return + + if not self.companies: + self._errors["base"] = "cannot_connect" + + async def async_step_company_selection( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the company selection step.""" + if self._errors: + return self.async_abort(reason=self._errors["base"]) + + if user_input is not None: + self.company_name = user_input[CONF_COMPANY] + self.user_input.update(user_input) + self.stations = {} + return await self.async_step_station_selection() + + return self._show_company_selection_form() + + async def async_step_station_selection( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the station selection step.""" + if not self.stations: + stations, error = await self._async_fetch_stations(self.company_name) + if error is not None: + self.user_input[CONF_COMPANY] = self.company_name + return self._show_company_selection_form({"base": error}) + self.stations = stations + + if user_input is not None: + user_input[CONF_STATION] = self.stations.find( + "name", user_input[CONF_STATION] + ) + + # Set UniqueID and abort if already existing + unique_id = ( + f"{self.user_input[CONF_COMPANY]}_{user_input[CONF_STATION]['id']}" + ) + entry = self._get_entry() + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="station_already_configured") + + # Process the user input and show next selection form + self.user_input.update(user_input) + return await self._async_create_or_update_subentry() + + stations = [s["name"] for s in self.stations] + + return self.async_show_form( + step_id="station_selection", + data_schema=vol.Schema( + { + vol.Required(CONF_STATION): vol.In(stations), + } + ), + errors=self._errors, + ) + + async def _async_create_or_update_subentry(self) -> SubentryFlowResult: + """Create the station subentry.""" + subentry_data = { + CONF_COMPANY: self.user_input[CONF_COMPANY], + CONF_STATION: self.user_input[CONF_STATION], + } + unique_id = ( + f"{self.user_input[CONF_COMPANY]}_{self.user_input[CONF_STATION]['id']}" + ) + title = ( + f"{self.user_input[CONF_COMPANY]} - {self.user_input[CONF_STATION]['name']}" + ) + + entry = self._get_entry() + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_create_entry( + title=title, + data=subentry_data, + unique_id=unique_id, + ) diff --git a/homeassistant/components/fuelprices_dk/const.py b/homeassistant/components/fuelprices_dk/const.py new file mode 100644 index 000000000000..654754113558 --- /dev/null +++ b/homeassistant/components/fuelprices_dk/const.py @@ -0,0 +1,10 @@ +"""Constants for the Fuelprices.dk integration.""" + +DOMAIN = "fuelprices_dk" + +CONF_COMPANY = "company" +CONF_STATION = "station" + +SUBENTRY_TYPE_STATION = "station" + +WEBSITE_URL = "https://fuelprices.dk" diff --git a/homeassistant/components/fuelprices_dk/coordinator.py b/homeassistant/components/fuelprices_dk/coordinator.py new file mode 100644 index 000000000000..11ccdf84eada --- /dev/null +++ b/homeassistant/components/fuelprices_dk/coordinator.py @@ -0,0 +1,64 @@ +"""Coordinator for the Fuelprices.dk integration.""" + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any, override + +from aiohttp import ClientResponseError +from pybraendstofpriser import Braendstofpriser +from pybraendstofpriser.exceptions import ProductNotFoundError + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +if TYPE_CHECKING: + from . import FuelpricesDkConfigEntry + +SCAN_INTERVAL = timedelta(hours=1) + +_LOGGER = logging.getLogger(__name__) + + +class FuelPricesDKCoordinator(DataUpdateCoordinator[dict[str, float | None]]): + """Data update coordinator for the Fuelprices.dk integration.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + company: str, + station: dict[str, Any], + subentry_id: str, + config_entry: FuelpricesDkConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + name=company, + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + + self._api = Braendstofpriser(api_key) + self.company = company + self.station_id: int = station["id"] + self.station_name: str = station["name"] + self.subentry_id = subentry_id + + @override + async def _async_update_data(self) -> dict[str, float | None]: + """Handle data update request from the coordinator.""" + try: + data = await self._api.get_prices(self.station_id) + except ProductNotFoundError as exc: + raise ConfigEntryError(exc) from exc + except ClientResponseError as exc: + if exc.status == 401: + raise ConfigEntryAuthFailed(exc) from exc + raise ConfigEntryError(exc) from exc + + self.station_name = data["station"]["name"] + + return dict(data["prices"]) diff --git a/homeassistant/components/fuelprices_dk/manifest.json b/homeassistant/components/fuelprices_dk/manifest.json new file mode 100644 index 000000000000..5ffa98f952cc --- /dev/null +++ b/homeassistant/components/fuelprices_dk/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "fuelprices_dk", + "name": "Fuelprices.dk", + "codeowners": ["@MTrab"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fuelprices_dk", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["pybraendstofpriser==2.2.0"] +} diff --git a/homeassistant/components/fuelprices_dk/quality_scale.yaml b/homeassistant/components/fuelprices_dk/quality_scale.yaml new file mode 100644 index 000000000000..d79be918fca4 --- /dev/null +++ b/homeassistant/components/fuelprices_dk/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any 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: | + The integration does not provide any additional actions. + docs-conditions: + status: exempt + comment: | + The integration does not provide any additional conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: | + The integration does not provide any additional triggers. + 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 provide any 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: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration cannot be discovered, it connects to a cloud service. + discovery: + status: exempt + comment: | + This integration cannot be discovered, it connects to a cloud service. + 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: todo + exception-translations: todo + icon-translations: + status: exempt + comment: | + The integration does not provide any additional icons. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/fuelprices_dk/sensor.py b/homeassistant/components/fuelprices_dk/sensor.py new file mode 100644 index 000000000000..459183cca682 --- /dev/null +++ b/homeassistant/components/fuelprices_dk/sensor.py @@ -0,0 +1,103 @@ +"""Sensor platform for the Fuelprices.dk integration.""" + +from typing import TYPE_CHECKING, override + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorEntityDescription, + SensorStateClass, +) +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 homeassistant.util import slugify as util_slugify + +from .const import DOMAIN +from .coordinator import FuelPricesDKCoordinator + +if TYPE_CHECKING: + from . import FuelpricesDkConfigEntry + +SENSORS = [ + SensorEntityDescription( + key="price", + name="Fuel Price", + native_unit_of_measurement="DKK/L", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:gas-station", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FuelpricesDkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform for Fuelprices.dk.""" + + for coordinator in entry.runtime_data.values(): + async_add_entities( + ( + FuelpricesDkSensor( + coordinator, + coordinator.station_name, + product_key, + sensor, + ) + for sensor in SENSORS + for product_key in coordinator.data + ), + config_subentry_id=coordinator.subentry_id, + ) + + +class FuelpricesDkSensor(CoordinatorEntity[FuelPricesDKCoordinator], RestoreSensor): + """Sensor for Fuelprices.dk.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FuelPricesDKCoordinator, + station_name: str, + product_key: str, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + + self._product_key = product_key + self._station_name = station_name + + self._attr_name = product_key + + self._attr_unique_id = util_slugify( + f"{self.coordinator.station_id}_{self.entity_description.key}_{product_key}" + ) + self._attr_config_subentry_id = self.coordinator.subentry_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(self.coordinator.station_id))}, + entry_type=DeviceEntryType.SERVICE, + name=self._station_name, + manufacturer=self.coordinator.company, + model=self.coordinator.station_name, + ) + + @property + @override + def available(self) -> bool: + """Return whether the entity is available.""" + return super().available and self._product_key in self.coordinator.data + + @property + @override + def native_value(self) -> float | None: + """Return the current value of the sensor.""" + price = self.coordinator.data[self._product_key] + if isinstance(price, int | float): + return float(price) + return None diff --git a/homeassistant/components/fuelprices_dk/strings.json b/homeassistant/components/fuelprices_dk/strings.json new file mode 100644 index 000000000000..ad8337531f3f --- /dev/null +++ b/homeassistant/components/fuelprices_dk/strings.json @@ -0,0 +1,94 @@ +{ + "config": { + "abort": { + "already_configured": "This API key is already configured.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "Invalid API key provided", + "rate_limit_exceeded": "Too many requests to the API. Please try again later.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "station_already_configured": "The selected station for this company is already configured" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]", + "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]" + }, + "step": { + "company_selection": { + "data": { + "company": "Select company" + }, + "data_description": { + "company": "The company you want to fetch prices from" + }, + "description": "Select company" + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your personal API key" + }, + "description": "Your API key needs to be updated." + }, + "station_selection": { + "data": { + "station": "Select station" + }, + "data_description": { + "station": "The station you want to create sensors for" + }, + "description": "Select station to show prices for" + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your personal API key" + }, + "description": "Enter your Fuelprices.dk API key\nIf you do not have an API key, you can get one for free at {website_url}" + } + } + }, + "config_subentries": { + "station": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]", + "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]", + "station_already_configured": "[%key:component::fuelprices_dk::config::abort::station_already_configured%]" + }, + "entry_type": "Station", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:component::fuelprices_dk::config::abort::invalid_api_key%]", + "rate_limit_exceeded": "[%key:component::fuelprices_dk::config::abort::rate_limit_exceeded%]" + }, + "initiate_flow": { + "user": "Add station" + }, + "step": { + "company_selection": { + "data": { + "company": "[%key:component::fuelprices_dk::config::step::company_selection::data::company%]" + }, + "data_description": { + "company": "[%key:component::fuelprices_dk::config::step::company_selection::data_description::company%]" + }, + "description": "[%key:component::fuelprices_dk::config::step::company_selection::description%]" + }, + "station_selection": { + "data": { + "station": "[%key:component::fuelprices_dk::config::step::station_selection::data::station%]" + }, + "data_description": { + "station": "[%key:component::fuelprices_dk::config::step::station_selection::data_description::station%]" + }, + "description": "[%key:component::fuelprices_dk::config::step::station_selection::description%]" + } + } + } + } +} diff --git a/homeassistant/components/gatus/__init__.py b/homeassistant/components/gatus/__init__.py new file mode 100644 index 000000000000..93cbcfc5999a --- /dev/null +++ b/homeassistant/components/gatus/__init__.py @@ -0,0 +1,25 @@ +"""The Gatus integration.""" + +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool: + """Set up Gatus from a config entry.""" + coordinator = GatusDataUpdateCoordinator(hass, entry, entry.data[CONF_URL]) + + 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: GatusConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/gatus/binary_sensor.py b/homeassistant/components/gatus/binary_sensor.py new file mode 100644 index 000000000000..f35d8815e42d --- /dev/null +++ b/homeassistant/components/gatus/binary_sensor.py @@ -0,0 +1,105 @@ +"""Support for Gatus binary sensors.""" + +from typing import override + +from gatus_api import EndpointStatus, Result + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +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 DOMAIN +from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GatusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Gatus binary sensor platform.""" + coordinator = entry.runtime_data + + async_add_entities( + GatusEndpointBinarySensor(coordinator, entry, endpoint_key) + for endpoint_key in coordinator.data + ) + + +class GatusEndpointBinarySensor( + CoordinatorEntity[GatusDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Gatus endpoint status.""" + + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: GatusDataUpdateCoordinator, + entry: GatusConfigEntry, + endpoint_key: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._endpoint_key = endpoint_key + + endpoint_data = self.endpoint_data + + endpoint_name = endpoint_data.name + if endpoint_data.group is not None: + device_name = f"{endpoint_data.group} {endpoint_name}" + else: + device_name = endpoint_name + + self._attr_unique_id = f"{entry.entry_id}_{endpoint_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.entry_id}_{endpoint_key}")}, + name=device_name, + manufacturer="Gatus", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + @override + def is_on(self) -> bool | None: + """Return true if the endpoint is up and healthy.""" + latest_result = self.latest_result + if latest_result is None: + return None + + return latest_result.success + + @property + @override + def available(self) -> bool: + """Return True if entity is available.""" + data = self.coordinator.data + # Guard for empty results list, which could imply a brand new endpoint + return ( + super().available + and self._endpoint_key in data + and bool(data[self._endpoint_key].results) + ) + + @property + def endpoint_data(self) -> EndpointStatus: + """Return this specific endpoint's data from the coordinator.""" + return self.coordinator.data[self._endpoint_key] + + @property + def latest_result(self) -> Result | None: + """Return the most recent monitoring result (Gatus appends newest last).""" + results = self.endpoint_data.results + if not results: + return None + return results[-1] diff --git a/homeassistant/components/gatus/config_flow.py b/homeassistant/components/gatus/config_flow.py new file mode 100644 index 000000000000..8abba8d95641 --- /dev/null +++ b/homeassistant/components/gatus/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for the Gatus integration.""" + +import logging +from typing import Any, override + +from gatus_api import GatusClient, GatusClientError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +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, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate that the user input allows us to connect to Gatus and return data.""" + client = GatusClient(url=data[CONF_URL], session=async_get_clientsession(hass)) + + try: + await client.get_endpoints_statuses() + except GatusClientError as err: + _LOGGER.debug("Cannot connect to Gatus instance at %s: %s", data[CONF_URL], err) + raise CannotConnect from err + + +class GatusConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Gatus.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial setup step when adding the integration via the UI.""" + errors: dict[str, str] = {} + + if user_input is not None: + user_input[CONF_URL] = str( + URL(user_input[CONF_URL]) + .with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + + 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 Exception: + _LOGGER.exception("Unexpected exception during Gatus setup") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Gatus", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + user_input[CONF_URL] = str( + url.with_query(None) + .with_fragment(None) + .with_user(None) + .with_password(None) + ).rstrip("/") + + if user_input[CONF_URL] != reconfigure_entry.data[CONF_URL]: + 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 Exception: + _LOGGER.exception("Unexpected exception during Gatus reconfigure") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input or reconfigure_entry.data + ), + errors=errors, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the server.""" diff --git a/homeassistant/components/gatus/const.py b/homeassistant/components/gatus/const.py new file mode 100644 index 000000000000..89ac9ee41fff --- /dev/null +++ b/homeassistant/components/gatus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Gatus integration.""" + +DOMAIN = "gatus" diff --git a/homeassistant/components/gatus/coordinator.py b/homeassistant/components/gatus/coordinator.py new file mode 100644 index 000000000000..37739f2ff6f3 --- /dev/null +++ b/homeassistant/components/gatus/coordinator.py @@ -0,0 +1,48 @@ +"""DataUpdateCoordinator for the Gatus integration.""" + +from datetime import timedelta +import logging +from typing import override + +from gatus_api import EndpointStatus, GatusClient, GatusClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type GatusConfigEntry = ConfigEntry[GatusDataUpdateCoordinator] + + +class GatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, EndpointStatus]]): + """Class to manage fetching Gatus data from the API via third-party library.""" + + def __init__(self, hass: HomeAssistant, entry: GatusConfigEntry, url: str) -> None: + """Initialize the coordinator.""" + self.url = url.rstrip("/") + self.client = GatusClient(url=self.url, session=async_get_clientsession(hass)) + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + @override + async def _async_update_data(self) -> dict[str, EndpointStatus]: + """Fetch endpoint statuses from the Gatus API.""" + try: + raw_endpoints = await self.client.get_endpoints_statuses() + except GatusClientError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from err + + return {ep.key: ep for ep in raw_endpoints} diff --git a/homeassistant/components/gatus/manifest.json b/homeassistant/components/gatus/manifest.json new file mode 100644 index 000000000000..53fddeab56ed --- /dev/null +++ b/homeassistant/components/gatus/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "gatus", + "name": "Gatus", + "codeowners": ["@TN-1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gatus", + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["gatus_api"], + "quality_scale": "silver", + "requirements": ["gatus-api==1.0.3"] +} diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml new file mode 100644 index 000000000000..dab6799b0a88 --- /dev/null +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: + status: exempt + comment: Integration does not register custom 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: Integration does not register custom 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: done + reauthentication-flow: + status: exempt + comment: Integration does not use authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: 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: + status: exempt + comment: All entities represent monitored services and should be enabled by default. + entity-translations: + status: exempt + comment: Entity names are dynamically provided by the Gatus service. + exception-translations: done + icon-translations: + status: exempt + comment: Entities use the connectivity device class for their icon and define no custom icons. + reconfiguration-flow: done + repair-issues: + status: exempt + comment: Integration does not require user intervention repairs. + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/gatus/strings.json b/homeassistant/components/gatus/strings.json new file mode 100644 index 000000000000..413dc7180c91 --- /dev/null +++ b/homeassistant/components/gatus/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reconfigure": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "[%key:component::gatus::config::step::user::data_description::url%]" + }, + "description": "[%key:component::gatus::config::step::user::description%]" + }, + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The full base URL of your Gatus status page instance including protocol and port." + }, + "description": "Enter the network details for your Gatus status page instance. Make sure to include the protocol (e.g., `http://` or `https://`) and the port number if you are not using a standard port." + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error communicating with Gatus API" + } + } +} diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 33463d54dbd0..ec69e74bc215 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==17.0.1", "Pillow==12.2.0"] + "requirements": ["av==17.0.1", "Pillow==12.3.0"] } diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 9af17b89c1ce..9540869b2765 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -105,7 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # humidifier's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 3d4781d43ed9..ef9acaaac724 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -7,7 +7,6 @@ import logging from typing import TYPE_CHECKING, Any, cast, override from homeassistant.components.humidifier import ( - ATTR_HUMIDITY, MODE_AWAY, MODE_NORMAL, PLATFORM_SCHEMA as HUMIDIFIER_PLATFORM_SCHEMA, @@ -15,11 +14,11 @@ from homeassistant.components.humidifier import ( HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, + HumidifierEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_MODE, CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, @@ -266,12 +265,17 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) if (old_state := await self.async_get_last_state()) is not None: - if old_state.attributes.get(ATTR_MODE) == MODE_AWAY: + if ( + old_state.attributes.get(HumidifierEntityStateAttribute.MODE) + == MODE_AWAY + ): self._is_away = True self._saved_target_humidity = self._target_humidity self._target_humidity = self._away_humidity or self._target_humidity - if old_state.attributes.get(ATTR_HUMIDITY): - self._target_humidity = int(old_state.attributes[ATTR_HUMIDITY]) + if old_state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY): + self._target_humidity = int( + old_state.attributes[HumidifierEntityStateAttribute.HUMIDITY] + ) if old_state.attributes.get(ATTR_SAVED_HUMIDITY): self._saved_target_humidity = int( old_state.attributes[ATTR_SAVED_HUMIDITY] diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index e2e997b9c11b..75f552b2850a 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -33,7 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # heater's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 3828ea00945e..31491b3306c2 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -13,11 +13,11 @@ import voluptuous as vol from homeassistant.components.climate import ( ATTR_HVAC_MODE, - ATTR_PRESET_MODE, PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, PRESET_NONE, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACAction, HVACMode, ) @@ -351,7 +351,10 @@ class GenericThermostat(ClimateEntity, RestoreEntity): # If we have no initial temperature, restore if self._target_temp is None: # If we have a previously saved temperature - if old_state.attributes.get(ATTR_TEMPERATURE) is None: + if ( + old_state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE) + is None + ): if self.ac_mode: self._target_temp = self.max_temp else: @@ -361,12 +364,17 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._target_temp, ) else: - self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) + self._target_temp = float( + old_state.attributes[ClimateEntityStateAttribute.TEMPERATURE] + ) if ( self.preset_modes - and old_state.attributes.get(ATTR_PRESET_MODE) in self.preset_modes + and old_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) + in self.preset_modes ): - self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + self._attr_preset_mode = old_state.attributes.get( + ClimateEntityStateAttribute.PRESET_MODE + ) if not self._hvac_mode and old_state.state: self._hvac_mode = HVACMode(old_state.state) diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index f9d6972f9c25..e3ce4bd21438 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -5,12 +5,9 @@ import logging import aiohttp from geniushubclient import GeniusHub -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_TEMPERATURE, CONF_HOST, CONF_MAC, CONF_PASSWORD, @@ -18,14 +15,15 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.service import verify_domain_control +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import setup_service_functions _LOGGER = logging.getLogger(__name__) @@ -34,31 +32,6 @@ SCAN_INTERVAL = timedelta(seconds=60) MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" -ATTR_ZONE_MODE = "mode" -ATTR_DURATION = "duration" - -SVC_SET_ZONE_MODE = "set_zone_mode" -SVC_SET_ZONE_OVERRIDE = "set_zone_override" - -SET_ZONE_MODE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_ZONE_MODE): vol.In(["off", "timer", "footprint"]), - } -) -SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_TEMPERATURE): vol.All( - vol.Coerce(float), vol.Range(min=4, max=28) - ), - vol.Optional(ATTR_DURATION): vol.All( - cv.time_period, - vol.Range(min=timedelta(minutes=5), max=timedelta(days=1)), - ), - } -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -70,6 +43,14 @@ PLATFORMS = [ type GeniusHubConfigEntry = ConfigEntry[GeniusBroker] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Genius Hub services.""" + setup_service_functions(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> bool: """Create a Genius Hub system.""" @@ -111,49 +92,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL) - setup_service_functions(hass, broker) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -@callback -def setup_service_functions(hass: HomeAssistant, broker): - """Set up the service functions.""" - - @verify_domain_control(DOMAIN) - async def set_zone_mode(call: ServiceCall) -> None: - """Set the system mode.""" - entity_id = call.data[ATTR_ENTITY_ID] - - registry = er.async_get(hass) - registry_entry = registry.async_get(entity_id) - - if registry_entry is None or registry_entry.platform != DOMAIN: - raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity") - - if registry_entry.domain != "climate": - raise ValueError(f"'{entity_id}' is not an {DOMAIN} zone") - - payload = { - "unique_id": registry_entry.unique_id, - "service": call.service, - "data": call.data, - } - - async_dispatcher_send(hass, DOMAIN, payload) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SVC_SET_ZONE_MODE, set_zone_mode, schema=SET_ZONE_MODE_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SVC_SET_ZONE_OVERRIDE, set_zone_mode, schema=SET_ZONE_OVERRIDE_SCHEMA - ) - - class GeniusBroker: """Container for geniushub client and data.""" diff --git a/homeassistant/components/geniushub/const.py b/homeassistant/components/geniushub/const.py index 4601eca5f9bd..e62caa15fd45 100644 --- a/homeassistant/components/geniushub/const.py +++ b/homeassistant/components/geniushub/const.py @@ -6,6 +6,12 @@ from homeassistant.const import Platform DOMAIN = "geniushub" +ATTR_ZONE_MODE = "mode" +ATTR_DURATION = "duration" + +SVC_SET_ZONE_MODE = "set_zone_mode" +SVC_SET_ZONE_OVERRIDE = "set_zone_override" + SCAN_INTERVAL = timedelta(seconds=60) SENSOR_PREFIX = "Genius" diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py index 741adac6c014..c9d7085b14c8 100644 --- a/homeassistant/components/geniushub/entity.py +++ b/homeassistant/components/geniushub/entity.py @@ -8,7 +8,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -from . import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE +from .const import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE # temperature is repeated here, as it gives access to high-precision temps GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"] diff --git a/homeassistant/components/geniushub/services.py b/homeassistant/components/geniushub/services.py new file mode 100644 index 000000000000..ac22ee8c5283 --- /dev/null +++ b/homeassistant/components/geniushub/services.py @@ -0,0 +1,81 @@ +"""Support for Genius Hub services.""" + +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service import verify_domain_control + +from .const import ( + ATTR_DURATION, + ATTR_ZONE_MODE, + DOMAIN, + SVC_SET_ZONE_MODE, + SVC_SET_ZONE_OVERRIDE, +) + +SET_ZONE_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ZONE_MODE): vol.In(["off", "timer", "footprint"]), + } +) +SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Range(min=4, max=28) + ), + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, + vol.Range(min=timedelta(minutes=5), max=timedelta(days=1)), + ), + } +) + + +@callback +def setup_service_functions(hass: HomeAssistant) -> None: + """Set up the service functions.""" + + @verify_domain_control(DOMAIN) + async def set_zone_mode(call: ServiceCall) -> None: + """Set the system mode.""" + entity_id = call.data[ATTR_ENTITY_ID] + + registry = er.async_get(hass) + registry_entry = registry.async_get(entity_id) + + if registry_entry is None or registry_entry.platform != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entity_id", + translation_placeholders={"entity_id": entity_id}, + ) + + if registry_entry.domain != "climate": + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_zone", + translation_placeholders={"entity_id": entity_id}, + ) + + payload = { + "unique_id": registry_entry.unique_id, + "service": call.service, + "data": call.data, + } + + async_dispatcher_send(hass, DOMAIN, payload) + + hass.services.async_register( + DOMAIN, SVC_SET_ZONE_MODE, set_zone_mode, schema=SET_ZONE_MODE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SVC_SET_ZONE_OVERRIDE, set_zone_mode, schema=SET_ZONE_OVERRIDE_SCHEMA + ) diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index 57521666ffb6..a90df19191fd 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -34,6 +34,14 @@ } }, + "exceptions": { + "invalid_entity_id": { + "message": "Unknown Genius Hub entity: {entity_id}" + }, + "invalid_zone": { + "message": "Unknown Genius Hub zone: {entity_id}" + } + }, "services": { "set_switch_override": { "description": "Overrides switch for a given duration.", diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 0abb997612f5..d22ec6c6c833 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -11,7 +11,8 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import ATTR_DURATION, GeniusHubConfigEntry +from . import GeniusHubConfigEntry +from .const import ATTR_DURATION from .entity import GeniusZone GH_ON_OFF_ZONE = "on / off" diff --git a/homeassistant/components/gentex_homelink/oauth2.py b/homeassistant/components/gentex_homelink/oauth2.py index 894c15378605..027100b7446d 100644 --- a/homeassistant/components/gentex_homelink/oauth2.py +++ b/homeassistant/components/gentex_homelink/oauth2.py @@ -110,7 +110,6 @@ class AsyncConfigEntryAuth(AbstractAuth): 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() + await self._oauth_session.async_ensure_token_valid() return self._oauth_session.token["access_token"] diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 743bc3ede71d..35a5f5c1a51a 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -7,7 +7,11 @@ from typing import Any, final, override from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE # noqa: F401 +from homeassistant.const import ( # noqa: F401 + ATTR_LATITUDE, + ATTR_LONGITUDE, + EntityStateAttribute, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity @@ -105,7 +109,7 @@ class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the state attributes of this external event.""" data: dict[str, Any] = {GeolocationEntityStateAttribute.SOURCE: self.source} if self.latitude is not None: - data[GeolocationEntityStateAttribute.LATITUDE] = round(self.latitude, 5) + data[EntityStateAttribute.LATITUDE] = round(self.latitude, 5) if self.longitude is not None: - data[GeolocationEntityStateAttribute.LONGITUDE] = round(self.longitude, 5) + data[EntityStateAttribute.LONGITUDE] = round(self.longitude, 5) return data diff --git a/homeassistant/components/geo_location/const.py b/homeassistant/components/geo_location/const.py index 8cd7194e8551..6d1513ccb714 100644 --- a/homeassistant/components/geo_location/const.py +++ b/homeassistant/components/geo_location/const.py @@ -2,10 +2,19 @@ from enum import StrEnum +from homeassistant.helpers.deprecation import EnumWithDeprecatedMembers -class GeolocationEntityStateAttribute(StrEnum): + +class GeolocationEntityStateAttribute( + StrEnum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "LATITUDE": ("EntityStateAttribute.LATITUDE", "2027.2.0"), + "LONGITUDE": ("EntityStateAttribute.LONGITUDE", "2027.2.0"), + }, +): """State attributes for geolocation entities.""" SOURCE = "source" - LATITUDE = "latitude" - LONGITUDE = "longitude" + LATITUDE = "latitude" # Deprecated, replaced with EntityStateAttribute.LATITUDE + LONGITUDE = "longitude" # Deprecated, replaced with EntityStateAttribute.LONGITUDE diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 788a5dffac79..8d7c3b24cc42 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -3,7 +3,7 @@ from typing import override from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -92,8 +92,8 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) @override async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 473a4ac0ad50..72f305d9f1e8 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -4,7 +4,7 @@ import logging from typing import Any, override from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength +from homeassistant.const import EntityStateAttribute, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -147,8 +147,8 @@ class GeonetnzVolcanoSensor(SensorEntity): (ATTR_EXTERNAL_ID, self._external_id), (ATTR_ACTIVITY, self._activity), (ATTR_HAZARDS, self._hazards), - (ATTR_LONGITUDE, self._longitude), - (ATTR_LATITUDE, self._latitude), + (EntityStateAttribute.LONGITUDE, self._longitude), + (EntityStateAttribute.LATITUDE, self._latitude), (ATTR_DISTANCE, self._distance), (ATTR_LAST_UPDATE, self._feed_last_update), (ATTR_LAST_UPDATE_SUCCESSFUL, self._feed_last_update_successful), diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index 4d2dc968eb9c..4b4bb22d6d9c 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -98,9 +98,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> if device := dev_reg.async_get_device({(DOMAIN, repository)}): dev_reg.async_update_device( device.id, - remove_config_entry_id=entry.entry_id, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=entry.entry_id, + new_config_entry_id=entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) hass.config_entries.async_update_entry(entry, minor_version=2) return True diff --git a/homeassistant/components/gitlab_ci/__init__.py b/homeassistant/components/gitlab_ci/__init__.py index 93b2a08c714a..27d82026ab9d 100644 --- a/homeassistant/components/gitlab_ci/__init__.py +++ b/homeassistant/components/gitlab_ci/__init__.py @@ -1 +1 @@ -"""The gitlab_ci component.""" +"""The GitLab-CI integration.""" diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 817467828325..3c87085929d2 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -1,13 +1,11 @@ """Support for Actions on Google Assistant Smart Home Control.""" # pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern -import logging - import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType @@ -29,15 +27,13 @@ from .const import ( # noqa: F401 DEFAULT_EXPOSED_DOMAINS, DOMAIN, EVENT_QUERY_RECEIVED, - SERVICE_REQUEST_SYNC, SOURCE_CLOUD, ) from .http import GoogleAssistantView, GoogleConfig +from .services import async_register_services from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, isort:skip -_LOGGER = logging.getLogger(__name__) - CONF_ALLOW_UNLOCK = "allow_unlock" PLATFORMS = [Platform.BUTTON] @@ -105,6 +101,9 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: hass.data[DOMAIN] = {} hass.data[DOMAIN][DATA_CONFIG] = yaml_config[DOMAIN] + if CONF_SERVICE_ACCOUNT in yaml_config[DOMAIN]: + async_register_services(hass) + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -149,26 +148,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo if google_config.should_report_state: google_config.async_enable_report_state() - async def request_sync_service_handler(call: ServiceCall) -> None: - """Handle request sync service calls.""" - agent_user_id = call.data.get("agent_user_id") or call.context.user_id - - if agent_user_id is None: - _LOGGER.warning( - "No agent_user_id supplied for request_sync. Call as a user or pass in" - " user id as agent_user_id" - ) - return - - await google_config.async_sync_entities(agent_user_id) - - # Register service only if key is provided - if CONF_SERVICE_ACCOUNT in config: - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/google_assistant/services.py b/homeassistant/components/google_assistant/services.py new file mode 100644 index 000000000000..2c4391558e5d --- /dev/null +++ b/homeassistant/components/google_assistant/services.py @@ -0,0 +1,37 @@ +"""Support for Google Assistant services.""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_REQUEST_SYNC +from .http import GoogleConfig + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_services(hass: HomeAssistant) -> None: + """Register Google Assistant services.""" + + async def request_sync_service_handler(call: ServiceCall) -> None: + """Handle request sync service calls.""" + agent_user_id = call.data.get("agent_user_id") or call.context.user_id + + if agent_user_id is None: + _LOGGER.warning( + "No agent_user_id supplied for request_sync. Call as a user or pass in" + " user id as agent_user_id" + ) + return + + entry: ConfigEntry[GoogleConfig] = service.async_get_config_entry( + hass, DOMAIN, None + ) + await entry.runtime_data.async_sync_entities(agent_user_id) + + hass.services.async_register( + DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler + ) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index b3f0eb0ce829..2667a278c9c1 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( DEFAULT_AI_TASK_NAME, @@ -182,7 +182,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # 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 + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -192,20 +192,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/google_health/__init__.py b/homeassistant/components/google_health/__init__.py new file mode 100644 index 000000000000..a389edc4bfa3 --- /dev/null +++ b/homeassistant/components/google_health/__init__.py @@ -0,0 +1,88 @@ +"""The Google Health integration.""" + +from dataclasses import dataclass + +from google_health_api import GoogleHealthApi +from google_health_api.const import HealthApiScope + +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 import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) + +from . import api +from .const import DOMAIN +from .coordinator import GoogleHealthActivityCoordinator, GoogleHealthBodyCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +@dataclass +class GoogleHealthData: + """Class to hold Google Health coordinators.""" + + activity_coordinator: GoogleHealthActivityCoordinator | None = None + body_coordinator: GoogleHealthBodyCoordinator | None = None + + +type GoogleHealthConfigEntry = ConfigEntry[GoogleHealthData] + + +async def async_setup_entry( + hass: HomeAssistant, entry: GoogleHealthConfigEntry +) -> bool: + """Set up Google Health from a config entry.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth_error", + ) from err + + session = OAuth2Session(hass, entry, implementation) + + scopes = session.token.get("scope", "").split() + if HealthApiScope.PROFILE_READ not in scopes: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="missing_profile_scope", + ) + + auth = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + + api_client = GoogleHealthApi(auth) + + activity_coordinator = None + if all(scope in scopes for scope in api_client.steps.required_read_scopes): + activity_coordinator = GoogleHealthActivityCoordinator(hass, entry, api_client) + await activity_coordinator.async_config_entry_first_refresh() + + body_coordinator = None + if all(scope in scopes for scope in api_client.weight.required_read_scopes): + body_coordinator = GoogleHealthBodyCoordinator(hass, entry, api_client) + await body_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = GoogleHealthData( + activity_coordinator=activity_coordinator, + body_coordinator=body_coordinator, + ) + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleHealthConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/google_health/api.py b/homeassistant/components/google_health/api.py new file mode 100644 index 000000000000..02c3bcfd4b4a --- /dev/null +++ b/homeassistant/components/google_health/api.py @@ -0,0 +1,42 @@ +"""API for Google Health bound to Home Assistant OAuth.""" + +from typing import cast, override + +from aiohttp import ClientSession +from google_health_api.auth import AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Google Health authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Google Health auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + @override + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) + + +class SimpleAuth(AbstractAuth): + """Temporary auth helper for the config flow.""" + + def __init__(self, websession: ClientSession, access_token: str) -> None: + """Initialize the auth helper.""" + super().__init__(websession) + self._access_token = access_token + + @override + async def async_get_access_token(self) -> str: + """Return the access token.""" + return self._access_token diff --git a/homeassistant/components/google_health/application_credentials.py b/homeassistant/components/google_health/application_credentials.py new file mode 100644 index 000000000000..732a13fece13 --- /dev/null +++ b/homeassistant/components/google_health/application_credentials.py @@ -0,0 +1,23 @@ +"""Application credentials platform for the Google Health 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, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_health/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_health/config_flow.py b/homeassistant/components/google_health/config_flow.py new file mode 100644 index 000000000000..4450e44e6fe7 --- /dev/null +++ b/homeassistant/components/google_health/config_flow.py @@ -0,0 +1,105 @@ +"""Config flow for Google Health.""" + +from collections.abc import Mapping +import logging +from typing import Any, override + +from google_health_api import GoogleHealthApi +from google_health_api.const import HealthApiScope +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, +) + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from .api import SimpleAuth +from .const import API_CONSOLE_URL, DEFAULT_TITLE, DOMAIN, OAUTH_SCOPES + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Health OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + @override + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + @property + @override + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH_SCOPES), + "access_type": "offline", + "prompt": "consent", + } + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + @override + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + scopes = data.get(CONF_TOKEN, {}).get("scope", "").split() + if HealthApiScope.PROFILE_READ not in scopes: + return self.async_abort(reason="missing_profile_scope") + + access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] + websession = aiohttp_client.async_get_clientsession(self.hass) + auth = SimpleAuth(websession, access_token) + api = GoogleHealthApi(auth) + + try: + identity = await api.get_identity() + except HealthApiForbiddenException as err: + _LOGGER.error("Error getting Google Health identity: %s", err) + return self.async_abort( + reason="api_not_enabled", + description_placeholders={"url": API_CONSOLE_URL}, + ) + except GoogleHealthApiError as err: + _LOGGER.error("Error getting Google Health identity: %s", err) + return self.async_abort(reason="cannot_connect") + + if not identity.health_user_id: + _LOGGER.error("Google Health identity has no health_user_id") + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(identity.health_user_id) + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort(reauth_entry, data=data) + self._abort_if_unique_id_configured() + + display_name = None + if HealthApiScope.USERINFO_PROFILE in scopes or "profile" in scopes: + try: + userinfo = await api.get_user_info() + display_name = userinfo.given_name or userinfo.name + except Exception as err: # pylint: disable=broad-except # noqa: BLE001 + _LOGGER.warning("Error fetching user profile name: %s", err) + + return self.async_create_entry( + title=display_name or DEFAULT_TITLE, + data=data, + ) diff --git a/homeassistant/components/google_health/const.py b/homeassistant/components/google_health/const.py new file mode 100644 index 000000000000..fc4660cc0695 --- /dev/null +++ b/homeassistant/components/google_health/const.py @@ -0,0 +1,21 @@ +"""Constants for the Google Health integration.""" + +from google_health_api.const import HealthApiScope + +DOMAIN = "google_health" + +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" + +API_CONSOLE_URL = ( + "https://console.developers.google.com/apis/api/health.googleapis.com/overview" +) + +DEFAULT_TITLE = "Google Health" + +OAUTH_SCOPES = [ + HealthApiScope.ACTIVITY_READ, + HealthApiScope.PROFILE_READ, + HealthApiScope.MEASUREMENTS_READ, + HealthApiScope.USERINFO_PROFILE, +] diff --git a/homeassistant/components/google_health/coordinator.py b/homeassistant/components/google_health/coordinator.py new file mode 100644 index 000000000000..a9b515b998aa --- /dev/null +++ b/homeassistant/components/google_health/coordinator.py @@ -0,0 +1,213 @@ +"""Coordinators for Google Health.""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, override + +from google_health_api import GoogleHealthApi +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, + HealthAuthException, +) +from google_health_api.model import ( + ActiveEnergyBurnedRollupValue, + BodyFat, + DailyRestingHeartRate, + DistanceRollupValue, + FloorsRollupValue, + StepsRollupValue, + TotalCaloriesRollupValue, + Weight, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +if TYPE_CHECKING: + from . import GoogleHealthConfigEntry + +_LOGGER = logging.getLogger(__name__) + +POLLING_INTERVAL = timedelta(minutes=15) +BODY_POLLING_INTERVAL = timedelta(hours=1) +DEFAULT_PAGE_SIZE = 1 + + +@dataclass +class GoogleHealthActivityData: + """Class to hold activity data.""" + + steps: StepsRollupValue | None = None + distance: DistanceRollupValue | None = None + active_energy_burned: ActiveEnergyBurnedRollupValue | None = None + total_calories: TotalCaloriesRollupValue | None = None + floors: FloorsRollupValue | None = None + + +@dataclass +class GoogleHealthBodyData: + """Class to hold body measurements.""" + + weight: Weight | None = None + resting_heart_rate: DailyRestingHeartRate | None = None + body_fat: BodyFat | None = None + + +class GoogleHealthDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base coordinator for Google Health API.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + update_interval: timedelta, + entry: GoogleHealthConfigEntry, + api_client: GoogleHealthApi, + ) -> None: + """Initialize the coordinator.""" + self.api = api_client + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + config_entry=entry, + ) + + @override + async def _async_update_data(self) -> _DataT: + """Fetch data from API.""" + try: + return await self._async_fetch_data() + except (HealthAuthException, HealthApiForbiddenException) as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err + except GoogleHealthApiError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from err + + async def _async_fetch_data(self) -> _DataT: + """Fetch data from API.""" + raise NotImplementedError + + +class GoogleHealthActivityCoordinator( + GoogleHealthDataUpdateCoordinator[GoogleHealthActivityData] +): + """Coordinator to fetch activity data from Google Health API.""" + + def __init__( + self, + hass: HomeAssistant, + entry: GoogleHealthConfigEntry, + api_client: GoogleHealthApi, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_activity", + update_interval=POLLING_INTERVAL, + entry=entry, + api_client=api_client, + ) + + @override + async def _async_fetch_data(self) -> GoogleHealthActivityData: + """Fetch activity rollups for today. + + Queries the daily rollup endpoints in parallel using Home Assistant's + local time zone to aggregate steps, distance, active calories, total + calories, and floors. If no data points exist for today yet, the API + returns None, which the sensors default to 0. + """ + ( + steps_rollup, + distance_rollup, + active_energy_rollup, + total_calories_rollup, + floors_rollup, + ) = await asyncio.gather( + self.api.steps.today(self.hass.config.time_zone), + self.api.distance.today(self.hass.config.time_zone), + self.api.active_energy_burned.today(self.hass.config.time_zone), + self.api.total_calories.today(self.hass.config.time_zone), + self.api.floors.today(self.hass.config.time_zone), + ) + + steps = steps_rollup.data if steps_rollup else None + distance = distance_rollup.data if distance_rollup else None + active_energy_burned = ( + active_energy_rollup.data if active_energy_rollup else None + ) + total_calories = total_calories_rollup.data if total_calories_rollup else None + floors = floors_rollup.data if floors_rollup else None + + return GoogleHealthActivityData( + steps=steps, + distance=distance, + active_energy_burned=active_energy_burned, + total_calories=total_calories, + floors=floors, + ) + + +class GoogleHealthBodyCoordinator( + GoogleHealthDataUpdateCoordinator[GoogleHealthBodyData] +): + """Coordinator to fetch body measurements from Google Health API.""" + + def __init__( + self, + hass: HomeAssistant, + entry: GoogleHealthConfigEntry, + api_client: GoogleHealthApi, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_body", + update_interval=BODY_POLLING_INTERVAL, + entry=entry, + api_client=api_client, + ) + + @override + async def _async_fetch_data(self) -> GoogleHealthBodyData: + """Fetch latest body weight, resting heart rate, and body fat in parallel.""" + # The Google Health API returns data points sorted by interval start time + # in descending order (newest first). Querying with page_size=1 and grabbing + # the first element is sufficient to fetch the most recent measurement. + weight_result, hr_result, body_fat_result = await asyncio.gather( + self.api.weight.list(page_size=DEFAULT_PAGE_SIZE), + self.api.daily_resting_heart_rate.list(page_size=DEFAULT_PAGE_SIZE), + self.api.body_fat.list(page_size=DEFAULT_PAGE_SIZE), + ) + + weight = ( + weight_result.data_points[0].data if weight_result.data_points else None + ) + resting_heart_rate = ( + hr_result.data_points[0].data if hr_result.data_points else None + ) + body_fat = ( + body_fat_result.data_points[0].data if body_fat_result.data_points else None + ) + + return GoogleHealthBodyData( + weight=weight, + resting_heart_rate=resting_heart_rate, + body_fat=body_fat, + ) diff --git a/homeassistant/components/google_health/icons.json b/homeassistant/components/google_health/icons.json new file mode 100644 index 000000000000..2c15524eb0f2 --- /dev/null +++ b/homeassistant/components/google_health/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "resting_heart_rate": { + "default": "mdi:heart-pulse" + }, + "steps": { + "default": "mdi:walk" + } + } + } +} diff --git a/homeassistant/components/google_health/manifest.json b/homeassistant/components/google_health/manifest.json new file mode 100644 index 000000000000..52ce63008c72 --- /dev/null +++ b/homeassistant/components/google_health/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "google_health", + "name": "Google Health", + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["application_credentials", "http", "webhook"], + "documentation": "https://www.home-assistant.io/integrations/google_health", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["google-health-api==0.5.1"] +} diff --git a/homeassistant/components/google_health/quality_scale.yaml b/homeassistant/components/google_health/quality_scale.yaml new file mode 100644 index 000000000000..adec2cbaf41d --- /dev/null +++ b/homeassistant/components/google_health/quality_scale.yaml @@ -0,0 +1,88 @@ +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-conditions: + status: exempt + comment: This integration does not have custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have custom triggers. + entity-event-setup: + status: exempt + comment: This 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: This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration parameters are available. + 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: 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: 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: + status: exempt + comment: All entities are user-facing primary sensors. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: No entities need to be disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration does not raise repair issues. + stale-devices: + status: exempt + comment: This integration has a static device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/google_health/sensor.py b/homeassistant/components/google_health/sensor.py new file mode 100644 index 000000000000..004f84bce3de --- /dev/null +++ b/homeassistant/components/google_health/sensor.py @@ -0,0 +1,173 @@ +"""Sensor platform for the Google Health integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, cast, override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfMass +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 GoogleHealthConfigEntry +from .const import DOMAIN +from .coordinator import ( + GoogleHealthActivityCoordinator, + GoogleHealthBodyCoordinator, + GoogleHealthDataUpdateCoordinator, +) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class GoogleHealthSensorEntityDescription[ + _CoordinatorT: GoogleHealthDataUpdateCoordinator[Any], + _ValueT: StateType, +](SensorEntityDescription): + """Class describing Google Health sensor entities.""" + + value_fn: Callable[[Any], _ValueT] + + +ACTIVITY_SENSORS: list[ + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, Any] +] = [ + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int]( + key="steps", + translation_key="steps", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.steps.count_sum if data and data.steps else 0, + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( + key="distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: ( + data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0 + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( + key="active_calories", + translation_key="active_calories", + native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: ( + data.active_energy_burned.kcal_sum + if data and data.active_energy_burned + else 0.0 + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float]( + key="total_calories", + translation_key="total_calories", + native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: ( + data.total_calories.kcal_sum if data and data.total_calories else 0.0 + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, int]( + key="floors", + translation_key="floors", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.floors.count_sum if data and data.floors else 0, + ), +] + +BODY_SENSORS: list[ + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, Any] +] = [ + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None]( + key="weight", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.weight.weight_grams / 1000.0 if data and data.weight else None + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, int | None]( + key="resting_heart_rate", + translation_key="resting_heart_rate", + native_unit_of_measurement="bpm", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.resting_heart_rate.beats_per_minute + if data and data.resting_heart_rate + else None + ), + ), + GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, float | None]( + key="body_fat", + translation_key="body_fat", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.body_fat.percentage if data and data.body_fat else None + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GoogleHealthConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Google Health sensor platform.""" + data = entry.runtime_data + + entities: list[SensorEntity] = [] + if (activity_coordinator := data.activity_coordinator) is not None: + entities.extend( + GoogleHealthSensor(activity_coordinator, entry.entry_id, description) + for description in ACTIVITY_SENSORS + ) + if (body_coordinator := data.body_coordinator) is not None: + entities.extend( + GoogleHealthSensor(body_coordinator, entry.entry_id, description) + for description in BODY_SENSORS + ) + + if entities: + async_add_entities(entities) + + +class GoogleHealthSensor[_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any]]( + CoordinatorEntity[_CoordinatorT], SensorEntity +): + """Generic Google Health sensor entity.""" + + _attr_has_entity_name = True + entity_description: GoogleHealthSensorEntityDescription[_CoordinatorT, Any] + + def __init__( + self, + coordinator: _CoordinatorT, + entry_id: str, + description: GoogleHealthSensorEntityDescription[_CoordinatorT, Any], + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + manufacturer="Google", + ) + + @property + @override + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return cast(StateType, self.entity_description.value_fn(self.coordinator.data)) diff --git a/homeassistant/components/google_health/strings.json b/homeassistant/components/google_health/strings.json new file mode 100644 index 000000000000..3263e03978f1 --- /dev/null +++ b/homeassistant/components/google_health/strings.json @@ -0,0 +1,73 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Health data. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + }, + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "api_not_enabled": "The Google Health API is not enabled for your Google Cloud project. Enable it in the [Google Cloud Console]({url}), wait a few minutes for the change to propagate, then try again.", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "cannot_connect": "Failed to connect.", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_profile_scope": "Missing required Google Health profile read permission.", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Google Health integration needs to re-authenticate your account", + "title": "[%key:common::config_flow::title::reauth%]" + } + } + }, + "entity": { + "sensor": { + "active_calories": { + "name": "Active calories" + }, + "body_fat": { + "name": "Body fat" + }, + "floors": { + "name": "Floors" + }, + "resting_heart_rate": { + "name": "Resting heart rate" + }, + "steps": { + "name": "Steps", + "unit_of_measurement": "steps" + }, + "total_calories": { + "name": "Total calories" + } + } + }, + "exceptions": { + "auth_error": { + "message": "Authentication or permission error talking to Google Health." + }, + "communication_error": { + "message": "Error communicating with Google Health." + }, + "missing_profile_scope": { + "message": "Missing required Google Health profile read permission." + }, + "oauth_error": { + "message": "OAuth2 implementation temporarily unavailable." + } + } +} diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 3700e0fb890d..f1d46178ccc8 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -1,4 +1,5 @@ """Support for Google Mail.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index c8dd60ba98f2..32e591e099cd 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -2,13 +2,11 @@ from typing import override -from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, ) +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -120,9 +118,11 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) - self._attr_location_accuracy = attr.get(ATTR_GPS_ACCURACY, 0) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) + self._attr_location_accuracy = attr.get( + TrackerEntityStateAttribute.GPS_ACCURACY, 0 + ) self._attr_extra_state_attributes = { ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), ATTR_ACTIVITY: attr.get(ATTR_ACTIVITY), diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 2e39f2e8226b..773bdd40899e 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -26,10 +26,6 @@ from homeassistant.components.climate import ( PRESET_ECO, PRESET_NONE, PRESET_SLEEP, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -57,7 +53,7 @@ HVAC_MODES = { Mode.Fan: HVACMode.FAN_ONLY, Mode.Heat: HVACMode.HEAT, } -HVAC_MODES_REVERSE = {v: k for k, v in HVAC_MODES.items()} +HVAC_MODES_INVERSE = {v: k for k, v in HVAC_MODES.items()} PRESET_MODES = [ PRESET_ECO, # Power saving mode @@ -75,9 +71,38 @@ FAN_MODES = { FanSpeed.MediumHigh: FAN_MEDIUM_HIGH, FanSpeed.High: FAN_HIGH, } -FAN_MODES_REVERSE = {v: k for k, v in FAN_MODES.items()} +FAN_MODES_INVERSE = {v: k for k, v in FAN_MODES.items()} -SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] +VERTICAL_SWING_MODES: dict[str, VerticalSwing] = { + "default": VerticalSwing.Default, + "full_swing": VerticalSwing.FullSwing, + "fixed_upper": VerticalSwing.FixedUpper, + "fixed_upper_middle": VerticalSwing.FixedUpperMiddle, + "fixed_middle": VerticalSwing.FixedMiddle, + "fixed_lower_middle": VerticalSwing.FixedLowerMiddle, + "fixed_lower": VerticalSwing.FixedLower, + "swing_upper": VerticalSwing.SwingUpper, + "swing_upper_middle": VerticalSwing.SwingUpperMiddle, + "swing_middle": VerticalSwing.SwingMiddle, + "swing_lower_middle": VerticalSwing.SwingLowerMiddle, + "swing_lower": VerticalSwing.SwingLower, +} +VERTICAL_SWING_MODES_INVERSE: dict[VerticalSwing, str] = { + v: k for k, v in VERTICAL_SWING_MODES.items() +} + +HORIZONTAL_SWING_MODES: dict[str, HorizontalSwing] = { + "default": HorizontalSwing.Default, + "full_swing": HorizontalSwing.FullSwing, + "left": HorizontalSwing.Left, + "left_center": HorizontalSwing.LeftCenter, + "center": HorizontalSwing.Center, + "right_center": HorizontalSwing.RightCenter, + "right": HorizontalSwing.Right, +} +HORIZONTAL_SWING_MODES_INVERSE: dict[HorizontalSwing, str] = { + v: k for k, v in HORIZONTAL_SWING_MODES.items() +} async def async_setup_entry( @@ -109,15 +134,18 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = TARGET_TEMPERATURE_STEP - _attr_hvac_modes = [*HVAC_MODES_REVERSE, HVACMode.OFF] + _attr_hvac_modes = [*HVAC_MODES_INVERSE, HVACMode.OFF] _attr_preset_modes = PRESET_MODES - _attr_fan_modes = [*FAN_MODES_REVERSE] - _attr_swing_modes = SWING_MODES + _attr_fan_modes = [*FAN_MODES_INVERSE] + _attr_swing_modes = [*VERTICAL_SWING_MODES] + _attr_swing_horizontal_modes = [*HORIZONTAL_SWING_MODES] _attr_name = None + _attr_translation_key = "climate" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_min_temp = TEMP_MIN _attr_max_temp = TEMP_MAX @@ -189,7 +217,7 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): if not self.coordinator.device.power: self.coordinator.device.power = True - self.coordinator.device.mode = HVAC_MODES_REVERSE.get(hvac_mode) + self.coordinator.device.mode = HVAC_MODES_INVERSE.get(hvac_mode) await self.coordinator.push_state_update() self.async_write_ha_state() @@ -264,47 +292,60 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): @override async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if fan_mode not in FAN_MODES_REVERSE: + if fan_mode not in FAN_MODES_INVERSE: raise ValueError(f"Invalid fan mode: {fan_mode}") - self.coordinator.device.fan_speed = FAN_MODES_REVERSE.get(fan_mode) + self.coordinator.device.fan_speed = FAN_MODES_INVERSE.get(fan_mode) await self.coordinator.push_state_update() self.async_write_ha_state() @property @override - def swing_mode(self) -> str: - """Return the current swing mode for the device.""" - h_swing = self.coordinator.device.horizontal_swing == HorizontalSwing.FullSwing - v_swing = self.coordinator.device.vertical_swing == VerticalSwing.FullSwing - - if h_swing and v_swing: - return SWING_BOTH - if h_swing: - return SWING_HORIZONTAL - if v_swing: - return SWING_VERTICAL - return SWING_OFF + def swing_mode(self) -> str | None: + """Return the current vertical swing mode for the device.""" + try: + return VERTICAL_SWING_MODES_INVERSE.get( + VerticalSwing(self.coordinator.device.vertical_swing) + ) + except ValueError: + return None @override async def async_set_swing_mode(self, swing_mode: str) -> None: - """Set new target swing operation.""" - if swing_mode not in SWING_MODES: - raise ValueError(f"Invalid swing mode: {swing_mode}") - + """Set new target vertical swing operation.""" _LOGGER.debug( - "Setting swing mode to %s for device %s", + "Setting vertical swing mode to %s for device %s", swing_mode, self._attr_name, ) - self.coordinator.device.horizontal_swing = HorizontalSwing.Center - self.coordinator.device.vertical_swing = VerticalSwing.FixedMiddle - if swing_mode in (SWING_BOTH, SWING_HORIZONTAL): - self.coordinator.device.horizontal_swing = HorizontalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_VERTICAL): - self.coordinator.device.vertical_swing = VerticalSwing.FullSwing + self.coordinator.device.vertical_swing = VERTICAL_SWING_MODES[swing_mode] + await self.coordinator.push_state_update() + self.async_write_ha_state() + @property + @override + def swing_horizontal_mode(self) -> str | None: + """Return the current horizontal swing mode for the device.""" + try: + return HORIZONTAL_SWING_MODES_INVERSE.get( + HorizontalSwing(self.coordinator.device.horizontal_swing) + ) + except ValueError: + return None + + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + _LOGGER.debug( + "Setting horizontal swing mode to %s for device %s", + swing_horizontal_mode, + self._attr_name, + ) + + self.coordinator.device.horizontal_swing = HORIZONTAL_SWING_MODES[ + swing_horizontal_mode + ] await self.coordinator.push_state_update() self.async_write_ha_state() diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index 153919fb0dce..cbf1cef49405 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -11,6 +11,39 @@ } }, "entity": { + "climate": { + "climate": { + "state_attributes": { + "swing_horizontal_mode": { + "state": { + "center": "Center", + "default": "Default", + "full_swing": "Full swing", + "left": "Left", + "left_center": "Left center", + "right": "Right", + "right_center": "Right center" + } + }, + "swing_mode": { + "state": { + "default": "Default", + "fixed_lower": "Fixed lower", + "fixed_lower_middle": "Fixed lower middle", + "fixed_middle": "Fixed middle", + "fixed_upper": "Fixed upper", + "fixed_upper_middle": "Fixed upper middle", + "full_swing": "Full swing", + "swing_lower": "Swing lower", + "swing_lower_middle": "Swing lower middle", + "swing_middle": "Swing middle", + "swing_upper": "Swing upper", + "swing_upper_middle": "Swing upper middle" + } + } + } + } + }, "switch": { "fresh_air": { "name": "Fresh air" diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 2061ce831cf8..2fafaa192806 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.group import ( expand_entity_ids as _expand_entity_ids, get_entity_ids as _get_entity_ids, + get_group_entities, ) from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.typing import ConfigType @@ -122,14 +123,42 @@ def groups_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]: Async friendly. """ - if DOMAIN not in hass.data: - return [] + groups: list[str] = [] - return [ - group.entity_id - for group in hass.data[DATA_COMPONENT].entities - if entity_id in group.tracking - ] + if DOMAIN in hass.data: + groups.extend( + group.entity_id + for group in hass.data[DATA_COMPONENT].entities + if entity_id in group.tracking + ) + + groups.extend( + group_entity_id + for group_entity_id, entity in get_group_entities(hass).items() + if entity.group is not None + and entity_id in entity.group.member_entity_ids + and group_entity_id not in groups + ) + + # Config entry groups whose platform does not (yet) register in + # the group entities registry of the group helper. + entity_registry = er.async_get(hass) + for entry in hass.config_entries.async_entries(DOMAIN): + members = [ + er.async_resolve_entity_id(entity_registry, member) or member + for member in entry.options[CONF_ENTITIES] + ] + if entity_id not in members: + continue + groups.extend( + registry_entry.entity_id + for registry_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if registry_entry.entity_id not in groups + ) + + return groups async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/group/icons.json b/homeassistant/components/group/icons.json index e3084bf950d6..f79b1e3b24f7 100644 --- a/homeassistant/components/group/icons.json +++ b/homeassistant/components/group/icons.json @@ -1,4 +1,14 @@ { + "entity": { + "light": { + "light": { + "default": "mdi:lightbulb-group", + "state": { + "off": "mdi:lightbulb-group-off" + } + } + } + }, "services": { "reload": { "service": "mdi:reload" diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index ace3878655a3..68f922272b4c 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -147,7 +147,7 @@ class LightGroup(GroupEntity, LightEntity): """Representation of a light group.""" _attr_available = False - _attr_icon = "mdi:lightbulb-group" + _attr_translation_key = "light" _attr_max_color_temp_kelvin = 6500 _attr_min_color_temp_kelvin = 2000 _attr_should_poll = False diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 819f5c8edbd0..abf9118c8fa5 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -23,6 +23,7 @@ Error handling pattern for reauth: → raise ConfigEntryAuthFailed - All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator) """ +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections.abc import Mapping import datetime @@ -452,10 +453,7 @@ async def async_setup_entry( for device_sn in device_domain_ids: if coordinator := runtime_data.devices.pop(device_sn, None): await coordinator.async_shutdown() - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) # Add new devices new_coordinators: list[GrowattCoordinator] = [] diff --git a/homeassistant/components/guntamatic/manifest.json b/homeassistant/components/guntamatic/manifest.json index 1b062a9a073a..09f3f81b6060 100644 --- a/homeassistant/components/guntamatic/manifest.json +++ b/homeassistant/components/guntamatic/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["guntamatic==1.9.0"] + "requirements": ["guntamatic==1.9.2"] } diff --git a/homeassistant/components/harbor/__init__.py b/homeassistant/components/harbor/__init__.py new file mode 100644 index 000000000000..1f10679688d0 --- /dev/null +++ b/homeassistant/components/harbor/__init__.py @@ -0,0 +1,43 @@ +"""The Harbor integration.""" + +from harbor.config import HarborCameraConfig + +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN, PLATFORMS +from .coordinator import HarborConfigEntry, HarborCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool: + """Set up Harbor from a config entry.""" + coordinator = HarborCoordinator( + hass, + entry, + HarborCameraConfig( + serial=entry.data[CONF_SERIAL], + cert_pem=entry.data[CONF_CERT_PEM], + key_pem=entry.data[CONF_KEY_PEM], + ip_address=entry.data[CONF_IP_ADDRESS], + ), + ) + await coordinator.async_start() + try: + await coordinator.async_wait_until_ready() + except TimeoutError as err: + await coordinator.async_shutdown() + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool: + """Unload a Harbor config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.async_shutdown() + return unload_ok diff --git a/homeassistant/components/harbor/config_flow.py b/homeassistant/components/harbor/config_flow.py new file mode 100644 index 000000000000..05d222fea54c --- /dev/null +++ b/homeassistant/components/harbor/config_flow.py @@ -0,0 +1,128 @@ +"""Config flow for Harbor.""" + +from typing import Any, override + +from harbor.config import HarborCameraConfig +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers import selector + +from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN +from .coordinator import async_probe_camera + +SERIAL_LENGTH = 10 + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL): selector.TextSelector(selector.TextSelectorConfig()), + vol.Required(CONF_CERT_PEM): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required(CONF_KEY_PEM): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Required(CONF_IP_ADDRESS): selector.TextSelector( + selector.TextSelectorConfig() + ), + } +) + + +def _validate_serial(value: str) -> bool: + """Validate the Harbor serial number.""" + return len(value) == SERIAL_LENGTH and value.isdigit() + + +def _validate_cert_pem(value: str) -> bool: + """Validate a Harbor client certificate PEM blob.""" + value = value.strip() + return value.startswith("-----BEGIN CERTIFICATE-----") and value.endswith( + "-----END CERTIFICATE-----" + ) + + +def _validate_key_pem(value: str) -> bool: + """Validate a Harbor private key PEM blob.""" + value = value.strip() + return value.startswith("-----BEGIN PRIVATE KEY-----") and value.endswith( + "-----END PRIVATE KEY-----" + ) + + +def _validate_credentials(cert_pem: str, key_pem: str) -> dict[str, str]: + """Validate cert/key PEM blobs and return any errors.""" + errors: dict[str, str] = {} + if not _validate_cert_pem(cert_pem): + errors[CONF_CERT_PEM] = "invalid_cert" + if not _validate_key_pem(key_pem): + errors[CONF_KEY_PEM] = "invalid_key" + return errors + + +class HarborConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Harbor.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors={}, + ) + + normalized = { + key: value.strip() if isinstance(value, str) else value + for key, value in user_input.items() + } + errors: dict[str, str] = {} + display_name: str | None = None + + serial = normalized[CONF_SERIAL] + if not _validate_serial(serial): + errors[CONF_SERIAL] = "invalid_serial" + + errors.update( + _validate_credentials(normalized[CONF_CERT_PEM], normalized[CONF_KEY_PEM]) + ) + + if not errors: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + config = HarborCameraConfig( + serial=serial, + cert_pem=normalized[CONF_CERT_PEM], + key_pem=normalized[CONF_KEY_PEM], + ip_address=normalized[CONF_IP_ADDRESS], + ) + try: + display_name = await async_probe_camera(config) + except TimeoutError: + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_SCHEMA, + errors=errors, + ) + + entry_data: dict[str, Any] = { + CONF_SERIAL: serial, + CONF_CERT_PEM: normalized[CONF_CERT_PEM], + CONF_KEY_PEM: normalized[CONF_KEY_PEM], + CONF_IP_ADDRESS: normalized[CONF_IP_ADDRESS], + } + + return self.async_create_entry( + title=display_name or f"Camera {serial}", + data=entry_data, + ) diff --git a/homeassistant/components/harbor/const.py b/homeassistant/components/harbor/const.py new file mode 100644 index 000000000000..f9b5670e332b --- /dev/null +++ b/homeassistant/components/harbor/const.py @@ -0,0 +1,13 @@ +"""Constants for the Harbor integration.""" + +from homeassistant.const import Platform + +DOMAIN = "harbor" +MANUFACTURER = "Harbor" +MODEL = "Harbor Camera" + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +CONF_CERT_PEM = "cert_pem" +CONF_KEY_PEM = "key_pem" +CONF_SERIAL = "serial" diff --git a/homeassistant/components/harbor/coordinator.py b/homeassistant/components/harbor/coordinator.py new file mode 100644 index 000000000000..55afb751b663 --- /dev/null +++ b/homeassistant/components/harbor/coordinator.py @@ -0,0 +1,176 @@ +"""Coordinator for Harbor.""" + +import asyncio +import logging +from typing import Any, override +from uuid import uuid4 + +from harbor.config import HarborCameraConfig +from harbor.devices.camera import HarborCamera +from harbor.mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient +from harbor.state import HarborDeviceState + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import instance_id +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, MODEL + +LOGGER = logging.getLogger(__name__) + +type HarborConfigEntry = ConfigEntry[HarborCoordinator] + +# How long to wait for the first successful MQTT connection and the first +# device data to arrive before treating the camera as unreachable, both when +# validating the config flow and during setup. +CONNECT_TIMEOUT = 30.0 + + +async def _discard_message(topic: str, payload: Any) -> None: + """Ignore messages received while probing the connection.""" + + +async def async_probe_camera(config: HarborCameraConfig) -> str | None: + """Connect to a Harbor camera and return its friendly name, if any. + + Raises ``TimeoutError`` when no MQTT session can be established with the + camera. Returns the camera's configured display name, or ``None`` when the + camera is reachable but has no name (or does not answer the settings + request in time). + """ + connected = asyncio.Event() + + async def _on_connection_change(is_connected: bool) -> None: + if is_connected: + connected.set() + + client = HarborMQTTClient( + config=config, + # Subscribe to the responses topic so the get-settings reply can be + # matched to its pending request; without a subscription the reply + # never reaches the client and the request would time out. + topics=[f"cameras/{config.serial}/responses/#"], + message_handler=_discard_message, + client_id=f"{DOMAIN}-{config.serial}-probe-{uuid4().hex[:8]}", + on_connection_change=_on_connection_change, + connection_grace_period=0, + ) + await client.start() + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + await connected.wait() + try: + settings = await client.get_settings() + except TimeoutError, ConnectionError: + return None + if settings.settings is None: + return None + return settings.settings.preference_display_name + finally: + await client.stop() + + +class HarborCoordinator(DataUpdateCoordinator[HarborDeviceState]): + """Own the MQTT transport and state for a single Harbor camera.""" + + config_entry: HarborConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: HarborConfigEntry, + config: HarborCameraConfig, + ) -> None: + """Initialize the Harbor coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{config.serial}", + ) + self._config = config + self.device = HarborCamera(config) + self.data = self.device.state + self.connected = False + self._ssl_context_cache: dict[str, Any] = {} + self._mqtt_client: HarborMQTTClient | None = None + self._connected_event = asyncio.Event() + self._data_event = asyncio.Event() + self._unsubscribe_updates = self.device.subscribe_updates( + self._handle_device_update + ) + + async def async_start(self) -> None: + """Start the Harbor MQTT client.""" + hass_instance_id = await instance_id.async_get(self.hass) + client_id = ( + f"{DOMAIN}-{hass_instance_id[:8]}-" + f"{self.config_entry.entry_id[:8]}-{self._config.serial}" + ) + self._mqtt_client = HarborMQTTClient( + config=self._config, + topics=self.device.get_topics(), + message_handler=self.device.handle_message, + client_id=client_id, + ssl_context_cache=self._ssl_context_cache, + on_connection_change=self._async_set_connected, + # Fetch the full settings snapshot on every (re)connection so the + # device name and settings-derived state populate immediately + # instead of waiting for the next heartbeat. + initial_commands=DEFAULT_INITIAL_COMMANDS, + ) + await self._mqtt_client.start() + + async def async_wait_until_ready(self) -> None: + """Wait for the first MQTT connection and the first device data. + + Registering entities only once the camera's first message has + arrived means the device registry sees the real name and firmware + from the start, instead of a placeholder that would otherwise + persist until the next reload. + + Raises ``TimeoutError`` if the camera does not connect and report + data in time. + """ + async with asyncio.timeout(CONNECT_TIMEOUT): + await self._connected_event.wait() + await self._data_event.wait() + + @override + async def async_shutdown(self) -> None: + """Stop the MQTT client and release device resources.""" + await super().async_shutdown() + if self._mqtt_client is not None: + await self._mqtt_client.stop() + self._mqtt_client = None + self._unsubscribe_updates() + self.device.shutdown() + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the Harbor camera.""" + state = self.data + return DeviceInfo( + identifiers={(DOMAIN, state.serial)}, + manufacturer=MANUFACTURER, + model=MODEL, + name=state.display_name or f"{MODEL} {state.serial}", + serial_number=state.serial, + sw_version=state.os_version, + ) + + def _handle_device_update(self, state: HarborDeviceState) -> None: + """Mirror a library device update into Home Assistant.""" + self._data_event.set() + self.async_set_updated_data(state) + + async def _async_set_connected(self, connected: bool) -> None: + """Propagate the MQTT connection state to entity availability.""" + if connected: + self._connected_event.set() + if self.connected == connected: + return + self.connected = connected + self.async_update_listeners() diff --git a/homeassistant/components/harbor/entity.py b/homeassistant/components/harbor/entity.py new file mode 100644 index 000000000000..b04a3b3269bf --- /dev/null +++ b/homeassistant/components/harbor/entity.py @@ -0,0 +1,37 @@ +"""Base entities for Harbor.""" + +from typing import override + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import HarborCoordinator + + +class HarborEntity(CoordinatorEntity[HarborCoordinator]): + """Base Harbor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HarborCoordinator, + unique_key: str, + ) -> None: + """Initialize the Harbor entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.serial}_{unique_key}" + + @override + @property + def available(self) -> bool: + """Return if the entity is currently available.""" + if not self.coordinator.connected: + return False + return self.coordinator.data.last_seen is not None + + @override + @property + def device_info(self) -> DeviceInfo: + """Return the device info for the backing Harbor device.""" + return self.coordinator.device_info diff --git a/homeassistant/components/harbor/icons.json b/homeassistant/components/harbor/icons.json new file mode 100644 index 000000000000..50c18c3b3a7e --- /dev/null +++ b/homeassistant/components/harbor/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "num_viewers": { + "default": "mdi:account-eye" + }, + "stream_quality": { + "default": "mdi:signal" + }, + "wifi_strength": { + "default": "mdi:wifi" + } + } + } +} diff --git a/homeassistant/components/harbor/manifest.json b/homeassistant/components/harbor/manifest.json new file mode 100644 index 000000000000..a9f927b12828 --- /dev/null +++ b/homeassistant/components/harbor/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "harbor", + "name": "Harbor Sleep", + "codeowners": ["@Lash-L", "@afgarcia86"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/harbor", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["harbor"], + "quality_scale": "bronze", + "requirements": ["harbor-python==1.2.1"] +} diff --git a/homeassistant/components/harbor/quality_scale.yaml b/homeassistant/components/harbor/quality_scale.yaml new file mode 100644 index 000000000000..9fb9660e4441 --- /dev/null +++ b/homeassistant/components/harbor/quality_scale.yaml @@ -0,0 +1,73 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: This integration is push-based via MQTT and does not poll. + 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-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: Entities receive updates via the coordinator and do not subscribe to events directly. + 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: 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/harbor/sensor.py b/homeassistant/components/harbor/sensor.py new file mode 100644 index 000000000000..ee1d03260895 --- /dev/null +++ b/homeassistant/components/harbor/sensor.py @@ -0,0 +1,97 @@ +"""Sensor entities for Harbor.""" + +from typing import override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import HarborConfigEntry, HarborCoordinator +from .entity import HarborEntity + +PARALLEL_UPDATES = 0 + +CAMERA_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="num_viewers", + translation_key="num_viewers", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="bitrate", + translation_key="bitrate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="wifi_strength", + translation_key="wifi_strength", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="stream_quality", + translation_key="stream_quality", + device_class=SensorDeviceClass.ENUM, + options=["excellent", "fair", "good", "poor"], + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HarborConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Harbor sensors from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + HarborSensor(coordinator, description) for description in CAMERA_SENSORS + ) + + +class HarborSensor(HarborEntity, SensorEntity): + """A Harbor sensor entity.""" + + def __init__( + self, + coordinator: HarborCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the Harbor sensor.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @override + @property + def native_value(self) -> StateType: + """Return the current sensor value.""" + value = self.coordinator.data.values.get(self.entity_description.key) + if ( + self.entity_description.device_class == SensorDeviceClass.ENUM + and value == "unknown" + ): + # The library falls back to the literal string "unknown" for any + # enum value it doesn't recognize; surface that as no value + # rather than a bogus member of the options list. + return None + return value diff --git a/homeassistant/components/harbor/strings.json b/homeassistant/components/harbor/strings.json new file mode 100644 index 000000000000..1d4c0bae7c10 --- /dev/null +++ b/homeassistant/components/harbor/strings.json @@ -0,0 +1,59 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_cert": "The client certificate must be a valid PEM certificate", + "invalid_key": "The private key must be a valid PEM private key", + "invalid_serial": "The serial number must be exactly 10 digits" + }, + "step": { + "user": { + "data": { + "cert_pem": "Client certificate", + "ip_address": "[%key:common::config_flow::data::ip%]", + "key_pem": "Private key", + "serial": "Serial number" + }, + "data_description": { + "cert_pem": "Paste the client certificate from the Harbor app.", + "ip_address": "The local IP address of the Harbor device.", + "key_pem": "Paste the private key that matches the client certificate.", + "serial": "The 10-digit serial number printed on the Harbor device." + }, + "title": "Set up Harbor" + } + } + }, + "entity": { + "sensor": { + "bitrate": { + "name": "Bitrate" + }, + "num_viewers": { + "name": "Viewers", + "unit_of_measurement": "viewers" + }, + "stream_quality": { + "name": "Stream quality", + "state": { + "excellent": "Excellent", + "fair": "Fair", + "good": "Good", + "poor": "Poor" + } + }, + "wifi_strength": { + "name": "Wi-Fi strength", + "unit_of_measurement": "bars" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to the Harbor camera. It may be offline or unreachable." + } + } +} diff --git a/homeassistant/components/harman_luxury/__init__.py b/homeassistant/components/harman_luxury/__init__.py new file mode 100644 index 000000000000..c8e4c9ae8d47 --- /dev/null +++ b/homeassistant/components/harman_luxury/__init__.py @@ -0,0 +1,31 @@ +"""The Harman Luxury Audio integration.""" + +from aioharmanluxury import HarmanLuxuryClient + +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import HarmanLuxuryConfigEntry, HarmanLuxuryCoordinator + +_PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry( + hass: HomeAssistant, entry: HarmanLuxuryConfigEntry +) -> bool: + """Set up Harman Luxury from a config entry.""" + client = HarmanLuxuryClient(entry.data[CONF_HOST], async_get_clientsession(hass)) + coordinator = HarmanLuxuryCoordinator(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: HarmanLuxuryConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/harman_luxury/config_flow.py b/homeassistant/components/harman_luxury/config_flow.py new file mode 100644 index 000000000000..9008d164257d --- /dev/null +++ b/homeassistant/components/harman_luxury/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for the Harman Luxury integration.""" + +from typing import Any, override +from urllib.parse import urlparse + +from aioharmanluxury import DeviceInfo, HarmanLuxuryClient, HarmanLuxuryError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class HarmanLuxuryConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Harman Luxury.""" + + _host: str + _name: str + + async def _async_get_info(self, host: str) -> DeviceInfo | None: + """Return the device info, or ``None`` if it has no usable identity.""" + client = HarmanLuxuryClient(host, async_get_clientsession(self.hass)) + try: + info = await client.async_get_info() + except HarmanLuxuryError: + return None + if not info.serial: + return None + return info + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + info = await self._async_get_info(user_input[CONF_HOST]) + if info is None: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(info.serial) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info.name, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + @override + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initiated by SSDP discovery.""" + host = urlparse(discovery_info.ssdp_location or "").hostname + serial = discovery_info.upnp.get(ATTR_UPNP_SERIAL) + if not host or not serial: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + info = await self._async_get_info(host) + # The unique ID is the advertised serial; refuse a device whose API + # reports a different one, so setup cannot later fail on the mismatch. + if info is None or info.serial != serial: + return self.async_abort(reason="cannot_connect") + + self._host = host + self._name = info.name + self.context["title_placeholders"] = {"name": info.name} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm setup of a discovered device.""" + if user_input is not None: + return self.async_create_entry( + title=self._name, data={CONF_HOST: self._host} + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={"name": self._name}, + ) diff --git a/homeassistant/components/harman_luxury/const.py b/homeassistant/components/harman_luxury/const.py new file mode 100644 index 000000000000..6c8d79b7b6fe --- /dev/null +++ b/homeassistant/components/harman_luxury/const.py @@ -0,0 +1,3 @@ +"""Constants for the Harman Luxury integration.""" + +DOMAIN = "harman_luxury" diff --git a/homeassistant/components/harman_luxury/coordinator.py b/homeassistant/components/harman_luxury/coordinator.py new file mode 100644 index 000000000000..c54d0adeef2c --- /dev/null +++ b/homeassistant/components/harman_luxury/coordinator.py @@ -0,0 +1,75 @@ +"""Data update coordinator for Harman Luxury.""" + +from datetime import datetime, timedelta +import logging +from typing import override + +from aioharmanluxury import ( + DeviceInfo, + HarmanLuxuryClient, + HarmanLuxuryError, + HarmanLuxuryState, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type HarmanLuxuryConfigEntry = ConfigEntry[HarmanLuxuryCoordinator] + +_SCAN_INTERVAL = timedelta(seconds=10) + + +class HarmanLuxuryCoordinator(DataUpdateCoordinator[HarmanLuxuryState]): + """Poll a Harman Luxury device for its live player state.""" + + config_entry: HarmanLuxuryConfigEntry + device_info: DeviceInfo + position_updated_at: datetime | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: HarmanLuxuryConfigEntry, + client: HarmanLuxuryClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=_SCAN_INTERVAL, + ) + self.client = client + + @override + async def _async_setup(self) -> None: + """Fetch static device identity once.""" + try: + self.device_info = await self.client.async_get_info() + except HarmanLuxuryError as err: + raise UpdateFailed(str(err)) from err + if self.device_info.serial != self.config_entry.unique_id: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="unexpected_device", + ) + + @override + async def _async_update_data(self) -> HarmanLuxuryState: + """Fetch the latest player state.""" + try: + state = await self.client.async_get_state() + except HarmanLuxuryError as err: + raise UpdateFailed(str(err)) from err + self.position_updated_at = ( + dt_util.utcnow() if state.position is not None else None + ) + return state diff --git a/homeassistant/components/harman_luxury/manifest.json b/homeassistant/components/harman_luxury/manifest.json new file mode 100644 index 000000000000..f935743154c9 --- /dev/null +++ b/homeassistant/components/harman_luxury/manifest.json @@ -0,0 +1,21 @@ +{ + "domain": "harman_luxury", + "name": "Harman Luxury Audio", + "codeowners": ["@sbesh91"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/harman_luxury", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["aioharmanluxury==0.2.3"], + "ssdp": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Harman Luxury Audio" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Harman Luxury Audio" + } + ] +} diff --git a/homeassistant/components/harman_luxury/media_player.py b/homeassistant/components/harman_luxury/media_player.py new file mode 100644 index 000000000000..322a3b4bdddf --- /dev/null +++ b/homeassistant/components/harman_luxury/media_player.py @@ -0,0 +1,209 @@ +"""Media player platform for Harman Luxury.""" + +from collections.abc import Coroutine +from datetime import datetime +from typing import Any, override + +from aioharmanluxury import HarmanLuxuryClient, HarmanLuxuryError + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HarmanLuxuryConfigEntry, HarmanLuxuryCoordinator + +# The device serializes control on a single session; serialize at HA's layer. +PARALLEL_UPDATES = 1 + +# The device exposes volume on a 0..99 scale. +_VOLUME_MAX = 99 + +_PLAY_STATE_MAP = { + "playing": MediaPlayerState.PLAYING, + "paused": MediaPlayerState.PAUSED, + "stopped": MediaPlayerState.IDLE, + "buffering": MediaPlayerState.BUFFERING, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HarmanLuxuryConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the media player from a config entry.""" + async_add_entities([HarmanLuxuryMediaPlayer(entry.runtime_data)]) + + +class HarmanLuxuryMediaPlayer( + CoordinatorEntity[HarmanLuxuryCoordinator], MediaPlayerEntity +): + """Representation of a Harman Luxury streamer.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_volume_step = 1 / _VOLUME_MAX + + _BASE_FEATURES = ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + ) + + def __init__(self, coordinator: HarmanLuxuryCoordinator) -> None: + """Initialize the media player.""" + super().__init__(coordinator) + info = coordinator.device_info + self._attr_unique_id = info.serial + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, info.serial)}, + connections={(CONNECTION_NETWORK_MAC, info.mac)} if info.mac else set(), + manufacturer="Harman Luxury Audio", + model=info.model, + name=info.name, + ) + + @property + def _client(self) -> HarmanLuxuryClient: + """Return the device client.""" + return self.coordinator.client + + @property + @override + def state(self) -> MediaPlayerState: + """Return the state of the device.""" + data = self.coordinator.data + if not data.online: + return MediaPlayerState.OFF + return _PLAY_STATE_MAP.get(data.play_state, MediaPlayerState.ON) + + @property + @override + def supported_features(self) -> MediaPlayerEntityFeature: + """Return the supported features.""" + features = self._BASE_FEATURES + data = self.coordinator.data + if data.can_play: + features |= MediaPlayerEntityFeature.PLAY + if data.can_pause: + features |= MediaPlayerEntityFeature.PAUSE + if data.can_stop: + features |= MediaPlayerEntityFeature.STOP + if data.can_next: + features |= MediaPlayerEntityFeature.NEXT_TRACK + if data.can_previous: + features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + return features + + @property + @override + def volume_level(self) -> float: + """Return the volume level (0..1).""" + return self.coordinator.data.volume / _VOLUME_MAX + + @property + @override + def is_volume_muted(self) -> bool: + """Return whether the output is muted.""" + return self.coordinator.data.muted + + @property + @override + def media_title(self) -> str | None: + """Return the title of the current media.""" + return self.coordinator.data.title + + @property + @override + def media_artist(self) -> str | None: + """Return the artist of the current media.""" + return self.coordinator.data.artist + + @property + @override + def media_album_name(self) -> str | None: + """Return the album of the current media.""" + return self.coordinator.data.album + + @property + @override + def media_image_url(self) -> str | None: + """Return the album art URL.""" + return self.coordinator.data.art_url + + @property + @override + def media_duration(self) -> int | None: + """Return the duration of the current media, in seconds.""" + duration = self.coordinator.data.duration + return int(duration) if duration is not None else None + + @property + @override + def media_position(self) -> int | None: + """Return the position of the current media, in seconds.""" + position = self.coordinator.data.position + return int(position) if position is not None else None + + @property + @override + def media_position_updated_at(self) -> datetime | None: + """Return when the media position was last retrieved.""" + return self.coordinator.position_updated_at + + async def _async_send(self, coro: Coroutine[Any, Any, None]) -> None: + """Run a client command, translating failures and refreshing state.""" + try: + await coro + except HarmanLuxuryError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="command_failed" + ) from err + await self.coordinator.async_request_refresh() + + @override + async def async_set_volume_level(self, volume: float) -> None: + """Set the volume level.""" + await self._async_send( + self._client.async_set_volume(round(volume * _VOLUME_MAX)) + ) + + @override + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute the output.""" + await self._async_send(self._client.async_set_mute(mute)) + + @override + async def async_media_play(self) -> None: + """Resume playback.""" + await self._async_send(self._client.async_control("play")) + + @override + async def async_media_pause(self) -> None: + """Pause playback.""" + await self._async_send(self._client.async_control("pause")) + + @override + async def async_media_stop(self) -> None: + """Stop playback.""" + await self._async_send(self._client.async_control("stop")) + + @override + async def async_media_next_track(self) -> None: + """Skip to the next track.""" + await self._async_send(self._client.async_control("next")) + + @override + async def async_media_previous_track(self) -> None: + """Skip to the previous track.""" + await self._async_send(self._client.async_control("previous")) diff --git a/homeassistant/components/harman_luxury/quality_scale.yaml b/homeassistant/components/harman_luxury/quality_scale.yaml new file mode 100644 index 000000000000..e0c52e495ea4 --- /dev/null +++ b/homeassistant/components/harman_luxury/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register any 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: This integration does not register any custom service actions. + docs-conditions: + status: exempt + comment: This integration does not register any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not register any triggers. + 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: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The device API is unauthenticated; there are no credentials to refresh. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + 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: A config entry maps to a single device; there are no dynamic sub-devices. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The single media player entity is the primary entity and stays enabled. + entity-translations: + status: exempt + comment: The media player uses the device name via has-entity-name. + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: There are no repairable conditions surfaced by the device. + stale-devices: + status: exempt + comment: A config entry maps to a single device; removal is via entry deletion. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/harman_luxury/strings.json b/homeassistant/components/harman_luxury/strings.json new file mode 100644 index 000000000000..1cf7c1f6a672 --- /dev/null +++ b/homeassistant/components/harman_luxury/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "flow_title": "{name}", + "step": { + "discovery_confirm": { + "description": "Do you want to set up {name}?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Harman Luxury device." + } + } + } + }, + "exceptions": { + "command_failed": { + "message": "Failed to send the command to the device." + }, + "unexpected_device": { + "message": "The device at this address reports a different serial number than the configured device." + } + } +} diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index b38caf53c336..231e7be94867 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -60,6 +60,8 @@ from .const import ( DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + ISSUE_MOUNT_MOUNT_FAILED, + JOBS_COORDINATOR, MAIN_COORDINATOR, STATS_COORDINATOR, ) @@ -67,6 +69,10 @@ from .coordinator import ( HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, HassioStatsDataUpdateCoordinator, + IssueSubscription, + IssueSubscriptionEvent, + SupervisorIssuesCoordinator, + SupervisorJobsCoordinator, get_addons_info, get_addons_list, get_addons_stats, @@ -85,7 +91,6 @@ from .exceptions import HassioNotReadyError from .handler import HassIO, async_update_diagnostics, get_supervisor_client from .http import HassIOView from .ingress import async_setup_ingress_view -from .issues import SupervisorIssues from .services import async_setup_services from .websocket_api import async_load_websocket_api @@ -326,9 +331,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[MAIN_COORDINATOR] = coordinator - addon_coordinator = HassioAddOnDataUpdateCoordinator( - hass, entry, dev_reg, coordinator.jobs - ) + jobs_coordinator = SupervisorJobsCoordinator(hass, entry) + await jobs_coordinator.async_config_entry_first_refresh() + hass.data[JOBS_COORDINATOR] = jobs_coordinator + + addon_coordinator = HassioAddOnDataUpdateCoordinator(hass, entry, dev_reg) await addon_coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = addon_coordinator @@ -336,18 +343,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await stats_coordinator.async_config_entry_first_refresh() hass.data[STATS_COORDINATOR] = stats_coordinator - # All coordinators refreshed successfully. Start the issues listener and - # install the stop handler now so they are never left in a partial state - # if a coordinator refresh raises ConfigEntryNotReady. - hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass) + issues_coordinator = SupervisorIssuesCoordinator(hass, entry) + hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues_coordinator - def _unload_supervisor_issues() -> None: - if ( - supervisor_issues := hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None) - ) is not None: - supervisor_issues.unload() + @callback + def _refresh_main_coordinator_on_mount_issue(_: IssueSubscriptionEvent) -> None: + coordinator.config_entry.async_create_task(hass, coordinator.async_refresh()) - entry.async_on_unload(_unload_supervisor_issues) + entry.async_on_unload( + issues_coordinator.subscribe( + IssueSubscription( + event_callback=_refresh_main_coordinator_on_mount_issue, + key=ISSUE_MOUNT_MOUNT_FAILED, + ) + ) + ) async def _async_stop(hass: HomeAssistant, restart: bool) -> None: """Stop or restart home assistant.""" @@ -403,9 +413,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Failed to update Home Assistant options in Supervisor: %s", err ) - # Push initial config to Supervisor and start issues listener + # Push initial config to Supervisor and refresh issues state await asyncio.gather( - update_hass_api(refresh_token), push_config(None), issues.setup() + update_hass_api(refresh_token), + push_config(None), + issues_coordinator.async_refresh(), ) # Setup hardware integration for the detected board type @@ -437,5 +449,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(MAIN_COORDINATOR, None) hass.data.pop(ADDONS_COORDINATOR, None) hass.data.pop(STATS_COORDINATOR, None) + hass.data.pop(JOBS_COORDINATOR, None) + hass.data.pop(DATA_KEY_SUPERVISOR_ISSUES, None) return unload_ok diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index c800e03b0a8c..6b8dcba7cf99 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -48,14 +48,13 @@ from homeassistant.components.backup import ( RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, - suggested_filename as suggested_backup_filename, suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT @@ -68,6 +67,16 @@ TAG_ADDON_UPDATE = "supervisor.addon_update" _LOGGER = logging.getLogger(__name__) +def _suggested_backup_filename(name: str, date: str) -> str: + """Suggest a filename for a Supervisor backup. + + Slugify the name so a display name with path separators (e.g. an add-on + named "Nabu Casa / Webhook Proxy") can't produce a filename Supervisor + rejects. The unsanitized name is still stored as the backup's display name. + """ + return suggested_filename_from_name_date(slugify(name), date) + + async def async_get_backup_agents( hass: HomeAssistant, **kwargs: Any, @@ -202,7 +211,7 @@ class SupervisorBackupAgent(BackupAgent): stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( location={self.location}, - filename=PurePath(suggested_backup_filename(backup)), + filename=PurePath(_suggested_backup_filename(backup.name, backup.date)), ) async def stream_with_progress() -> AsyncIterator[bytes]: @@ -361,7 +370,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): date = dt_util.now().isoformat() extra_metadata = extra_metadata | {"supervisor.backup_request_date": date} - filename = suggested_filename_from_name_date(backup_name, date) + filename = _suggested_backup_filename(backup_name, date) try: backup = await self._client.backups.partial_backup( supervisor_backups.PartialBackupOptions( diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 418f956c46c2..a33ab7fb1115 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -27,9 +27,10 @@ if TYPE_CHECKING: HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, HassioStatsDataUpdateCoordinator, + SupervisorIssuesCoordinator, + SupervisorJobsCoordinator, ) from .handler import HassIO - from .issues import SupervisorIssues DOMAIN = "hassio" @@ -103,6 +104,9 @@ ADDONS_COORDINATOR: HassKey[HassioAddOnDataUpdateCoordinator] = HassKey( STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey( "hassio_stats_coordinator" ) +JOBS_COORDINATOR: HassKey[SupervisorJobsCoordinator] = HassKey( + "hassio_jobs_coordinator" +) DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN) @@ -126,6 +130,8 @@ DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list") HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5) HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15) HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60) +HASSIO_ISSUES_UPDATE_INTERVAL = timedelta(minutes=30) +SUPERVISOR_JOBS_UPDATE_INTERVAL = timedelta(minutes=15) ATTR_AUTO_UPDATE = "auto_update" ATTR_VERSION = "version" @@ -143,7 +149,9 @@ DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" DATA_KEY_HOST = "host" -DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssues] = HassKey("supervisor_issues") +DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssuesCoordinator] = HassKey( + "supervisor_issues" +) DATA_KEY_MOUNTS = "mounts" DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host") DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") @@ -155,6 +163,7 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url" PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" PLACEHOLDER_KEY_FREE_SPACE = "free_space" +PLACEHOLDER_KEY_REASON = "reason" ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index b08cd67939eb..0dd9e2da0187 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -2,10 +2,11 @@ import asyncio from collections import defaultdict -from collections.abc import Awaitable -from dataclasses import dataclass +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace import logging -from typing import TYPE_CHECKING, Any, cast, override +from typing import Any, Literal, cast, override +from uuid import UUID from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import ( @@ -17,6 +18,8 @@ from aiohasupervisor.models import ( HostInfo, InstalledAddon, InstalledAddonComplete, + Issue as SupervisorIssue, + Job, NetworkInfo, NFSMountResponse, OSInfo, @@ -25,22 +28,39 @@ from aiohasupervisor.models import ( StoreInfo, SupervisorInfo, SupervisorStats, + UnhealthyReason, + UnsupportedReason, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MANUFACTURER -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + callback, + is_callback_check_partial, +) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_ADDONS, ATTR_DATA, + ATTR_HEALTHY, ATTR_REPOSITORIES, + ATTR_SLUG, ATTR_STARTUP, + ATTR_SUPPORTED, + ATTR_UNHEALTHY_REASONS, + ATTR_UNSUPPORTED_REASONS, ATTR_UPDATE_KEY, ATTR_WS_EVENT, CONTAINER_STATS, @@ -59,26 +79,667 @@ from .const import ( DATA_SUPERVISOR_INFO, DATA_SUPERVISOR_STATS, DOMAIN, + EVENT_HEALTH_CHANGED, + EVENT_ISSUE_CHANGED, + EVENT_ISSUE_REMOVED, + EVENT_JOB, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, + EVENT_SUPPORTED_CHANGED, + EXTRA_PLACEHOLDERS, HASSIO_ADDON_UPDATE_INTERVAL, + HASSIO_ISSUES_UPDATE_INTERVAL, HASSIO_MAIN_UPDATE_INTERVAL, HASSIO_STATS_UPDATE_INTERVAL, + ISSUE_KEY_ADDON_BOOT_FAIL, + ISSUE_KEY_ADDON_DEPRECATED_ARCH, + 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, + ISSUE_MOUNT_MOUNT_FAILED, + PLACEHOLDER_KEY_ADDON, + PLACEHOLDER_KEY_ADDON_URL, + PLACEHOLDER_KEY_FREE_SPACE, + PLACEHOLDER_KEY_REASON, + PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, STARTUP_COMPLETE, SUPERVISOR_CONTAINER, + SUPERVISOR_JOBS_UPDATE_INTERVAL, UPDATE_KEY_SUPERVISOR, SupervisorEntityModel, ) from .exceptions import HassioNotReadyError from .handler import get_supervisor_client -from .jobs import SupervisorJobs - -if TYPE_CHECKING: - from .issues import SupervisorIssues +from .issues import Issue, IssueDataType, Suggestion _LOGGER = logging.getLogger(__name__) +ISSUE_KEY_UNHEALTHY = "unhealthy" +ISSUE_KEY_UNSUPPORTED = "unsupported" +ISSUE_ID_UNHEALTHY = "unhealthy_system" +ISSUE_ID_UNSUPPORTED = "unsupported_system" + +INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" +INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" + +# 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"} + +# Keys (type + context) of issues that when found should be made into a repair. +ISSUE_KEYS_FOR_REPAIRS = { + ISSUE_KEY_ADDON_BOOT_FAIL, + ISSUE_MOUNT_MOUNT_FAILED, + "issue_system_multiple_data_disks", + "issue_system_reboot_required", + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + 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, + ISSUE_KEY_ADDON_DEPRECATED_ARCH, + "issue_system_ntp_sync_failed", +} + + +@dataclass(slots=True, frozen=True) +class IssueSubscription: + """Subscribe for updates on supervisor issues matching a key.""" + + event_callback: Callable[[IssueSubscriptionEvent], None] + key: str + + def __post_init__(self) -> None: + """Validate inputs.""" + if not self.key: + raise ValueError("A key must be provided!") + if not is_callback_check_partial(self.event_callback): + raise ValueError("event_callback must be a homeassistant.core.callback!") + + def matches(self, issue: Issue) -> bool: + """Return true if issue matches this subscription.""" + return issue.key == self.key + + +@dataclass(slots=True, frozen=True) +class IssueSubscriptionEvent: + """Issue subscription event.""" + + event: Literal["changed", "removed"] + issue: Issue + + +@dataclass(slots=True, frozen=True) +class SupervisorIssuesData: + """Data class for supervisor issues.""" + + unhealthy_reasons: set[str] + unsupported_reasons: set[str] + issues: dict[UUID, Issue] + + +class SupervisorIssuesCoordinator(DataUpdateCoordinator[SupervisorIssuesData]): + """Manage supervisor issues state and repair synchronization.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize supervisor issues coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="SupervisorIssuesCoordinator", + update_interval=HASSIO_ISSUES_UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self._supervisor_client = get_supervisor_client(hass) + self._subscriptions: set[IssueSubscription] = set() + self._dispatcher_disconnect: Callable[[], None] | None = ( + async_dispatcher_connect( + self.hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues + ) + ) + # Keep polling active even if initial refresh fails so coordinator can recover. + self.async_add_listener(lambda: None) + + @property + def unhealthy_reasons(self) -> set[str]: + """Get unhealthy reasons. Returns empty set if system is healthy.""" + return self.data.unhealthy_reasons if self.data is not None else set() + + @property + def unsupported_reasons(self) -> set[str]: + """Get unsupported reasons. Returns empty set if system is supported.""" + return self.data.unsupported_reasons if self.data is not None else set() + + @property + def issues(self) -> set[Issue]: + """Get issues.""" + if self.data is None: + return set() + return set(self.data.issues.values()) + + def get_issue(self, issue_id: str) -> Issue | None: + """Get issue from key.""" + if self.data is None: + return None + return self.data.issues.get(UUID(issue_id)) + + def subscribe(self, subscription: IssueSubscription) -> CALLBACK_TYPE: + """Subscribe to updates for issue key. Callback is used to unsubscribe.""" + self._subscriptions.add(subscription) + + for match in [issue for issue in self.issues if subscription.matches(issue)]: + self._notify_issue_subscription_event( + subscription, IssueSubscriptionEvent(event="changed", issue=match) + ) + + def _unsubscribe() -> None: + self._subscriptions.discard(subscription) + + return _unsubscribe + + def _process_issue_change(self, event: IssueSubscriptionEvent) -> None: + """Process an issue change by triggering callbacks on subscribers.""" + for sub in self._subscriptions: + if sub.matches(event.issue): + self._notify_issue_subscription_event(sub, event) + + def _notify_issue_subscription_event( + self, subscription: IssueSubscription, event: IssueSubscriptionEvent + ) -> None: + """Run a subscription callback and log callback failures.""" + try: + subscription.event_callback(event) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error encountered processing Supervisor issue (%s %s %s) - %s", + event.issue.key, + event.issue.reference, + event.issue.uuid, + err, + ) + + @staticmethod + def _issue_equal(previous_issue: Issue, issue: Issue) -> bool: + """Return true if issues are equal including suggestions.""" + return ( + previous_issue == issue and previous_issue.suggestions == issue.suggestions + ) + + def _process_reason_deltas( + self, + previous_data: SupervisorIssuesData, + current_data: SupervisorIssuesData, + ) -> None: + """Create/delete unhealthy and unsupported repairs based on reason deltas.""" + for unhealthy in ( + current_data.unhealthy_reasons - previous_data.unhealthy_reasons + ): + if unhealthy in UnhealthyReason: + translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" + translation_placeholders = None + else: + translation_key = ISSUE_KEY_UNHEALTHY + translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy} + + async_create_issue( + self.hass, + DOMAIN, + f"{ISSUE_ID_UNHEALTHY}_{unhealthy}", + is_fixable=False, + learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}", + severity=IssueSeverity.CRITICAL, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + for fixed in previous_data.unhealthy_reasons - current_data.unhealthy_reasons: + async_delete_issue(self.hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}") + + for unsupported in ( + current_data.unsupported_reasons + - UNSUPPORTED_SKIP_REPAIR + - previous_data.unsupported_reasons + ): + if unsupported in UnsupportedReason: + translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" + translation_placeholders = None + else: + translation_key = ISSUE_KEY_UNSUPPORTED + translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported} + + async_create_issue( + self.hass, + DOMAIN, + f"{ISSUE_ID_UNSUPPORTED}_{unsupported}", + is_fixable=False, + learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}", + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + for fixed in previous_data.unsupported_reasons - ( + current_data.unsupported_reasons - UNSUPPORTED_SKIP_REPAIR + ): + async_delete_issue(self.hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}") + + def _create_or_update_issue_repair(self, issue: Issue) -> None: + """Create/update a repair for an issue if needed.""" + if issue.key not in ISSUE_KEYS_FOR_REPAIRS: + return + + if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS: + placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy() + else: + placeholders = {} + + 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}" + ) + addons_list = get_addons_list(self.hass) or [] + placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference + for addon in addons_list: + if addon[ATTR_SLUG] == issue.reference: + placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME] + break + + elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE: + host_info = get_host_info(self.hass) + if host_info and "disk_free" in host_info: + placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(host_info["disk_free"]) + else: + placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2" + + async_create_issue( + self.hass, + DOMAIN, + issue.uuid.hex, + is_fixable=bool(issue.suggestions), + severity=IssueSeverity.WARNING, + translation_key=issue.key, + translation_placeholders=placeholders or None, + ) + + def _delete_issue_repair(self, issue: Issue) -> None: + """Delete repair for issue if it maps to a repair.""" + if issue.key in ISSUE_KEYS_FOR_REPAIRS: + async_delete_issue(self.hass, DOMAIN, issue.uuid.hex) + + def _process_issue_deltas( + self, + previous_data: SupervisorIssuesData, + current_data: SupervisorIssuesData, + ) -> None: + """Create/delete issue repairs and notify subscribers based on issue deltas.""" + for issue in current_data.issues.values(): + previous_issue = previous_data.issues.get(issue.uuid) + if previous_issue is not None and self._issue_equal(previous_issue, issue): + continue + + self._create_or_update_issue_repair(issue) + self._process_issue_change( + IssueSubscriptionEvent(event="changed", issue=issue) + ) + + for issue_uuid, issue in previous_data.issues.items(): + if issue_uuid not in current_data.issues: + self._delete_issue_repair(issue) + self._process_issue_change( + IssueSubscriptionEvent(event="removed", issue=issue) + ) + + @override + async def _async_update_data(self) -> SupervisorIssuesData: + """Update issues data from Supervisor resolution center.""" + try: + data = await self._supervisor_client.resolution.info() + except SupervisorError as err: + raise UpdateFailed(f"Error on Supervisor API: {err}") from err + + issue_from_data_results = await asyncio.gather( + *(self._issue_from_data(issue) for issue in data.issues) + ) + issues = { + issue_from_data.uuid: issue_from_data + for issue_from_data in issue_from_data_results + if issue_from_data is not None + } + + return SupervisorIssuesData( + unhealthy_reasons={str(reason) for reason in data.unhealthy}, + unsupported_reasons={str(reason) for reason in data.unsupported}, + issues=issues, + ) + + async def _issue_from_data(self, data: SupervisorIssue) -> Issue | None: + """Build an Issue model from Supervisor issue data and fetched suggestions.""" + try: + suggestions = ( + await self._supervisor_client.resolution.suggestions_for_issue( + data.uuid + ) + ) + except SupervisorError: + _LOGGER.error( + "Could not get suggestions for supervisor issue %s, skipping it", + data.uuid.hex, + ) + return None + + return Issue( + uuid=data.uuid, + type=str(data.type), + context=data.context, + reference=data.reference, + suggestions=[ + Suggestion( + uuid=suggestion.uuid, + type=str(suggestion.type), + context=suggestion.context, + reference=suggestion.reference, + ) + for suggestion in suggestions + ], + ) + + @override + 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: + """Refresh issue data and apply repair/subscription deltas.""" + previous_data = self.data or SupervisorIssuesData(set(), set(), {}) + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) + if self.last_update_success and self.data is not None: + self._process_reason_deltas(previous_data, self.data) + self._process_issue_deltas(previous_data, self.data) + + @override + async def async_shutdown(self) -> None: + """Shut down the coordinator.""" + await super().async_shutdown() + if self._dispatcher_disconnect: + self._dispatcher_disconnect() + self._dispatcher_disconnect = None + + @callback + def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: + """Update issues data from supervisor events.""" + if ATTR_WS_EVENT not in event: + return + + if ( + event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE + ): + self.config_entry.async_create_task(self.hass, self.async_refresh()) + return + + previous_data = self.data or SupervisorIssuesData(set(), set(), {}) + + if event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED: + unhealthy_reasons = ( + set() + if event[ATTR_DATA][ATTR_HEALTHY] + else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS]) + ) + updated_data = SupervisorIssuesData( + unhealthy_reasons=unhealthy_reasons, + unsupported_reasons=set(previous_data.unsupported_reasons), + issues=dict(previous_data.issues), + ) + elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED: + unsupported_reasons = ( + set() + if event[ATTR_DATA][ATTR_SUPPORTED] + else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) + ) + updated_data = SupervisorIssuesData( + unhealthy_reasons=set(previous_data.unhealthy_reasons), + unsupported_reasons=unsupported_reasons, + issues=dict(previous_data.issues), + ) + elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED: + issue = Issue.from_dict(cast(IssueDataType, event[ATTR_DATA])) + updated_issues = dict(previous_data.issues) + updated_issues[issue.uuid] = issue + updated_data = SupervisorIssuesData( + unhealthy_reasons=set(previous_data.unhealthy_reasons), + unsupported_reasons=set(previous_data.unsupported_reasons), + issues=updated_issues, + ) + elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED: + issue = Issue.from_dict(cast(IssueDataType, event[ATTR_DATA])) + updated_issues = dict(previous_data.issues) + updated_issues.pop(issue.uuid, None) + updated_data = SupervisorIssuesData( + unhealthy_reasons=set(previous_data.unhealthy_reasons), + unsupported_reasons=set(previous_data.unsupported_reasons), + issues=updated_issues, + ) + else: + return + + self.async_set_updated_data(updated_data) + self._process_reason_deltas(previous_data, updated_data) + self._process_issue_deltas(previous_data, updated_data) + + +@dataclass(slots=True, frozen=True) +class JobSubscription: + """Subscribe for updates on jobs which match filters. + + UUID is preferred match but only available in cases of a background API that + returns the UUID before taking the action. Others are used to match jobs only + if UUID is omitted. Either name or UUID is required to be able to match. + + event_callback must be safe annotated as a homeassistant.core.callback + and safe to call in the event loop. + """ + + event_callback: Callable[[Job], None] + uuid: str | None = None + name: str | None = None + reference: str | None = None + + def __post_init__(self) -> None: + """Validate at least one filter option is present.""" + if not self.name and not self.uuid: + raise ValueError("Either name or uuid must be provided!") + if not is_callback_check_partial(self.event_callback): + raise ValueError("event_callback must be a homeassistant.core.callback!") + + def matches(self, job: Job) -> bool: + """Return true if job matches subscription filters.""" + if self.uuid: + return job.uuid == self.uuid + return job.name == self.name and self.reference in (None, job.reference) + + +class SupervisorJobsCoordinator(DataUpdateCoordinator[dict[UUID, Job]]): + """Manage access to Supervisor jobs.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize object.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="SupervisorJobsCoordinator", + update_interval=SUPERVISOR_JOBS_UPDATE_INTERVAL, + # We don't want an immediate refresh since we want to avoid + # hammering the Supervisor API on startup + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self._supervisor_client = get_supervisor_client(hass) + self._subscriptions: set[JobSubscription] = set() + self._dispatcher_disconnect: Callable[[], None] | None = None + self._noop_listener_disconnect: Callable[[], None] | None = None + + @property + def current_jobs(self) -> list[Job]: + """Return current jobs.""" + return list(self.data.values()) if self.data is not None else [] + + @staticmethod + def _build_jobs(jobs: list[Job]) -> dict[UUID, Job]: + """Flatten jobs and child jobs into a UUID keyed cache.""" + job_queue: list[Job] = jobs.copy() + cached_jobs: dict[UUID, Job] = {} + + while job_queue: + job = job_queue.pop(0) + job_queue.extend(job.child_jobs) + cached_jobs[job.uuid] = replace(job, child_jobs=[]) + + return cached_jobs + + @override + async def _async_update_data(self) -> dict[UUID, Job]: + """Fetch data from Supervisor.""" + job_data = await self._supervisor_client.jobs.info() + return self._build_jobs(job_data.jobs) + + def _process_job_change(self, job: Job) -> None: + """Process a job change by triggering callbacks on subscribers.""" + for sub in self._subscriptions: + if sub.matches(job): + sub.event_callback(job) + + def _process_job_deltas( + self, + previous_jobs: dict[UUID, Job], + current_jobs: dict[UUID, Job], + ) -> None: + """Notify subscribers about changes between two job caches.""" + for job in current_jobs.values(): + if (previous_job := previous_jobs.get(job.uuid)) is not None and ( + previous_job == job + ): + continue + self._process_job_change(job) + + for uuid, job in previous_jobs.items(): + if uuid not in current_jobs and job.done is False: + self._process_job_change(replace(job, done=True)) + + def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE: + """Subscribe to updates for job. Return callback is used to unsubscribe. + + If any jobs match the subscription at the time this is called, runs the + callback on them. + """ + self._subscriptions.add(subscription) + + # Connect a stub listener to start the update interval polling on first subscriber + if self._noop_listener_disconnect is None: + self._noop_listener_disconnect = self.async_add_listener(lambda: None) + + # Run the callback on each existing match + # We catch all errors to prevent an error in one from stopping the others + for match in [job for job in self.current_jobs if subscription.matches(job)]: + try: + subscription.event_callback(match) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error encountered processing Supervisor Job (%s %s %s) - %s", + match.name, + match.reference, + match.uuid, + err, + ) + + def _unsubscribe() -> None: + self._subscriptions.discard(subscription) + + # Stop polling if there are no more subscribers + if not self._subscriptions and self._noop_listener_disconnect is not None: + self._noop_listener_disconnect() + self._noop_listener_disconnect = None + + return _unsubscribe + + @callback + @override + def _async_refresh_finished(self) -> None: + """Register to receive Supervisor events after the first successful refresh.""" + if self.last_update_success and self._dispatcher_disconnect is None: + self._dispatcher_disconnect = async_dispatcher_connect( + self.hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs + ) + + @override + 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: + """Refresh data and notify subscribers about cache changes.""" + previous_jobs = self.data or {} + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) + if self.last_update_success and self.data is not None: + self._process_job_deltas(previous_jobs, self.data) + + @override + async def async_shutdown(self) -> None: + """Shut down the coordinator.""" + await super().async_shutdown() + if self._dispatcher_disconnect: + self._dispatcher_disconnect() + self._dispatcher_disconnect = None + + @callback + def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None: + """Update job data cache from supervisor events.""" + if ATTR_WS_EVENT not in event: + return + + if ( + event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE + and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR + and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE + ): + self.config_entry.async_create_task(self.hass, self.async_request_refresh()) + + elif event[ATTR_WS_EVENT] == EVENT_JOB: + job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []}) + previous_jobs = self.data or {} + updated_jobs = {**previous_jobs, job.uuid: job} + if job.done: + updated_jobs.pop(job.uuid, None) + self.async_set_updated_data(updated_jobs) + self._process_job_change(job) + @dataclass class HassioMainData: @@ -344,7 +1005,7 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any]: @callback -def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: +def get_issues_info(hass: HomeAssistant) -> SupervisorIssuesCoordinator | None: """Return Supervisor issues info. Async friendly. @@ -591,7 +1252,6 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry, - jobs: SupervisorJobs, ) -> None: """Initialize coordinator.""" super().__init__( @@ -610,7 +1270,6 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): self.dev_reg = dev_reg self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set) self.supervisor_client = get_supervisor_client(hass) - self.jobs = jobs @override async def _async_update_data(self) -> HassioAddonData: @@ -752,6 +1411,11 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) + async def async_refresh_after_store_reload(self) -> None: + """Refresh addon data when the store was already reloaded externally.""" + async with self._debounced_refresh.async_lock(): + await super()._async_refresh(log_failures=True) + async def force_addon_info_data_refresh(self, addon_slug: str) -> None: """Force refresh of addon info data for a specific addon.""" try: @@ -800,7 +1464,6 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): self.dev_reg = dev_reg self.is_hass_os = False self.supervisor_client = get_supervisor_client(hass) - self.jobs = SupervisorJobs(hass) self._dispatcher_disconnect = async_dispatcher_connect( hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event ) @@ -854,7 +1517,6 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): ), ) mounts_info = await client.mounts.info() - await self.jobs.refresh_data(is_first_update) except SupervisorError as err: raise UpdateFailed(f"Error on Supervisor API: {err}") from err @@ -951,4 +1613,3 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): """Shut down and clean up when config entry unloaded.""" await super().async_shutdown() self._dispatcher_disconnect() - self.jobs.unload() diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py index a3166d15888d..dc45e57ea2fb 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -2,9 +2,10 @@ from typing import Any -from attr import asdict - -from homeassistant.components.diagnostics import entity_entry_as_dict +from homeassistant.components.diagnostics import ( + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -53,7 +54,7 @@ async def async_get_config_entry_diagnostics( {"entry": entity_entry_as_dict(entity_entry), "state": state_dict} ) - devices.append({"device": asdict(device), "entities": entities}) + devices.append({"device": device_entry_as_dict(device), "entities": entities}) return { "coordinator_data": coordinator.data.to_dict(), diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index b8d14947c0ca..ffdbe4befb29 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -1,100 +1,10 @@ -"""Supervisor events monitor.""" +"""Supervisor issue models.""" -import asyncio -from collections.abc import Callable from dataclasses import dataclass, field -from datetime import datetime -import logging -from typing import Any, NotRequired, TypedDict +from typing import NotRequired, TypedDict from uuid import UUID -from aiohasupervisor import SupervisorError -from aiohasupervisor.models import ( - ContextType, - Issue as SupervisorIssue, - UnhealthyReason, - UnsupportedReason, -) - -from homeassistant.const import ATTR_NAME -from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) - -from .const import ( - ATTR_DATA, - ATTR_HEALTHY, - ATTR_SLUG, - ATTR_STARTUP, - ATTR_SUPPORTED, - ATTR_UNHEALTHY_REASONS, - ATTR_UNSUPPORTED_REASONS, - ATTR_UPDATE_KEY, - ATTR_WS_EVENT, - DOMAIN, - EVENT_HEALTH_CHANGED, - EVENT_ISSUE_CHANGED, - EVENT_ISSUE_REMOVED, - EVENT_SUPERVISOR_EVENT, - EVENT_SUPERVISOR_UPDATE, - EVENT_SUPPORTED_CHANGED, - EXTRA_PLACEHOLDERS, - ISSUE_KEY_ADDON_BOOT_FAIL, - ISSUE_KEY_ADDON_DEPRECATED_ARCH, - 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, - ISSUE_MOUNT_MOUNT_FAILED, - MAIN_COORDINATOR, - PLACEHOLDER_KEY_ADDON, - PLACEHOLDER_KEY_ADDON_URL, - PLACEHOLDER_KEY_FREE_SPACE, - PLACEHOLDER_KEY_REFERENCE, - REQUEST_REFRESH_DELAY, - STARTUP_COMPLETE, - UPDATE_KEY_SUPERVISOR, -) -from .coordinator import HassioMainDataUpdateCoordinator, get_addons_list, get_host_info -from .handler import get_supervisor_client - -ISSUE_KEY_UNHEALTHY = "unhealthy" -ISSUE_KEY_UNSUPPORTED = "unsupported" -ISSUE_ID_UNHEALTHY = "unhealthy_system" -ISSUE_ID_UNSUPPORTED = "unsupported_system" - -INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" -INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" - -PLACEHOLDER_KEY_REASON = "reason" - -# 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"} - -# Keys (type + context) of issues that when found should be made into a repair -ISSUE_KEYS_FOR_REPAIRS = { - ISSUE_KEY_ADDON_BOOT_FAIL, - ISSUE_MOUNT_MOUNT_FAILED, - "issue_system_multiple_data_disks", - "issue_system_reboot_required", - ISSUE_KEY_SYSTEM_DOCKER_CONFIG, - 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, - ISSUE_KEY_ADDON_DEPRECATED_ARCH, - "issue_system_ntp_sync_failed", -} - -_LOGGER = logging.getLogger(__name__) +from aiohasupervisor.models import ContextType class SuggestionDataType(TypedDict): @@ -169,277 +79,3 @@ class Issue: Suggestion.from_dict(suggestion) for suggestion in suggestions ], ) - - -class SupervisorIssues: - """Create issues from supervisor events.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize supervisor issues.""" - self._hass = hass - self._unsupported_reasons: set[str] = set() - self._unhealthy_reasons: set[str] = set() - self._issues: dict[UUID, Issue] = {} - self._supervisor_client = get_supervisor_client(hass) - self._disconnect: Callable[[], None] | None = None - self._cancel_update_retry: Callable[[], None] | None = None - - @property - def unhealthy_reasons(self) -> set[str]: - """Get unhealthy reasons. Returns empty set if system is healthy.""" - return self._unhealthy_reasons - - @unhealthy_reasons.setter - 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 UnhealthyReason: - translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" - translation_placeholders = None - else: - translation_key = ISSUE_KEY_UNHEALTHY - translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy} - - async_create_issue( - self._hass, - DOMAIN, - f"{ISSUE_ID_UNHEALTHY}_{unhealthy}", - is_fixable=False, - learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}", - severity=IssueSeverity.CRITICAL, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - for fixed in self.unhealthy_reasons - reasons: - async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}") - - self._unhealthy_reasons = reasons - - @property - def unsupported_reasons(self) -> set[str]: - """Get unsupported reasons. Returns empty set if system is supported.""" - return self._unsupported_reasons - - @unsupported_reasons.setter - 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 UnsupportedReason: - translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" - translation_placeholders = None - else: - translation_key = ISSUE_KEY_UNSUPPORTED - translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported} - - async_create_issue( - self._hass, - DOMAIN, - f"{ISSUE_ID_UNSUPPORTED}_{unsupported}", - is_fixable=False, - learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}", - severity=IssueSeverity.WARNING, - translation_key=translation_key, - translation_placeholders=translation_placeholders, - ) - - for fixed in self.unsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR): - async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}") - - self._unsupported_reasons = reasons - - @property - def issues(self) -> set[Issue]: - """Get issues.""" - return set(self._issues.values()) - - 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: - if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS: - placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy() - else: - placeholders = {} - - 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}" - ) - addons_list = get_addons_list(self._hass) or [] - placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference - for addon in addons_list: - if addon[ATTR_SLUG] == issue.reference: - placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME] - break - - elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE: - host_info = get_host_info(self._hass) - if host_info and "disk_free" in host_info: - placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str( - host_info["disk_free"] - ) - else: - placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2" - - if issue.key == ISSUE_MOUNT_MOUNT_FAILED: - self._async_coordinator_refresh() - - async_create_issue( - self._hass, - DOMAIN, - issue.uuid.hex, - is_fixable=bool(issue.suggestions), - severity=IssueSeverity.WARNING, - translation_key=issue.key, - translation_placeholders=placeholders or None, - ) - - self._issues[issue.uuid] = issue - - async def add_issue_from_data(self, data: SupervisorIssue) -> None: - """Add issue from data to list after getting latest suggestions.""" - try: - suggestions = ( - await self._supervisor_client.resolution.suggestions_for_issue( - data.uuid - ) - ) - except SupervisorError: - _LOGGER.error( - "Could not get suggestions for supervisor issue %s, skipping it", - data.uuid.hex, - ) - return - self.add_issue( - Issue( - uuid=data.uuid, - type=str(data.type), - context=data.context, - reference=data.reference, - suggestions=[ - Suggestion( - uuid=suggestion.uuid, - type=str(suggestion.type), - context=suggestion.context, - reference=suggestion.reference, - ) - for suggestion in suggestions - ], - ) - ) - - def remove_issue(self, issue: Issue) -> None: - """Remove an issue from the list. Delete a repair if necessary.""" - if issue.uuid not in self._issues: - return - - if issue.key in ISSUE_KEYS_FOR_REPAIRS: - async_delete_issue(self._hass, DOMAIN, issue.uuid.hex) - - if issue.key == ISSUE_MOUNT_MOUNT_FAILED: - self._async_coordinator_refresh() - - del self._issues[issue.uuid] - - def get_issue(self, issue_id: str) -> Issue | None: - """Get issue from key.""" - return self._issues.get(UUID(issue_id)) - - async def setup(self) -> None: - """Create supervisor events listener.""" - await self.async_update() - - self._disconnect = async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues - ) - - def unload(self) -> None: - """Remove supervisor events listener.""" - if self._disconnect is not None: - self._disconnect() - self._disconnect = None - if self._cancel_update_retry is not None: - self._cancel_update_retry() - self._cancel_update_retry = None - - async def async_update(self) -> None: - """Update issues from Supervisor resolution center.""" - if self._cancel_update_retry: - self._cancel_update_retry() - self._cancel_update_retry = None - await self._update() - - async def _update(self, _: datetime | None = None) -> None: - """Update issues from Supervisor resolution center with retry on failure.""" - try: - data = await self._supervisor_client.resolution.info() - except SupervisorError as err: - _LOGGER.error("Failed to update supervisor issues: %r", err) - self._cancel_update_retry = async_call_later( - self._hass, - REQUEST_REFRESH_DELAY, - HassJob(self._update, cancel_on_shutdown=True), - ) - return - self._cancel_update_retry = None - self.unhealthy_reasons = set(data.unhealthy) - self.unsupported_reasons = set(data.unsupported) - - # Remove any cached issues that weren't returned - for issue_id in set(self._issues) - {issue.uuid for issue in data.issues}: - self.remove_issue(self._issues[issue_id]) - - # Add/update any issues that came back - await asyncio.gather( - *[self.add_issue_from_data(issue) for issue in data.issues] - ) - - @callback - def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: - """Create issues from supervisor events.""" - if ATTR_WS_EVENT not in event: - return - - if ( - event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE - and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR - and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE - ): - self._hass.async_create_task(self.async_update()) - - elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED: - self.unhealthy_reasons = ( - set() - if event[ATTR_DATA][ATTR_HEALTHY] - else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS]) - ) - - elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED: - self.unsupported_reasons = ( - set() - if event[ATTR_DATA][ATTR_SUPPORTED] - else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) - ) - - elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED: - self.add_issue(Issue.from_dict(event[ATTR_DATA])) - - elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED: - self.remove_issue(Issue.from_dict(event[ATTR_DATA])) - - def _async_coordinator_refresh(self) -> None: - """Refresh coordinator to update latest data in entities.""" - coordinator: HassioMainDataUpdateCoordinator | None - if coordinator := self._hass.data.get(MAIN_COORDINATOR): - coordinator.config_entry.async_create_task( - self._hass, coordinator.async_refresh() - ) diff --git a/homeassistant/components/hassio/jobs.py b/homeassistant/components/hassio/jobs.py deleted file mode 100644 index a7445b33b5e9..000000000000 --- a/homeassistant/components/hassio/jobs.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Track Supervisor job data and allow subscription to updates.""" - -from collections.abc import Callable -from dataclasses import dataclass, replace -from functools import partial -import logging -from typing import Any -from uuid import UUID - -from aiohasupervisor.models import Job - -from homeassistant.core import ( - CALLBACK_TYPE, - HomeAssistant, - callback, - is_callback_check_partial, -) -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -from .const import ( - ATTR_DATA, - ATTR_STARTUP, - ATTR_UPDATE_KEY, - ATTR_WS_EVENT, - EVENT_JOB, - EVENT_SUPERVISOR_EVENT, - EVENT_SUPERVISOR_UPDATE, - STARTUP_COMPLETE, - UPDATE_KEY_SUPERVISOR, -) -from .handler import get_supervisor_client - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(slots=True, frozen=True) -class JobSubscription: - """Subscribe for updates on jobs which match filters. - - UUID is preferred match but only available in cases of a background API that - returns the UUID before taking the action. Others are used to match jobs only - if UUID is omitted. Either name or UUID is required to be able to match. - - event_callback must be safe annotated as a homeassistant.core.callback - and safe to call in the event loop. - """ - - event_callback: Callable[[Job], Any] - uuid: str | None = None - name: str | None = None - reference: str | None = None - - def __post_init__(self) -> None: - """Validate at least one filter option is present.""" - if not self.name and not self.uuid: - raise ValueError("Either name or uuid must be provided!") - if not is_callback_check_partial(self.event_callback): - raise ValueError("event_callback must be a homeassistant.core.callback!") - - def matches(self, job: Job) -> bool: - """Return true if job matches subscription filters.""" - if self.uuid: - return job.uuid == self.uuid - return job.name == self.name and self.reference in (None, job.reference) - - -class SupervisorJobs: - """Manage access to Supervisor jobs.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize object.""" - self._hass = hass - self._supervisor_client = get_supervisor_client(hass) - self._jobs: dict[UUID, Job] = {} - self._subscriptions: set[JobSubscription] = set() - self._dispatcher_disconnect: Callable[[], None] | None = None - - @property - def current_jobs(self) -> list[Job]: - """Return current jobs.""" - return list(self._jobs.values()) - - def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE: - """Subscribe to updates for job. Return callback is used to unsubscribe. - - If any jobs match the subscription at the time this is called, runs the - callback on them. - """ - self._subscriptions.add(subscription) - - # Run the callback on each existing match - # We catch all errors to prevent an error in one from stopping the others - for match in [job for job in self._jobs.values() if subscription.matches(job)]: - try: - subscription.event_callback(match) - except Exception as err: # noqa: BLE001 - _LOGGER.error( - "Error encountered processing Supervisor Job (%s %s %s) - %s", - match.name, - match.reference, - match.uuid, - err, - ) - - return partial(self._subscriptions.discard, subscription) - - async def refresh_data(self, first_update: bool = False) -> None: - """Refresh job data.""" - job_data = await self._supervisor_client.jobs.info() - job_queue: list[Job] = job_data.jobs.copy() - new_jobs: dict[UUID, Job] = {} - changed_jobs: list[Job] = [] - - # Rebuild our job cache from new info and compare to find changes - while job_queue: - job = job_queue.pop(0) - job_queue.extend(job.child_jobs) - job = replace(job, child_jobs=[]) - - if job.uuid not in self._jobs or job != self._jobs[job.uuid]: - changed_jobs.append(job) - new_jobs[job.uuid] = replace(job, child_jobs=[]) - - # For any jobs that disappeared which weren't done, tell subscribers they - # changed to done. We don't know what else happened to them so leave the - # rest of their state as is rather then guessing - changed_jobs.extend( - [ - replace(job, done=True) - for uuid, job in self._jobs.items() - if uuid not in new_jobs and job.done is False - ] - ) - - # Replace our cache and inform subscribers of all changes - self._jobs = new_jobs - for job in changed_jobs: - self._process_job_change(job) - - # If this is the first update register to receive Supervisor events - if first_update: - self._dispatcher_disconnect = async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs - ) - - @callback - def _supervisor_events_to_jobs(self, event: dict[str, Any]) -> None: - """Update job data cache from supervisor events.""" - if ATTR_WS_EVENT not in event: - return - - if ( - event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE - and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR - and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE - ): - self._hass.async_create_task(self.refresh_data()) - - elif event[ATTR_WS_EVENT] == EVENT_JOB: - job = Job.from_dict(event[ATTR_DATA] | {"child_jobs": []}) - self._jobs[job.uuid] = job - self._process_job_change(job) - - def _process_job_change(self, job: Job) -> None: - """Process a job change by triggering callbacks on subscribers.""" - for sub in self._subscriptions: - if sub.matches(job): - sub.event_callback(job) - - # If the job is done, pop it from our cache if present after processing is done - if job.done and job.uuid in self._jobs: - del self._jobs[job.uuid] - - @callback - def unload(self) -> None: - """Unregister with dispatcher on config entry unload.""" - if self._dispatcher_disconnect: - self._dispatcher_disconnect() - self._dispatcher_disconnect = None diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 10b0c10362fc..afe62408ef55 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -17,15 +17,19 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR -from .coordinator import AddonData +from .const import ( + ADDONS_COORDINATOR, + ATTR_VERSION_LATEST, + JOBS_COORDINATOR, + MAIN_COORDINATOR, +) +from .coordinator import AddonData, JobSubscription from .entity import ( HassioAddonEntity, HassioCoreEntity, HassioOSEntity, HassioSupervisorEntity, ) -from .jobs import JobSubscription from .update_helper import update_addon, update_core, update_os ENTITY_DESCRIPTION = UpdateEntityDescription( @@ -220,7 +224,7 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription( self._update_job_changed, name="addon_manager_update", @@ -278,6 +282,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): ) -> None: """Install an update.""" await update_os(self.hass, version, backup) + await self.coordinator.async_refresh() @override async def async_release_notes(self) -> str | None: @@ -398,7 +403,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription(self._update_job_changed, name="supervisor_update") ) ) @@ -468,7 +473,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): """Subscribe to progress updates.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.jobs.subscribe( + self.hass.data[JOBS_COORDINATOR].subscribe( JobSubscription( self._update_job_changed, name="home_assistant_core_update" ) diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index ed3034437e1f..dea7dbfbd45a 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import ( from .config import HassioUpdateParametersDict from .const import ( + ADDONS_COORDINATOR, ATTR_DATA, ATTR_ENDPOINT, ATTR_METHOD, @@ -59,6 +60,10 @@ WS_NO_ADMIN_ENDPOINTS = re.compile( r")$" ) +# Endpoint that reloads the add-on store. Afterwards the add-on update +# entities must be refreshed so they don't report stale update information. +STORE_RELOAD_ENDPOINT = "/store/reload" + _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -159,6 +164,15 @@ async def websocket_supervisor_api( # sensitive information and the frontend does not require it for ingress. if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command): data.pop("options", None) + # Await so the frontend only sees the reload finish once the add-on + # update entities reflect the reloaded store. + if ( + command == STORE_RELOAD_ENDPOINT + and msg[ATTR_METHOD] == "post" + and (coordinator := hass.data.get(ADDONS_COORDINATOR)) + ): + await coordinator.async_refresh_after_store_reload() + connection.send_result(msg[WS_ID], data) diff --git a/homeassistant/components/haveibeenpwned/__init__.py b/homeassistant/components/haveibeenpwned/__init__.py index adead4ec46e0..69b93fc33ebf 100644 --- a/homeassistant/components/haveibeenpwned/__init__.py +++ b/homeassistant/components/haveibeenpwned/__init__.py @@ -1 +1 @@ -"""The haveibeenpwned component.""" +"""The HaveIBeenPwned integration.""" diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 1992a0de97f4..19e8dfe88a19 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -11,10 +11,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_MODE, CONF_NAME, + EntityStateAttribute, UnitOfLength, UnitOfTime, ) @@ -184,9 +183,10 @@ class OriginSensor(HERETravelTimeSensor): def extra_state_attributes(self) -> Mapping[str, Any] | None: """GPS coordinates.""" if self.coordinator.data is not None: + latitude, longitude = self.coordinator.data[ATTR_ORIGIN].split(",") return { - ATTR_LATITUDE: self.coordinator.data[ATTR_ORIGIN].split(",")[0], - ATTR_LONGITUDE: self.coordinator.data[ATTR_ORIGIN].split(",")[1], + EntityStateAttribute.LATITUDE: latitude, + EntityStateAttribute.LONGITUDE: longitude, } return None @@ -214,8 +214,9 @@ class DestinationSensor(HERETravelTimeSensor): def extra_state_attributes(self) -> Mapping[str, Any] | None: """GPS coordinates.""" if self.coordinator.data is not None: + latitude, longitude = self.coordinator.data[ATTR_DESTINATION].split(",") return { - ATTR_LATITUDE: self.coordinator.data[ATTR_DESTINATION].split(",")[0], - ATTR_LONGITUDE: self.coordinator.data[ATTR_DESTINATION].split(",")[1], + EntityStateAttribute.LATITUDE: latitude, + EntityStateAttribute.LONGITUDE: longitude, } return None diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index eceaf0ebdbc0..2d0404f0def3 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -252,17 +252,29 @@ async def async_setup_entry( sensor_type, ) - async_add_entities( - HikvisionBinarySensor( - entry=entry, - description=BINARY_SENSOR_DESCRIPTIONS[sensor_type], - sensor_type=sensor_type, - channel=channel_info[1], - ) - for sensor_type, channel_list in sensors.items() - if sensor_type in BINARY_SENSOR_DESCRIPTIONS - for channel_info in channel_list - ) + entities: list[HikvisionBinarySensor] = [] + for sensor_type, channel_list in sensors.items(): + if sensor_type not in BINARY_SENSOR_DESCRIPTIONS: + continue + # pyhik can report the same channel more than once for a sensor type + # (e.g. when a channel has several notification methods enabled), so + # deduplicate on the channel to avoid colliding unique IDs. + seen_channels: set[int] = set() + for channel_info in channel_list: + channel = channel_info[1] + if channel in seen_channels: + continue + seen_channels.add(channel) + entities.append( + HikvisionBinarySensor( + entry=entry, + description=BINARY_SENSOR_DESCRIPTIONS[sensor_type], + sensor_type=sensor_type, + channel=channel, + ) + ) + + async_add_entities(entities) class HikvisionBinarySensor(HikvisionEntity, BinarySensorEntity): diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index ebfb13653254..35745d6ebfbb 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -78,7 +78,6 @@ async def async_setup_entry( entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 1f132870af14..76ed224b5f78 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -197,8 +197,7 @@ class HistoryStats: finally: self._query_count -= 1 self._history_current_period = [ - HistoryState(state.state, state.last_changed.timestamp()) - for state in states + HistoryState(state.state, state.last_changed_timestamp) for state in states ] def _state_changes_during_period( diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 0edf787d5d05..43038c1a8c41 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.99", "babel==2.18.0"] + "requirements": ["holidays==0.100", "babel==2.18.0"] } diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json index b44b25fa03ba..7bb66015e5f0 100644 --- a/homeassistant/components/holiday/strings.json +++ b/homeassistant/components/holiday/strings.json @@ -51,20 +51,33 @@ "selector": { "device_class": { "options": { + "albanian": "Albanian", "armed_forces": "Armed forces", + "armenian": "Armenian", "bank": "Bank", + "bosnian": "Bosnian", "catholic": "Catholic", "chinese": "Chinese", "christian": "Christian", + "de_facto": "De facto", "government": "Government", "half_day": "Half day", "hebrew": "Hebrew", "hindu": "Hindu", "islamic": "Islamic", "optional": "Optional", + "orthodox": "Orthodox", + "protestant": "Protestant", + "public": "Public", + "roma": "Roma", + "sabian": "Sabian", "school": "School", + "serbian": "Serbian", + "turkish": "Turkish", "unofficial": "Unofficial", - "workday": "Workday" + "vlach": "Vlach", + "workday": "Workday", + "yazidi": "Yazidi" } } }, diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 44e475995ffb..c65e96298e01 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -93,9 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) for device in device_entries: if not device.identifiers.intersection(appliances_identifiers): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 1141b3a29737..94c8c7216af6 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -104,7 +104,7 @@ TRANSLATION_KEYS_PROGRAMS_MAP = { if program not in (ProgramKey.UNKNOWN, *FAVORITE_PROGRAMS) } -PROGRAMS_TRANSLATION_KEYS_MAP = { +PROGRAMS_TRANSLATION_KEYS_MAP: dict[ProgramKey, str] = { value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() } diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 54c6454167b0..5f5a9d52aa0c 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -324,7 +324,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: { vol.Required(ATTR_LATITUDE): cv.latitude, vol.Required(ATTR_LONGITUDE): cv.longitude, - vol.Optional(ATTR_ELEVATION): int, + vol.Optional(ATTR_ELEVATION): vol.Coerce(int), } ), ) diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py new file mode 100644 index 000000000000..11c3c9e30caa --- /dev/null +++ b/homeassistant/components/homeassistant/llm.py @@ -0,0 +1,341 @@ +"""LLM tools for the homeassistant integration.""" + +from decimal import Decimal +from enum import Enum +from operator import attrgetter +from typing import Any, override + +import voluptuous as vol + +from homeassistant.components.llm import LLMTools +from homeassistant.components.sensor import SensorDeviceClass, async_rounded_state +from homeassistant.const import EntityStateAttribute +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + device_registry as dr, + entity_registry as er, + intent, +) +from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput +from homeassistant.util import dt as dt_util, yaml as yaml_util +from homeassistant.util.json import JsonObjectType + +from .exposed_entities import async_should_expose + +# Domains bucketed out of the exposed-entity overview. +CALENDAR_DOMAIN = "calendar" +SCRIPT_DOMAIN = "script" + +NO_ENTITIES_PROMPT = ( + "Only if the user wants to control a device, tell them to expose entities " + "to their voice assistant in Home Assistant." +) + +DYNAMIC_CONTEXT_PROMPT = ( + "You ARE equipped to answer questions about the" + " current state of\n" + "the home using the `GetLiveContext` tool." + " This is a primary function." + " Do not state you lack the\n" + "functionality if the question requires live data.\n" + "If the user asks about device existence/type" + ' (e.g., "Do I have lights in the bedroom?"):' + " Answer\n" + "from the static context below.\n" + "If the user asks about the CURRENT state, value," + ' or mode (e.g., "Is the lock locked?",\n' + '"Is the fan on?",' + ' "What mode is the thermostat in?",' + ' "What is the temperature outside?"):\n' + " 1. Recognize this requires live data.\n" + " 2. You MUST call `GetLiveContext`." + " This tool will provide the needed real-time" + " information (like temperature from the local" + " weather, lock status, etc.).\n" + " 3. Use the tool's response** to answer the" + " user accurately" + ' (e.g., "The temperature outside is' + ' [value from tool].").\n' + "For general knowledge questions not about the" + " home: Answer truthfully from internal" + " knowledge.\n" +) + + +@callback +def async_get_exposed_entities( + hass: HomeAssistant, + assistant: str, + include_state: bool = True, +) -> dict[str, dict[str, Any]]: + """Get exposed entities, ignoring calendars and scripts.""" + area_registry = ar.async_get(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + interesting_attributes = { + "temperature", + "current_temperature", + "temperature_unit", + "brightness", + "humidity", + "unit_of_measurement", + "device_class", + "current_position", + "percentage", + "volume_level", + "media_title", + "media_artist", + "media_album_name", + } + + entities: dict[str, dict[str, Any]] = {} + + for state in sorted(hass.states.async_all(), key=attrgetter("name")): + if not async_should_expose(hass, assistant, state.entity_id): + continue + + # Calendars and scripts have their own tools; skip them here. + if state.domain in (CALENDAR_DOMAIN, SCRIPT_DOMAIN): + continue + + entity_entry = entity_registry.async_get(state.entity_id) + device_entry = ( + device_registry.async_get(entity_entry.device_id) + if entity_entry is not None and entity_entry.device_id is not None + else None + ) + names = intent.async_get_entity_aliases(hass, entity_entry, state=state) + area_names = [] + + if entity_entry is not None: + if ( + entity_entry.area_id is not None + and (area_entry := area_registry.async_get_area(entity_entry.area_id)) + is not None + ): + # Entity is in area + area_names.append(area_entry.name) + area_names.extend(sorted(area_entry.aliases)) + elif device_entry is not None: + # Check device area + if ( + device_entry.area_id is not None + and ( + area_entry := area_registry.async_get_area(device_entry.area_id) + ) + is not None + ): + area_names.append(area_entry.name) + area_names.extend(sorted(area_entry.aliases)) + + info: dict[str, Any] = { + "names": ", ".join(names), + "domain": state.domain, + } + + if include_state: + info["state"] = state.state + + # Format numeric states with configured display precision + if state.domain == "sensor": + info["state"] = async_rounded_state(hass, state.entity_id, state) + + # Convert timestamp device_class states from UTC to local time + if ( + state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + == SensorDeviceClass.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) + + if include_state and ( + attributes := { + str(attr_name): ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) + for attr_name, attr_value in state.attributes.items() + if attr_name in interesting_attributes + } + ): + info["attributes"] = attributes + + entities[state.entity_id] = info + + return entities + + +def _live_context_match_error( + match_result: intent.MatchTargetsResult, + name_filter: str | None, + area_filter: str | None, + domain_filter: list[str] | None, +) -> str: + """Build an actionable error message for a failed GetLiveContext match.""" + reason = match_result.no_match_reason + if reason is intent.MatchFailedReason.INVALID_AREA: + return f"Area '{match_result.no_match_name}' does not exist" + if reason is intent.MatchFailedReason.NAME: + return f"No exposed entities matched name '{name_filter}'" + if reason is intent.MatchFailedReason.AREA: + return f"No exposed entities found in area '{area_filter}'" + if reason is intent.MatchFailedReason.DOMAIN: + domains = ", ".join(domain_filter) if domain_filter else "" + return f"No exposed entities found in domain(s): {domains}" + return "No entities matched the provided filter" + + +class GetLiveContextTool(Tool): + """Tool for getting the current state of exposed entities. + + This returns state for all entities that have been exposed to + the assistant. This is different than the GetState intent, which + returns state for entities based on intent parameters. + """ + + name = "GetLiveContext" + description = ( + "Provides real-time information about the" + " CURRENT state, value, or mode of devices," + " sensors, entities, or areas. " + "Use this tool for: " + "1. Answering questions about current" + " conditions (e.g., 'Is the light on?'). " + "2. As the first step in conditional actions" + " (e.g., 'If the weather is rainy, turn off" + " sprinklers' requires checking the weather" + " first). " + "You may filter for devices by name, domain," + " and area, including combining those" + " filters. " + "Prefer filtering by domain when searching" + " for multiple devices of the same type." + ) + parameters = vol.Schema( + { + vol.Optional( + "name", + description="Filter entities by name or alias (case-insensitive).", + ): cv.string, + vol.Optional( + "domain", + description=( + "Filter entities by domain" + " (e.g. 'light', 'sensor')." + " Accepts a single domain or a list." + ), + ): vol.Any(cv.string, [cv.string]), + vol.Optional( + "area", + description="Filter entities by area name or alias (case-insensitive).", + ): cv.string, + } + ) + + @override + async def async_call( + self, + hass: HomeAssistant, + tool_input: ToolInput, + llm_context: LLMContext, + ) -> JsonObjectType: + """Get the current state of exposed entities.""" + args = self.parameters(tool_input.tool_args) + exposed_entities = async_get_exposed_entities(hass, llm_context.assistant) + + if not exposed_entities: + return {"success": False, "error": NO_ENTITIES_PROMPT} + + name_filter = args.get("name") + area_filter = args.get("area") + domain_filter = args.get("domain") + + if isinstance(domain_filter, str): + domain_filter = [domain_filter] + + if domain_filter is not None: + domain_filter = [ + normalized_domain + for domain in domain_filter + if (normalized_domain := domain.strip().lower()) + ] + + if name_filter or area_filter or domain_filter: + exposed_states = [ + state + for entity_id in exposed_entities + if (state := hass.states.get(entity_id)) is not None + ] + match_result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=name_filter, + area_name=area_filter, + domains=domain_filter, + # This tool only returns context, so multiple entities + # sharing a name (e.g. "AC" in two areas) should all be + # returned rather than failing as an ambiguous match. + allow_duplicate_names=True, + ), + states=exposed_states, + ) + + if not match_result.is_match: + return { + "success": False, + "error": _live_context_match_error( + match_result, name_filter, area_filter, domain_filter + ), + } + + matched_ids = {state.entity_id for state in match_result.states} + entities = [ + info + for entity_id, info in exposed_entities.items() + if entity_id in matched_ids + ] + else: + entities = list(exposed_entities.values()) + + prompt = [ + "Live Context: An overview of the areas" + " and the devices in this smart home:", + yaml_util.dump(entities), + ] + return { + "success": True, + "result": "\n".join(prompt), + } + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the GetLiveContext tool and the smart home context prompt.""" + if api_id != LLM_API_ASSIST: + return None + + exposed_entities = async_get_exposed_entities( + hass, llm_context.assistant, include_state=False + ) + if exposed_entities: + prompt = "\n".join( + [ + DYNAMIC_CONTEXT_PROMPT, + "Static Context: An overview of the areas" + " and the devices in this smart home:", + yaml_util.dump(list(exposed_entities.values())), + ] + ) + else: + prompt = NO_ENTITIES_PROMPT + + return LLMTools(tools=[GetLiveContextTool()], prompt=prompt) diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index ded640b55a70..b8d1b91b7097 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.const import ( - ATTR_DEVICE_CLASS, CONF_AT, CONF_ENTITY_ID, CONF_OFFSET, @@ -18,6 +17,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, WEEKDAYS, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -224,7 +224,7 @@ async def async_attach_trigger( # noqa: C901 ) elif ( new_state.domain == "sensor" - and new_state.attributes.get(ATTR_DEVICE_CLASS) + and new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS) in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME) and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 01a7d3995344..dac324ea09ac 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -105,10 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo ) if not is_node_present: _LOGGER.info("Removing device %s", device.name) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) # Remove device at runtime when node is removed in homee async def _remove_node_callback(node: HomeeNode, add: bool) -> None: diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index 84ad604667de..b2f853dc792f 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -1,6 +1,6 @@ """The homee cover platform.""" -from enum import Enum +from enum import IntEnum import logging from typing import TYPE_CHECKING, Any, cast, override @@ -38,22 +38,22 @@ IS_CLOSED_ATTRIBUTES = [ ] -class HomeeCoverState(float, Enum): +class HomeeCoverState(IntEnum): """Open/closed states for covers in homee.""" - OPEN = 0.0 - CLOSED = 1.0 - STOPPED = 2.0 - OPENING = 3.0 - CLOSING = 4.0 + OPEN = 0 + CLOSED = 1 + STOPPED = 2 + OPENING = 3 + CLOSING = 4 -class HomeeSlatState(float, Enum): +class HomeeSlatState(IntEnum): """Slat states for covers in homee.""" - STOPPED = 0.0 - CLOSED = 1.0 - OPEN = 2.0 + STOPPED = 0 + CLOSED = 1 + OPEN = 2 def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None: diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 54e1c3efbc88..d43c504f5018 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.4.1"], + "requirements": ["pyHomee==1.4.2"], "zeroconf": [ { "name": "homee-*", diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 0626071eeaff..55f2ae2d3bc8 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -88,6 +88,7 @@ from . import ( # noqa: F401 type_cameras, type_covers, type_fans, + type_heater_coolers, type_humidifiers, type_lights, type_locks, @@ -98,7 +99,13 @@ from . import ( # noqa: F401 type_switches, type_thermostats, ) -from .accessories import HomeAccessory, HomeBridge, HomeDriver, get_accessory +from .accessories import ( + HomeAccessory, + HomeBridge, + HomeDriver, + async_resolve_accessory_type, + get_accessory, +) from .aidmanager import AccessoryAidStorage from .const import ( ATTR_INTEGRATION, @@ -572,6 +579,10 @@ class HomeKit: self.bridge: HomeBridge | None = None self._reset_lock = asyncio.Lock() self._cancel_reload_dispatcher: CALLBACK_TYPE | None = None + # True while running the first ever start of this entry (no + # persisted pairing state yet); accessory mode uses it to tell a + # brand new entry from one that predates the HeaterCooler. + self._first_ever_start = False def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> bool: """Set up bridge and accessory driver. @@ -669,8 +680,10 @@ class HomeKit: removed: list[str] = [] acc: HomeAccessory | None for entity_id in entity_ids: - aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) - if aid not in self.bridge.accessories: + # A lookup must not allocate; an allocation marks the entity as + # previously bridged, which would suppress the automatic routing. + aid = self.aid_storage.get_allocated_aid_for_entity_id(entity_id) + if aid is None or aid not in self.bridge.accessories: continue if acc := self.async_remove_bridge_accessory(aid): self._async_shutdown_accessory(acc) @@ -753,8 +766,14 @@ class HomeKit: assert self.aid_storage is not None assert self.bridge is not None - aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) conf = self._config.get(state.entity_id, {}).copy() + # Must run before the aid is allocated below so a never bridged + # entity is still recognizable as new. + pending_type = async_resolve_accessory_type( + self.aid_storage, state, conf, allow_auto=True + ) + newly_allocated = not self.aid_storage.entity_is_allocated(state.entity_id) + aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) # If an accessory cannot be created or added due to an exception # of any kind (usually in pyhap) it should not prevent # the rest of the accessories from being created @@ -762,11 +781,19 @@ class HomeKit: acc = get_accessory(self.hass, self.driver, state, aid, conf) if acc is not None: self.bridge.add_accessory(acc) + if pending_type: + self.aid_storage.async_set_accessory_type( + state.entity_id, pending_type + ) return acc except Exception: _LOGGER.exception( "Failed to create a HomeKit accessory for %s", state.entity_id ) + if newly_allocated: + # A failed first attempt must not classify the entity as + # existing on the next try. + self.aid_storage.async_delete_aid_for_entity_id(state.entity_id) return None def _would_exceed_max_devices(self, name: str | None) -> bool: @@ -882,6 +909,7 @@ class HomeKit: self.setup, async_zc_instance, uuid ) assert self.driver is not None + self._first_ever_start = not loaded_from_disk if not await self._async_create_accessories(): return @@ -894,6 +922,9 @@ class HomeKit: # need to make sure its persisted to disk. async with self.hass.data[PERSIST_LOCK_DATA]: await self.hass.async_add_executor_job(self.driver.persist) + # The pairing state is persisted now, so later reloads treat the + # entry as existing. + self._first_ever_start = False self.status = STATUS_RUNNING if self.driver.state.paired: @@ -1000,6 +1031,13 @@ class HomeKit: return None state = entity_states[0] conf = self._config.get(state.entity_id, {}).copy() + # Accessory mode has no aid allocation to tell new from existing, + # so only a brand new pairing picks its type automatically; anything + # else keeps its current accessory. + assert self.aid_storage is not None + pending_type = async_resolve_accessory_type( + self.aid_storage, state, conf, allow_auto=self._first_ever_start + ) acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) if acc is None: _LOGGER.error( @@ -1007,6 +1045,9 @@ class HomeKit: self._name, self._filter.config, ) + return None + if pending_type: + self.aid_storage.async_set_accessory_type(state.entity_id, pending_type) return acc async def _async_create_bridge_accessory( diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 186be4ce8c0c..88cd6b276dbf 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -12,6 +12,10 @@ from pyhap.iid_manager import IIDManager from pyhap.service import Service from pyhap.util import callback as pyhap_callback +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityFeature, +) from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.media_player import MediaPlayerDeviceClass @@ -56,6 +60,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.decorator import Registry +from .aidmanager import AccessoryAidStorage +from .climate_util import ( + get_fan_modes_and_speeds, + get_swing_on_mode, + has_swing_off_mode, +) from .const import ( ATTR_DISPLAY_NAME, ATTR_INTEGRATION, @@ -87,10 +97,12 @@ from .const import ( TYPE_AIR_PURIFIER, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from .iidmanager import AccessoryIIDStorage @@ -117,6 +129,10 @@ FAN_TYPES = { TYPE_AIR_PURIFIER: "AirPurifier", TYPE_FAN: "Fan", } +CLIMATE_TYPES = { + TYPE_HEATER_COOLER: "HeaterCooler", + TYPE_THERMOSTAT: "Thermostat", +} TYPES: Registry[str, type[HomeAccessory]] = Registry() RELOAD_ON_CHANGE_ATTRS = ( @@ -126,6 +142,102 @@ RELOAD_ON_CHANGE_ATTRS = ( ) +def climate_controls_target_humidity(state: State) -> bool: + """Return True when a climate entity exposes a humidity setpoint. + + HeaterCooler cannot control a humidity setpoint; entities that + expose one (e.g. econet) stay on the Thermostat, which can. + """ + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + return bool(features & ClimateEntityFeature.TARGET_HUMIDITY) + + +def climate_supports_heater_cooler(state: State) -> bool: + """Return True when a climate entity fits the HeaterCooler accessory.""" + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + # Timing fan modes like auto or circulate do not count as speeds. + has_fan = bool(features & ClimateEntityFeature.FAN_MODE) and ( + len(get_fan_modes_and_speeds(attributes)[1]) >= 2 + ) + # The binary swing control writes the off mode back, so automatic + # routing requires the entity to advertise one. + has_swing = bool(features & ClimateEntityFeature.SWING_MODE) and ( + get_swing_on_mode(attributes) is not None and has_swing_off_mode(attributes) + ) + return (has_fan or has_swing) and not ( + features & ClimateEntityFeature.TARGET_HUMIDITY + ) + + +@ha_callback +def async_resolve_accessory_type( + aid_storage: AccessoryAidStorage, + state: State, + conf: dict[str, Any], + *, + allow_auto: bool, +) -> str | None: + """Resolve which accessory an entity uses into conf. + + Some domains can be represented by more than one HomeKit accessory; + climate is the only such domain today. Returns the accessory type the + caller must record with async_set_accessory_type once the accessory is + successfully created, so a failed creation is not sticky across + restarts; a stored routing the entity can no longer support is dropped + immediately instead. + """ + if state.domain != CLIMATE_DOMAIN: + return None + return _async_resolve_climate_type(aid_storage, state, conf, allow_auto=allow_auto) + + +@ha_callback +def _async_resolve_climate_type( + aid_storage: AccessoryAidStorage, + state: State, + conf: dict[str, Any], + *, + allow_auto: bool, +) -> str | None: + """Resolve which accessory a climate entity uses into conf. + + An explicit type in the entity config always wins, even for entities + with a humidity setpoint, and updates the stored routing, so switching + back to automatic keeps the accessory the entity already uses. In + bridge mode an entity that has never been bridged gets the HeaterCooler + when capable. Anything else keeps the Thermostat; the accessory type + can be changed at any time from the bridge options. + """ + entity_id = state.entity_id + if climate_type := conf.get(CONF_TYPE): + # The explicit type is recorded by the caller like the automatic + # one, so every path that sets a type defers persistence until + # the accessory exists. + return cast(str, climate_type) + stored_type = aid_storage.get_accessory_type(entity_id) + if stored_type == TYPE_HEATER_COOLER: + if not climate_controls_target_humidity(state): + conf[CONF_TYPE] = TYPE_HEATER_COOLER + return None + # A humidity setpoint gained since the choice was stored cannot + # be represented by the HeaterCooler, so the routing is dropped. + aid_storage.async_set_accessory_type(entity_id, None) + if not climate_supports_heater_cooler(state): + return None + if ( + stored_type is None + and allow_auto + and not aid_storage.entity_is_allocated(entity_id) + ): + # A stored Thermostat choice survives even when the entity looks + # new again, like an accessory mode pairing reset, so Automatic + # keeps the accessory the entity already uses. + conf[CONF_TYPE] = TYPE_HEATER_COOLER + return TYPE_HEATER_COOLER + return None + + def get_accessory( # noqa: C901 hass: HomeAssistant, driver: HomeDriver, state: State, aid: int | None, config: dict ) -> HomeAccessory | None: @@ -151,7 +263,8 @@ def get_accessory( # noqa: C901 a_type = "BinarySensor" elif state.domain == "climate": - a_type = "Thermostat" + # The type is resolved by the bridge before the accessory is created. + a_type = CLIMATE_TYPES[config.get(CONF_TYPE, TYPE_THERMOSTAT)] elif state.domain == "cover": device_class = state.attributes.get(ATTR_DEVICE_CLASS) @@ -637,6 +750,25 @@ class HomeAccessory(Accessory): # type: ignore[misc] value: Any | None = None, ) -> None: """Fire event and call service for changes from HomeKit.""" + self.hass.async_create_task( + self.async_call_service_and_wait(domain, service, service_data, value), + eager_start=True, + ) + + async def async_call_service_and_wait( + self, + domain: str, + service: str, + service_data: dict[str, Any], + value: Any | None = None, + ) -> bool: + """Fire event and call service, returning True when it succeeded. + + blocking=True so the handler's exception reaches us (the + non-blocking path swallows it); on failure we resync so pyhap's + optimistic target characteristic doesn't strand the tile on the + requested action. + """ event_data = { ATTR_ENTITY_ID: service_data.get(ATTR_ENTITY_ID, self.entity_id), ATTR_DISPLAY_NAME: self.display_name, @@ -647,36 +779,40 @@ class HomeAccessory(Accessory): # type: ignore[misc] self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data, context=context) - async def _call() -> None: - # blocking=True so the handler's exception reaches us (the - # non-blocking path swallows it); on failure we resync so pyhap's - # optimistic target characteristic doesn't strand the tile on the - # requested action. - try: - await self.hass.services.async_call( - domain, service, service_data, blocking=True, context=context - ) - except HomeAssistantError as err: - _LOGGER.warning( - "%s: %s.%s failed (%s); re-syncing HomeKit state", - self.entity_id, - domain, - service, - err, - ) - except Exception: - _LOGGER.exception( - "%s: %s.%s raised unexpectedly; re-syncing HomeKit state", - self.entity_id, - domain, - service, - ) - else: - return + try: + await self.hass.services.async_call( + domain, service, service_data, blocking=True, context=context + ) + except HomeAssistantError as err: + _LOGGER.warning( + "%s: %s.%s failed (%s); re-syncing HomeKit state", + self.entity_id, + domain, + service, + err, + ) + except Exception: + _LOGGER.exception( + "%s: %s.%s raised unexpectedly; re-syncing HomeKit state", + self.entity_id, + domain, + service, + ) + else: + return True + # This coroutine often runs fire-and-forget, so failures must be + # logged here instead of by the loop's default task handler. + try: if (state := self.hass.states.get(self.entity_id)) is not None: self.async_update_state(state) - - self.hass.async_create_task(_call(), eager_start=True) + else: + _LOGGER.debug( + "%s: cannot re-sync HomeKit state; entity has no state", + self.entity_id, + ) + except Exception: + _LOGGER.exception("%s: re-syncing HomeKit state failed", self.entity_id) + return False @ha_callback def async_reload(self) -> None: diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index c76232f65f91..abcfb03575cd 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -25,6 +25,7 @@ AID_MANAGER_SAVE_DELAY = 2 ALLOCATIONS_KEY = "allocations" UNIQUE_IDS_KEY = "unique_ids" +ACCESSORY_TYPES_KEY = "accessory_types" INVALID_AIDS = (0, 1) @@ -69,6 +70,7 @@ class AccessoryAidStorage: self.hass = hass self.allocations: dict[str, int] = {} self.allocated_aids: set[int] = set() + self.accessory_types: dict[str, str] = {} self._entry_id = entry_id self.store: Store | None = None self._entity_registry = er.async_get(hass) @@ -84,6 +86,59 @@ class AccessoryAidStorage: assert isinstance(raw_storage, dict) self.allocations = raw_storage.get(ALLOCATIONS_KEY, {}) self.allocated_aids = set(self.allocations.values()) + self.accessory_types = raw_storage.get(ACCESSORY_TYPES_KEY, {}) + + def _stable_storage_keys(self, entity_id: str) -> tuple[str, ...]: + """Return the keys the entity's stable identity can resolve to. + + The preferred key comes first, matching the aid allocation + preference for the system unique id over the entity id. + """ + if not (entry := self._entity_registry.async_get(entity_id)): + return (entity_id,) + keys = [get_system_unique_id(entry, entry.unique_id)] + if previous_unique_id := entry.previous_unique_id: + keys.append(get_system_unique_id(entry, previous_unique_id)) + keys.append(entity_id) + return tuple(keys) + + @callback + def async_set_accessory_type( + self, entity_id: str, accessory_type: str | None + ) -> None: + """Persist the accessory type an entity resolved to, None clears it. + + The choice is stored by the same stable identity as the aid + allocation, so it survives entity id renames and unique id changes. + """ + if accessory_type == self.get_accessory_type(entity_id): + return + types = self.accessory_types + keys = self._stable_storage_keys(entity_id) + for key in keys: + types.pop(key, None) + if accessory_type is not None: + types[keys[0]] = accessory_type + self.async_schedule_save() + + @callback + def get_accessory_type(self, entity_id: str) -> str | None: + """Return the stored accessory type for the entity, if any. + + A type found under an outdated identity moves to the current one + and schedules a save, since only the latest previous unique id + stays resolvable; the read is loop bound because of that. + """ + types = self.accessory_types + keys = self._stable_storage_keys(entity_id) + for key in keys: + if (accessory_type := types.get(key)) is not None: + if key != keys[0]: + del types[key] + types[keys[0]] = accessory_type + self.async_schedule_save() + return accessory_type + return None def get_or_allocate_aid_for_entity_id(self, entity_id: str) -> int: """Generate a stable aid for an entity id.""" @@ -94,6 +149,15 @@ class AccessoryAidStorage: self._migrate_unique_id_aid_assignment_if_needed(sys_unique_id, entry) return self.get_or_allocate_aid(sys_unique_id, entity_id) + def entity_is_allocated(self, entity_id: str) -> bool: + """Return True when the entity already has an allocated aid. + + Checks every key get_or_allocate_aid_for_entity_id could resolve to, + without allocating, so callers can tell a previously bridged entity + from a new one. + """ + return self.get_allocated_aid_for_entity_id(entity_id) is not None + def _migrate_unique_id_aid_assignment_if_needed( self, sys_unique_id: str, entry: er.RegistryEntry ) -> None: @@ -129,6 +193,24 @@ class AccessoryAidStorage: f"Unable to generate unique aid allocation for {entity_id} [{unique_id}]" ) + def get_allocated_aid_for_entity_id(self, entity_id: str) -> int | None: + """Return the entity's allocated aid without allocating one.""" + allocations = self.allocations + return next( + ( + allocations[key] + for key in self._stable_storage_keys(entity_id) + if key in allocations + ), + None, + ) + + @callback + def async_delete_aid_for_entity_id(self, entity_id: str) -> None: + """Remove the aid allocation for an entity.""" + for key in self._stable_storage_keys(entity_id): + self.delete_aid(key) + def delete_aid(self, storage_key: str) -> None: """Delete an aid allocation.""" if storage_key not in self.allocations: @@ -150,6 +232,11 @@ class AccessoryAidStorage: return await self.store.async_save(self._data_to_save()) @callback - def _data_to_save(self) -> dict[str, dict[str, int]]: + def _data_to_save(self) -> dict[str, dict[str, int] | dict[str, str]]: """Return data of entity map to store in a file.""" - return {ALLOCATIONS_KEY: self.allocations} + data: dict[str, dict[str, int] | dict[str, str]] = { + ALLOCATIONS_KEY: self.allocations + } + if self.accessory_types: + data[ACCESSORY_TYPES_KEY] = self.accessory_types + return data diff --git a/homeassistant/components/homekit/climate_base.py b/homeassistant/components/homekit/climate_base.py new file mode 100644 index 000000000000..401f5409d0d8 --- /dev/null +++ b/homeassistant/components/homekit/climate_base.py @@ -0,0 +1,383 @@ +"""Base class shared by the climate accessory types.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_THERMOSTAT +from pyhap.service import Service + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_OFF, + FAN_ON, + SERVICE_SET_FAN_MODE, + SERVICE_SET_SWING_MODE, + SWING_OFF, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import State, callback +from homeassistant.util.percentage import percentage_to_ordered_list_item + +from .accessories import HomeAccessory +from .climate_util import ( + fan_mode_to_speed, + fan_speed_to_mode, + get_fan_modes_and_speeds, + get_swing_off_mode, + get_swing_on_mode, + get_temperature_range_from_state, + has_swing_off_mode, + is_swing_on, + resolve_target_temp_range, + temperature_attribute_to_homekit, +) +from .const import ( + CHAR_ACTIVE, + CHAR_CURRENT_FAN_STATE, + CHAR_CURRENT_TEMPERATURE, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_FANV2, +) +from .util import temperature_to_homekit, temperature_to_states + +_LOGGER = logging.getLogger(__name__) + +FAN_STATE_INACTIVE = 0 +FAN_STATE_IDLE = 1 +FAN_STATE_ACTIVE = 2 + +# States in which a climate entity is inactive rather than idle +CLIMATE_INACTIVE_STATES = frozenset({HVACMode.OFF, STATE_UNAVAILABLE, STATE_UNKNOWN}) + +HC_HASS_TO_HOMEKIT_FAN_STATE = { + HVACAction.OFF: FAN_STATE_INACTIVE, + HVACAction.IDLE: FAN_STATE_IDLE, + HVACAction.HEATING: FAN_STATE_ACTIVE, + HVACAction.COOLING: FAN_STATE_ACTIVE, + HVACAction.DRYING: FAN_STATE_ACTIVE, + HVACAction.FAN: FAN_STATE_ACTIVE, + HVACAction.PREHEATING: FAN_STATE_IDLE, + HVACAction.DEFROSTING: FAN_STATE_IDLE, +} + + +class HomeKitClimateAccessory(HomeAccessory): + """Base class for the Thermostat and HeaterCooler accessories.""" + + # Configured by subclasses only when the entity exposes the mode. + char_speed: Characteristic + char_swing: Characteristic + + char_current_temp: Characteristic + + # Configured by _configure_fan_service when fan_chars is non-empty. + char_fan_active: Characteristic + char_target_fan_state: Characteristic + char_current_fan_state: Characteristic + + def __init__(self, *args: Any) -> None: + """Initialize the shared climate accessory state.""" + super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._unit = self.hass.config.units.temperature_unit + + state = self.hass.states.get(self.entity_id) + assert state + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # ``fan_modes`` maps lowercased names to their original casing; + # ``ordered_fan_speeds`` holds the predefined speeds in HomeKit order. + self.fan_modes: dict[str, str] = {} + self.ordered_fan_speeds: list[str] = [] + if features & ClimateEntityFeature.FAN_MODE: + self.fan_modes, self.ordered_fan_speeds = get_fan_modes_and_speeds( + attributes + ) + + self.swing_on_mode: str | None = None + self.swing_off_mode: str = SWING_OFF + # The binary swing toggle writes the off mode back, so it is only + # usable when the entity advertises one. + if features & ClimateEntityFeature.SWING_MODE and has_swing_off_mode( + attributes + ): + self.swing_on_mode = get_swing_on_mode(attributes) + self.swing_off_mode = get_swing_off_mode(attributes) + + # Characteristics the subclass places on a linked fan service; which + # ones, if any, is the subclass's policy. + self.fan_chars: list[str] = [] + + # These attributes drive the characteristic set and valid values, so + # reload the accessory when any of them change. + self._reload_on_change_attrs.extend( + ( + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_FAN_MODES, + ATTR_SWING_MODES, + ATTR_HVAC_MODES, + ) + ) + + def get_temperature_range(self, state: State) -> tuple[float, float]: + """Return the min and max temperature range.""" + return get_temperature_range_from_state( + state, self._unit, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP + ) + + def _configure_current_temperature_char(self, serv: Service) -> None: + """Configure the shared current temperature characteristic.""" + self.char_current_temp = serv.configure_char( + CHAR_CURRENT_TEMPERATURE, value=21.0 + ) + + def _configure_target_mode_char( + self, + serv: Service, + char_name: str, + value: int, + valid_values: dict[HVACMode, int], + ) -> Characteristic: + """Configure a target mode characteristic scoped to the supported modes. + + The value must be set before ``valid_values`` because pyhap applies the + valid values first and would reject a default outside that set. + """ + char = serv.configure_char(char_name, value=value) + char.override_properties(valid_values=valid_values) + char.allow_invalid_client_values = True + return char + + def _reject_char_write(self, char: Characteristic, value: Any) -> None: + """Flip a characteristic back after rejecting a client write.""" + char.value = value + char.notify() + + def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None: + """Send a climate write from a characteristic setter. + + Subclasses can override this to serialize their writes. + """ + self.async_call_service( + CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params} + ) + + def _update_temperature_char( + self, char: Characteristic, state: State, attr: str + ) -> None: + """Set a temperature characteristic from a state attribute, if present.""" + if ( + value := temperature_attribute_to_homekit(state, attr, self._unit) + ) is not None: + char.set_value(value) + + def _update_current_temperature_char(self, state: State) -> None: + """Update the current temperature characteristic from the entity state.""" + self._update_temperature_char( + self.char_current_temp, state, ATTR_CURRENT_TEMPERATURE + ) + + def _dual_setpoint_params( + self, + cool_char: Characteristic, + heat_char: Characteristic, + new_high: float | None, + new_low: float | None, + ) -> dict[str, float]: + """Return an ordered high/low target temperature pair for a range write. + + Fills the unchanged side from the current characteristic value and + enforces the deadband, so the entity always gets a consistent pair. + """ + high, low = resolve_target_temp_range( + cool_char.value, + heat_char.value, + new_high, + new_low, + cool_char.properties[PROP_MIN_VALUE], + cool_char.properties[PROP_MAX_VALUE], + ) + return { + ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high), + ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low), + } + + def _temperature_to_homekit(self, temp: float) -> float: + """Convert a temperature in the entity's unit to the HomeKit unit.""" + return temperature_to_homekit(temp, self._unit) + + def _temperature_to_states(self, temp: float) -> float: + """Convert a temperature in the HomeKit unit to the entity's unit.""" + return temperature_to_states(temp, self._unit) + + def _fan_speed_params(self, speed: int) -> dict[str, Any] | None: + """Return the set_fan_mode data for a HomeKit rotation speed.""" + _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) + if not self.ordered_fan_speeds or not 0 < speed <= 100: + return None + mode = fan_speed_to_mode(self.ordered_fan_speeds, self.fan_modes, speed) + return {ATTR_FAN_MODE: mode} + + def _set_fan_speed(self, speed: int) -> None: + """Send the climate fan mode for a HomeKit rotation speed.""" + if (params := self._fan_speed_params(speed)) is not None: + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, params) + + def _swing_mode_params(self, swing_on: int) -> dict[str, Any] | None: + """Return the set_swing_mode data for a HomeKit swing toggle.""" + if self.swing_on_mode is None: + return None + _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) + return { + ATTR_SWING_MODE: self.swing_on_mode if swing_on else self.swing_off_mode + } + + def _set_swing_mode(self, swing_on: int) -> None: + """Send the climate swing mode for a HomeKit swing toggle.""" + if (params := self._swing_mode_params(swing_on)) is not None: + self._dispatch_climate_write(SERVICE_SET_SWING_MODE, params) + + def _update_fan_speed_char(self, attributes: Mapping[str, Any]) -> None: + """Update the rotation speed characteristic from the current fan mode.""" + # Modes with no predefined speed (e.g. fan auto) keep the last value; + # HomeKit's slider has no position to represent them. + if ( + self.ordered_fan_speeds + and ( + speed := fan_mode_to_speed( + self.ordered_fan_speeds, attributes.get(ATTR_FAN_MODE) + ) + ) + is not None + ): + self.char_speed.set_value(speed) + + def _update_swing_char(self, attributes: Mapping[str, Any]) -> None: + """Update the swing characteristic from the current swing mode.""" + # An absent swing mode keeps the last value; there is nothing to show. + if self.swing_on_mode is not None and ( + swing_mode := attributes.get(ATTR_SWING_MODE) + ): + self.char_swing.set_value(1 if is_swing_on(swing_mode) else 0) + + def _configure_fan_service(self, primary_serv: Service) -> None: + """Create a linked fan service for the chars in ``fan_chars``.""" + serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars) + primary_serv.add_linked_service(serv_fan) + self.char_fan_active = serv_fan.configure_char( + CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active + ) + if CHAR_SWING_MODE in self.fan_chars: + self.char_swing = serv_fan.configure_char( + CHAR_SWING_MODE, + value=0, + setter_callback=self._set_swing_mode, + ) + self.char_swing.display_name = "Swing Mode" + if CHAR_ROTATION_SPEED in self.fan_chars: + self.char_speed = serv_fan.configure_char( + CHAR_ROTATION_SPEED, + value=100, + properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)}, + setter_callback=self._set_fan_speed, + ) + self.char_speed.display_name = "Fan Mode" + if CHAR_CURRENT_FAN_STATE in self.fan_chars: + self.char_current_fan_state = serv_fan.configure_char( + CHAR_CURRENT_FAN_STATE, + value=0, + ) + self.char_current_fan_state.display_name = "Fan State" + if CHAR_TARGET_FAN_STATE in self.fan_chars: + self.char_target_fan_state = serv_fan.configure_char( + CHAR_TARGET_FAN_STATE, + value=0, + setter_callback=self._set_fan_auto, + ) + self.char_target_fan_state.display_name = "Fan Auto" + + def _get_on_mode(self) -> str: + """Return the fan mode to use when leaving auto or turning the fan on.""" + if self.ordered_fan_speeds: + speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50) + return self.fan_modes[speed_key] + return self.fan_modes[FAN_ON] + + def _set_fan_active(self, active: int) -> None: + """Send the climate fan mode for a HomeKit fan active toggle.""" + _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active) + if FAN_OFF not in self.fan_modes: + _LOGGER.debug( + "%s: Fan does not support off, resetting to on", self.entity_id + ) + self._reject_char_write(self.char_fan_active, 1) + return + mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF] + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode}) + + def _set_fan_auto(self, auto: int) -> None: + """Send the climate fan mode for a HomeKit fan auto toggle. + + Subclasses must only add CHAR_TARGET_FAN_STATE to ``fan_chars`` when + FAN_AUTO is in ``fan_modes``; this setter assumes the mode exists. + """ + _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) + mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() + self._dispatch_climate_write(SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: mode}) + + @callback + def _async_update_fan_service(self, new_state: State) -> None: + """Update the linked fan service from the entity state.""" + attributes = new_state.attributes + + self._update_swing_char(attributes) + self._update_fan_speed_char(attributes) + + fan_mode = attributes.get(ATTR_FAN_MODE) + fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None + if CHAR_TARGET_FAN_STATE in self.fan_chars: + self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0) + + if CHAR_CURRENT_FAN_STATE in self.fan_chars and ( + hvac_action := attributes.get(ATTR_HVAC_ACTION) + ): + self.char_current_fan_state.set_value( + HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action] + ) + + self.char_fan_active.set_value( + int( + new_state.state not in CLIMATE_INACTIVE_STATES + and fan_mode_lower != FAN_OFF + ) + ) diff --git a/homeassistant/components/homekit/climate_util.py b/homeassistant/components/homekit/climate_util.py new file mode 100644 index 000000000000..92a5bfce06b8 --- /dev/null +++ b/homeassistant/components/homekit/climate_util.py @@ -0,0 +1,205 @@ +"""Shared fan, swing, and temperature helpers for the climate accessory types.""" + +from collections.abc import Iterable +import math +from typing import Any + +from homeassistant.components.climate import ( + ATTR_FAN_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODES, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_ON, + SWING_VERTICAL, +) +from homeassistant.core import State +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .util import get_min_max, temperature_to_homekit + +ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH] +PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS) +SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL] +PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER) + +# Minimum gap kept between the low and high set points of a range. +HEAT_COOL_DEADBAND = 5 + + +def _lower_to_original(modes: Iterable[Any]) -> dict[str, str]: + """Map each string mode to its original casing, keyed by the lowercase form.""" + return {mode.lower(): mode for mode in modes if isinstance(mode, str)} + + +def get_fan_modes_and_speeds( + attributes: dict[str, Any], +) -> tuple[dict[str, str], list[str]]: + """Return the fan modes and ordered predefined speeds for a climate entity. + + ``fan_modes`` maps each lowercased fan mode to its original casing. + ``ordered_fan_speeds`` is the subset of predefined speeds the entity + exposes, in HomeKit rotation-speed order; it is empty when the entity only + advertises custom fan mode names. + """ + fan_modes = _lower_to_original(attributes.get(ATTR_FAN_MODES) or []) + ordered_fan_speeds: list[str] = [] + if PRE_DEFINED_FAN_MODES.intersection(fan_modes): + ordered_fan_speeds = [ + speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes + ] + return fan_modes, ordered_fan_speeds + + +def get_swing_on_mode(attributes: dict[str, Any]) -> str | None: + """Return the preferred swing-on mode for a climate entity, if any. + + The match is case insensitive and the entity's original casing is + returned so it can be sent back to the service. Returns ``None`` when the + entity exposes no predefined swing modes. + """ + if not (swing_modes := attributes.get(ATTR_SWING_MODES)): + return None + lower_to_original = _lower_to_original(swing_modes) + return next( + ( + lower_to_original[swing_mode] + for swing_mode in SWING_MODE_PREFERRED_ORDER + if swing_mode in lower_to_original + ), + None, + ) + + +def get_swing_off_mode(attributes: dict[str, Any]) -> str: + """Return the entity's off swing mode, preserving its original casing.""" + swing_modes = attributes.get(ATTR_SWING_MODES) or [] + return _lower_to_original(swing_modes).get(SWING_OFF, SWING_OFF) + + +def has_swing_off_mode(attributes: dict[str, Any]) -> bool: + """Return whether the entity advertises a swing off mode.""" + swing_modes = attributes.get(ATTR_SWING_MODES) or [] + return SWING_OFF in _lower_to_original(swing_modes) + + +def fan_speed_to_mode( + ordered_fan_speeds: list[str], fan_modes: dict[str, str], speed: int +) -> str: + """Return the climate fan mode for a HomeKit rotation speed percentage. + + The percentage is offset by one so the lowest slider step maps to the + first ordered speed. + """ + speed_key = percentage_to_ordered_list_item(ordered_fan_speeds, speed - 1) + return fan_modes[speed_key] + + +def fan_mode_to_speed(ordered_fan_speeds: list[str], fan_mode: Any) -> int | None: + """Return the HomeKit rotation speed percentage for a climate fan mode. + + Returns ``None`` when the mode is not one of the ordered predefined speeds. + """ + if ( + not isinstance(fan_mode, str) + or (fan_mode_lower := fan_mode.lower()) not in ordered_fan_speeds + ): + return None + return ordered_list_item_to_percentage(ordered_fan_speeds, fan_mode_lower) + + +def is_swing_on(swing_mode: Any) -> bool: + """Return whether a climate swing mode maps to HomeKit swing on.""" + return isinstance(swing_mode, str) and swing_mode.lower() in PRE_DEFINED_SWING_MODES + + +def get_temperature_range_from_state( + state: State, unit: str, default_min: float, default_max: float +) -> tuple[float, float]: + """Return the HomeKit min and max temperature range for a climate state. + + Attribute values are in the entity's unit and converted to Celsius; the + defaults are already Celsius and used as-is. The minimum is clamped to zero + because the Home app crashes on negative bounds. + """ + if (min_temp := state.attributes.get(ATTR_MIN_TEMP)) is not None: + min_temp = temperature_to_homekit(min_temp, unit) + else: + min_temp = default_min + + if (max_temp := state.attributes.get(ATTR_MAX_TEMP)) is not None: + max_temp = temperature_to_homekit(max_temp, unit) + else: + max_temp = default_max + + # Handle a reversed temperature range + min_temp, max_temp = get_min_max(min_temp, max_temp) + + # Round inward to the characteristic's 0.1 step so the slider cannot + # produce a write the entity's own limit validation rejects; a range + # too narrow to hold a step keeps the exact limits rather than + # expanding beyond them. + rounded_min = math.ceil(min_temp * 10) / 10 + rounded_max = math.floor(max_temp * 10) / 10 + if rounded_min <= rounded_max: + min_temp, max_temp = rounded_min, rounded_max + + min_temp = max(min_temp, 0) + max_temp = max(max_temp, min_temp) + + return min_temp, max_temp + + +def temperature_attribute_to_homekit(state: State, key: str, unit: str) -> float | None: + """Return a numeric temperature attribute converted to the HomeKit unit.""" + value = state.attributes.get(key) + if isinstance(value, (int, float)): + return temperature_to_homekit(value, unit) + return None + + +def resolve_target_temp_range( + current_high: float, + current_low: float, + new_high: float | None, + new_low: float | None, + min_temp: float, + max_temp: float, +) -> tuple[float, float]: + """Return an ordered (high, low) target range within the temperature bounds. + + The unchanged side keeps its current value and a deadband is enforced so + the range is never inverted. + """ + high = current_high + low = current_low + deadband_enforced = False + if new_high is not None: + high = new_high + if high < low: + low = high - HEAT_COOL_DEADBAND + deadband_enforced = True + if new_low is not None: + low = new_low + if low > high: + high = low + HEAT_COOL_DEADBAND + deadband_enforced = True + high = min(high, max_temp) + low = max(low, min_temp) + # Clamping a deadband-adjusted setpoint to a bound can erase the gap it just + # enforced; restore it by moving the setpoint that is not pinned to the bound. + if deadband_enforced and high - low < HEAT_COOL_DEADBAND: + if high >= max_temp: + low = max(min_temp, high - HEAT_COOL_DEADBAND) + else: + high = min(max_temp, low + HEAT_COOL_DEADBAND) + return high, low diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 0d3294a1365e..fe50616a732b 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import device_automation from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN @@ -31,6 +32,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_NAME, CONF_PORT, + CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.helpers import ( @@ -55,14 +57,24 @@ from .const import ( HOMEKIT_MODE_BRIDGE, HOMEKIT_MODES, SHORT_BRIDGE_NAME, + TYPE_HEATER_COOLER, + TYPE_THERMOSTAT, VIDEO_CODEC_COPY, ) +from .models import HomeKitEntryData from .util import async_find_next_available_port, state_needs_accessory_mode CONF_CAMERA_AUDIO = "camera_audio" CONF_CAMERA_COPY = "camera_copy" CONF_INCLUDE_EXCLUDE_MODE = "include_exclude_mode" +CLIMATE_TYPE_AUTOMATIC = "automatic" +# Display names for the accessory classes a climate entity can use +CLIMATE_ACCESSORY_NAMES = { + "Thermostat": "Thermostat", + "HeaterCooler": "Heater Cooler", +} + MODE_INCLUDE = "include" MODE_EXCLUDE = "exclude" @@ -179,14 +191,29 @@ def _async_build_entities_filter( ) -def _async_cameras_from_entities(entities: list[str]) -> list[str]: +def _async_entities_in_domain(entities: list[str], domain: str) -> list[str]: return [ - entity_id - for entity_id in entities - if entity_id.startswith(CAMERA_ENTITY_PREFIX) + entity_id for entity_id in entities if split_entity_id(entity_id)[0] == domain ] +@callback +def _async_included_domain_entities( + hass: HomeAssistant, + entity_filter: EntityFilterDict, + entities: list[str], + domain: str, +) -> list[str]: + """Return a domain's included entities, expanding a whole domain include. + + The whole domain is included when none of its entities are selected + explicitly. + """ + if domain in entity_filter[CONF_INCLUDE_DOMAINS]: + return _async_get_matching_entities(hass, [domain]) + return _async_entities_in_domain(entities, domain) + + async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]: """Create a mapping of types of devices/entities HomeKit can support.""" integrations = await async_get_integrations(hass, SUPPORTED_DOMAINS) @@ -374,6 +401,97 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.hk_options: dict[str, Any] = {} self.included_cameras: list[str] = [] + self.included_climates: list[str] = [] + # Maps the displayed climate field label back to its entity id. + self._climate_choices: dict[str, str] = {} + + async def async_step_climate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose the accessory type for climate entities.""" + if not self.included_climates: + return await self.async_step_bridged_device_triggers() + + hk_options = self.hk_options + all_entity_config: dict[str, dict[str, Any]] + + if user_input is not None: + all_entity_config = hk_options[CONF_ENTITY_CONFIG] + for label, entity_id in self._climate_choices.items(): + entity_config = all_entity_config.setdefault(entity_id, {}) + + if (choice := user_input[label]) == CLIMATE_TYPE_AUTOMATIC: + entity_config.pop(CONF_TYPE, None) + else: + entity_config[CONF_TYPE] = choice + + if not entity_config: + all_entity_config.pop(entity_id) + + if not all_entity_config: + del hk_options[CONF_ENTITY_CONFIG] + + return await self.async_step_bridged_device_triggers() + + # Field labels come from the schema keys, so key the form by the + # friendly name and map back to the entity id on submit. The + # accessory a bridged entity currently uses is shown so Automatic + # is not a mystery. + current_accessories = self._async_current_climate_accessories() + self._climate_choices = {} + for entity_id in self.included_climates: + state = self.hass.states.get(entity_id) + label = f"{state.name} ({entity_id})" if state else entity_id + if current := current_accessories.get(entity_id): + label = f"{label} [{current}]" + self._climate_choices[label] = entity_id + + all_entity_config = hk_options.setdefault(CONF_ENTITY_CONFIG, {}) + type_selector = selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + CLIMATE_TYPE_AUTOMATIC, + TYPE_THERMOSTAT, + TYPE_HEATER_COOLER, + ], + translation_key="climate_accessory_type", + ) + ) + data_schema = vol.Schema( + { + vol.Required( + label, + default=all_entity_config.get(entity_id, {}).get( + CONF_TYPE, CLIMATE_TYPE_AUTOMATIC + ), + ): type_selector + for label, entity_id in self._climate_choices.items() + } + ) + return self.async_show_form(step_id="climate", data_schema=data_schema) + + @callback + def _async_current_climate_accessories(self) -> dict[str, str]: + """Map bridged climate entities to their current accessory name.""" + entry_data: HomeKitEntryData | None = getattr( + self.config_entry, "runtime_data", None + ) + if entry_data is None: + return {} + homekit = entry_data.homekit + accessories: Iterable[Any] + if homekit.bridge is not None: + accessories = homekit.bridge.accessories.values() + elif homekit.driver is not None and homekit.driver.accessory is not None: + accessories = [homekit.driver.accessory] + else: + return {} + return { + entity_id: CLIMATE_ACCESSORY_NAMES[accessory_name] + for accessory in accessories + if (entity_id := getattr(accessory, "entity_id", None)) is not None + and (accessory_name := type(accessory).__name__) in CLIMATE_ACCESSORY_NAMES + } async def async_step_yaml( self, user_input: dict[str, Any] | None = None @@ -426,6 +544,9 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Choose camera config.""" + if not self.included_cameras: + return await self.async_step_climate() + hk_options = self.hk_options all_entity_config: dict[str, dict[str, Any]] @@ -447,7 +568,7 @@ class OptionsFlowHandler(OptionsFlow): if not entity_config: all_entity_config.pop(entity_id) - return await self.async_step_bridged_device_triggers() + return await self.async_step_climate() cameras_with_audio = [] cameras_with_copy = [] @@ -492,11 +613,10 @@ class OptionsFlowHandler(OptionsFlow): if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) entity_filter = _async_build_entities_filter(domains, entities) - self.included_cameras = _async_cameras_from_entities(entities) + self.included_cameras = _async_entities_in_domain(entities, CAMERA_DOMAIN) + self.included_climates = _async_entities_in_domain(entities, CLIMATE_DOMAIN) hk_options[CONF_FILTER] = entity_filter - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + return await self.async_step_cameras() entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) @@ -536,13 +656,17 @@ class OptionsFlowHandler(OptionsFlow): domains = hk_options[CONF_DOMAINS] if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) - self.included_cameras = _async_cameras_from_entities(entities) - hk_options[CONF_FILTER] = _async_build_entities_filter(domains, entities) - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + entity_filter = _async_build_entities_filter(domains, entities) + self.included_cameras = _async_included_domain_entities( + self.hass, entity_filter, entities, CAMERA_DOMAIN + ) + self.included_climates = _async_included_domain_entities( + self.hass, entity_filter, entities, CLIMATE_DOMAIN + ) + hk_options[CONF_FILTER] = entity_filter + return await self.async_step_cameras() - entity_filter: EntityFilterDict = hk_options.get(CONF_FILTER, {}) + entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) all_supported_entities = _async_get_matching_entities( self.hass, domains, include_entity_category=True, include_hidden=True @@ -579,23 +703,23 @@ class OptionsFlowHandler(OptionsFlow): domains = hk_options[CONF_DOMAINS] if user_input is not None: - self.included_cameras = [] entities = cv.ensure_list(user_input[CONF_ENTITIES]) - if CAMERA_DOMAIN in domains: - camera_entities = _async_get_matching_entities( - self.hass, [CAMERA_DOMAIN] - ) - self.included_cameras = [ + + def _remaining_in_domain(domain: str) -> list[str]: + if domain not in domains: + return [] + return [ entity_id - for entity_id in camera_entities + for entity_id in _async_get_matching_entities(self.hass, [domain]) if entity_id not in entities ] + + self.included_cameras = _remaining_in_domain(CAMERA_DOMAIN) + self.included_climates = _remaining_in_domain(CLIMATE_DOMAIN) hk_options[CONF_FILTER] = _make_entity_filter( include_domains=domains, exclude_entities=entities ) - if self.included_cameras: - return await self.async_step_cameras() - return await self.async_step_bridged_device_triggers() + return await self.async_step_cameras() entity_filter = self.hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 5d5a8efc0e2a..0f69c4350f27 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -128,6 +128,9 @@ TYPE_VALVE = "valve" TYPE_FAN = "fan" TYPE_AIR_PURIFIER = "air_purifier" +TYPE_HEATER_COOLER = "heater_cooler" +TYPE_THERMOSTAT = "thermostat" + # #### Categories #### CATEGORY_RECEIVER = 34 @@ -145,6 +148,7 @@ SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_FILTER_MAINTENANCE = "FilterMaintenance" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" +SERV_HEATER_COOLER = "HeaterCooler" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" SERV_HUMIDITY_SENSOR = "HumiditySensor" SERV_INPUT_SOURCE = "InputSource" @@ -193,6 +197,7 @@ CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState" CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" CHAR_CURRENT_FAN_STATE = "CurrentFanState" +CHAR_CURRENT_HEATER_COOLER_STATE = "CurrentHeaterCoolerState" CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER = "CurrentHumidifierDehumidifierState" CHAR_CURRENT_POSITION = "CurrentPosition" @@ -245,6 +250,7 @@ CHAR_STREAMING_STRATUS = "StreamingStatus" CHAR_SWING_MODE = "SwingMode" CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState" CHAR_TARGET_DOOR_STATE = "TargetDoorState" +CHAR_TARGET_HEATER_COOLER_STATE = "TargetHeaterCoolerState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" CHAR_TARGET_FAN_STATE = "TargetFanState" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 3bb8625cba5d..6c93b3f65356 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -5,7 +5,7 @@ }, "step": { "pairing": { - "description": "To complete pairing follow the instructions in \u201cNotifications\u201d under \u201cHomeKit Pairing\u201d.", + "description": "To complete pairing follow the instructions in “Notifications” under “HomeKit Pairing”.", "title": "Pair HomeKit" }, "user": { @@ -40,18 +40,22 @@ "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single-board computers.", "title": "Camera configuration" }, + "climate": { + "description": "Choose which accessory each climate entity uses in HomeKit. Heater Cooler puts the mode, temperature, and the supported fan speed and swing controls on one air conditioner style tile; Thermostat is the classic temperature dial with a separate fan. Automatic keeps the accessory the entity already uses and picks the best fit when it is bridged for the first time. Changing the accessory type keeps the room and name, but HomeKit scenes or automations that used the old controls may need to be recreated.", + "title": "Climate accessory configuration" + }, "exclude": { "data": { "entities": "[%key:component::homekit::options::step::include::data::entities%]" }, - "description": "All \u201c{domains}\u201d entities will be included except for the excluded entities and categorized entities.", + "description": "All “{domains}” entities will be included except for the excluded entities and categorized entities.", "title": "Select the entities to be excluded" }, "include": { "data": { "entities": "Entities" }, - "description": "Select entities from each domain in \u201c{domains}\u201d. The include will cover the entire domain if you do not select any entities for a given domain.", + "description": "Select entities from each domain in “{domains}”. The include will cover the entire domain if you do not select any entities for a given domain.", "title": "Select the entities to be included" }, "init": { @@ -60,7 +64,7 @@ "include_exclude_mode": "Inclusion mode", "mode": "HomeKit mode" }, - "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", + "description": "HomeKit can be configured to expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the “Domains to include” will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", "title": "Select mode and domains." }, "yaml": { @@ -69,6 +73,15 @@ } } }, + "selector": { + "climate_accessory_type": { + "options": { + "automatic": "Automatic", + "heater_cooler": "Heater Cooler", + "thermostat": "Thermostat" + } + } + }, "services": { "reload": { "description": "Reloads HomeKit and re-processes the YAML-configuration.", diff --git a/homeassistant/components/homekit/type_heater_coolers.py b/homeassistant/components/homekit/type_heater_coolers.py new file mode 100644 index 000000000000..64f726911985 --- /dev/null +++ b/homeassistant/components/homekit/type_heater_coolers.py @@ -0,0 +1,734 @@ +"""Class to hold all heater cooler accessories.""" + +import asyncio +from collections.abc import Callable, Coroutine +import functools +import logging +from typing import Any, Concatenate, NamedTuple, override + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_AIR_CONDITIONER, CATEGORY_HEATER + +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_ON, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES +from homeassistant.core import State, callback +from homeassistant.util.enum import try_parse_enum + +from .accessories import TYPES +from .climate_base import CLIMATE_INACTIVE_STATES, HomeKitClimateAccessory +from .climate_util import temperature_attribute_to_homekit +from .const import ( + CHAR_ACTIVE, + CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_FAN_STATE, + CHAR_CURRENT_HEATER_COOLER_STATE, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_NAME, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_HEATER_COOLER, + SERV_HUMIDITY_SENSOR, +) + +_LOGGER = logging.getLogger(__name__) + +# HomeKit CurrentHeaterCoolerState values (per HomeKit spec) +HC_INACTIVE, HC_IDLE, HC_HEATING, HC_COOLING = range(4) + +# HomeKit TargetHeaterCoolerState valid values: Auto=0, Heat=1, Cool=2 +HC_TARGET_AUTO, HC_TARGET_HEAT, HC_TARGET_COOL = range(3) + +# Off is intentionally not mapped: when the entity is off the target +# characteristic keeps the last active mode so it stays in sync with +# _last_known_mode, which is what turning Active back on restores. +HC_HASS_TO_HOMEKIT_TARGET = { + HVACMode.HEAT: HC_TARGET_HEAT, + HVACMode.COOL: HC_TARGET_COOL, + HVACMode.HEAT_COOL: HC_TARGET_AUTO, + HVACMode.AUTO: HC_TARGET_AUTO, +} + +# HomeKit's CurrentHeaterCoolerState has no drying or fan-only value. Those +# actions map to Cooling rather than Idle so the tile still shows the unit is +# doing something, which also matches the Thermostat's action mapping. +HC_HASS_TO_HOMEKIT_ACTION = { + HVACAction.OFF: HC_INACTIVE, + HVACAction.IDLE: HC_IDLE, + HVACAction.HEATING: HC_HEATING, + HVACAction.PREHEATING: HC_HEATING, + HVACAction.COOLING: HC_COOLING, + HVACAction.DRYING: HC_COOLING, + HVACAction.FAN: HC_COOLING, + HVACAction.DEFROSTING: HC_HEATING, +} + +# Hysteresis band in Celsius used when the entity omits hvac_action +ACTION_HYSTERESIS = 0.25 + + +class ClimateServiceCall(NamedTuple): + """A queued climate write and the modes to apply once accepted.""" + + service: str + data: dict[str, Any] + # Remembered as the Active on restore target + commit_mode: HVACMode | None = None + # Bridges resolution until the entity reports a mode change + pending_mode: HVACMode | None = None + + +def _locked_write[**_P]( + func: Callable[Concatenate[HeaterCooler, _P], Coroutine[Any, Any, None]], +) -> Callable[Concatenate[HeaterCooler, _P], Coroutine[Any, Any, None]]: + """Run the write coroutine under the accessory's write lock.""" + + @functools.wraps(func) + async def _wrapper(self: HeaterCooler, *args: _P.args, **kwargs: _P.kwargs) -> None: + async with self._write_lock: + await func(self, *args, **kwargs) + + return _wrapper + + +# Modes that drive both a heating and a cooling threshold +RANGE_MODES = (HVACMode.HEAT_COOL, HVACMode.AUTO) + + +@TYPES.register("HeaterCooler") +class HeaterCooler(HomeKitClimateAccessory): + """Generate a HeaterCooler accessory for a climate entity.""" + + # Configured only when the entity accepts a target temperature. + char_cool: Characteristic + char_heat: Characteristic + + # Configured only when the entity reports a current humidity. + char_current_humidity: Characteristic + + def __init__(self, *args: Any) -> None: + """Initialize a HeaterCooler accessory object.""" + super().__init__(*args) + + state = self.hass.states.get(self.entity_id) + assert state + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # The thresholds double as the setpoints, so only expose them when the + # entity accepts a target temperature; a fan/dry-only entity otherwise + # gets sliders that dispatch set_temperature it cannot honor. + has_thresholds = bool( + features + & ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + ) + + hvac_modes = attributes.get(ATTR_HVAC_MODES, []) + current_mode = try_parse_enum(HVACMode, state.state) + + self._supports_off = HVACMode.OFF in hvac_modes + supports_auto = HVACMode.AUTO in hvac_modes or current_mode == HVACMode.AUTO + supports_heat_cool = ( + HVACMode.HEAT_COOL in hvac_modes or current_mode == HVACMode.HEAT_COOL + ) + + can_cool = HVACMode.COOL in hvac_modes or supports_auto or supports_heat_cool + can_heat = HVACMode.HEAT in hvac_modes or supports_auto or supports_heat_cool + + # Standalone pairings advertise the category in the QR code and mDNS + # metadata, so pick the one matching the device instead of Thermostat; + # only a heat only device is a heater, everything else including the + # fan or dry only case is an air conditioner. + self.category = ( + CATEGORY_HEATER if can_heat and not can_cool else CATEGORY_AIR_CONDITIONER + ) + + # Per the HomeKit spec a heater must include the heating threshold and a + # cooler the cooling one, so a one sided device gets a single slider. A + # device with neither side (e.g. dry only with a setpoint) keeps both, + # and a range only device always needs both sides in a write, so the + # setpoints stay controllable. + if (not can_cool and not can_heat) or not ( + features & ClimateEntityFeature.TARGET_TEMPERATURE + ): + can_cool = can_heat = True + self._has_cool_threshold = has_thresholds and can_cool + self._has_heat_threshold = has_thresholds and can_heat + + # Only expose the targets the entity actually supports so HomeKit does + # not offer a mode the climate service would reject. Auto is backed by + # a range mode, preferring HEAT_COOL whose thresholds stay adjustable + # over AUTO, which may follow a schedule, like the thermostat does. + self._hk_to_ha_target: dict[int, HVACMode] = {} + if HVACMode.HEAT in hvac_modes: + self._hk_to_ha_target[HC_TARGET_HEAT] = HVACMode.HEAT + if HVACMode.COOL in hvac_modes: + self._hk_to_ha_target[HC_TARGET_COOL] = HVACMode.COOL + if supports_heat_cool: + self._hk_to_ha_target[HC_TARGET_AUTO] = HVACMode.HEAT_COOL + elif supports_auto: + self._hk_to_ha_target[HC_TARGET_AUTO] = HVACMode.AUTO + if not self._hk_to_ha_target: + # Entities exposing neither heat, cool, nor a range mode (e.g. + # fan-only) still need a valid target; map Auto to the first mode the + # entity actually supports so the control does something. A degenerate + # off-only entity has no active mode, so fall back to off rather than + # an unsupported Auto. + fallback_mode = next( + (mode for mode in hvac_modes if mode != HVACMode.OFF), HVACMode.OFF + ) + self._hk_to_ha_target[HC_TARGET_AUTO] = fallback_mode + + chars = [ + CHAR_ACTIVE, + CHAR_CURRENT_HEATER_COOLER_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + CHAR_CURRENT_TEMPERATURE, + ] + if self._has_cool_threshold: + chars.append(CHAR_COOLING_THRESHOLD_TEMPERATURE) + if self._has_heat_threshold: + chars.append(CHAR_HEATING_THRESHOLD_TEMPERATURE) + + # The HeaterCooler service has no auto fan control, so when the entity + # exposes an auto fan mode (and a manual mode to switch back to) the fan + # is exposed through a full linked fan service instead; the rotation + # speed then lives there, since per the HomeKit spec it only belongs on + # the HeaterCooler when the fan cannot be independently controlled. + if FAN_AUTO in self.fan_modes and ( + FAN_ON in self.fan_modes or self.ordered_fan_speeds + ): + self.fan_chars.append(CHAR_TARGET_FAN_STATE) + if self.ordered_fan_speeds: + self.fan_chars.append(CHAR_ROTATION_SPEED) + if attributes.get(ATTR_HVAC_ACTION) is not None: + self.fan_chars.append(CHAR_CURRENT_FAN_STATE) + + # Fan/swing modes are detected in the base class; only advertise the + # characteristics when the entity exposes predefined modes. + if self.ordered_fan_speeds and not self.fan_chars: + chars.append(CHAR_ROTATION_SPEED) + if self.swing_on_mode is not None: + chars.append(CHAR_SWING_MODE) + + serv = self.add_preload_service(SERV_HEATER_COOLER, chars) + + self.char_active = serv.configure_char(CHAR_ACTIVE, value=0) + self.char_current_state = serv.configure_char( + CHAR_CURRENT_HEATER_COOLER_STATE, value=HC_INACTIVE + ) + # Also the reverse lookup for modes only selectable through the + # Auto fallback, so every selectable mode is representable. + self._ha_to_hk_target = { + ha_mode: hk_state for hk_state, ha_mode in self._hk_to_ha_target.items() + } + if HC_TARGET_AUTO in self._hk_to_ha_target: + default_target = HC_TARGET_AUTO + else: + default_target = next(iter(self._hk_to_ha_target)) + self.char_target_state = self._configure_target_mode_char( + serv, + CHAR_TARGET_HEATER_COOLER_STATE, + default_target, + self._ha_to_hk_target, + ) + self._configure_current_temperature_char(serv) + + if self._has_cool_threshold or self._has_heat_threshold: + min_temp_hk, max_temp_hk = self.get_temperature_range(state) + temp_properties = { + PROP_MIN_VALUE: min_temp_hk, + PROP_MAX_VALUE: max_temp_hk, + # We do not set PROP_MIN_STEP here and instead use the HomeKit + # default of 0.1 in order to have enough precision to convert + # temperature units and avoid setting 73F resulting in 74F + } + # Placeholder value within the configured range; async_update_state + # overwrites it from the entity immediately. + default_temp = min(max(21.0, min_temp_hk), max_temp_hk) + if self._has_cool_threshold: + self.char_cool = serv.configure_char( + CHAR_COOLING_THRESHOLD_TEMPERATURE, + value=default_temp, + properties=temp_properties, + ) + if self._has_heat_threshold: + self.char_heat = serv.configure_char( + CHAR_HEATING_THRESHOLD_TEMPERATURE, + value=default_temp, + properties=temp_properties, + ) + + if self.ordered_fan_speeds and not self.fan_chars: + self.char_speed = serv.configure_char( + CHAR_ROTATION_SPEED, + value=100, + properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)}, + ) + if self.swing_on_mode is not None: + self.char_swing = serv.configure_char(CHAR_SWING_MODE, value=0) + + if self.fan_chars: + self._configure_fan_service(serv) + + # The Heater Cooler service has no humidity characteristic, so surface a + # reported current humidity through a linked humidity sensor. Like the + # Thermostat, this is decided once at setup and not a reload attribute: + # current humidity changes on every update, so reloading on it would + # thrash the accessory. + self._has_humidity = ATTR_CURRENT_HUMIDITY in attributes + if self._has_humidity: + humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME) + serv.add_linked_service(humidity_serv) + humidity_serv.configure_char( + CHAR_NAME, value=f"{self.display_name} Humidity" + ) + self.char_current_humidity = humidity_serv.configure_char( + CHAR_CURRENT_HUMIDITY, value=50 + ) + + # Every service exists now, so they all get an explicit primary + # flag; without one the Home app can pick its own tile service. + self.set_primary_service(serv) + + # Fall back to the displayed target mode so turning Active on for a device + # that was off at startup activates the mode HomeKit is showing rather than + # an arbitrary one. + # Modes without a HomeKit target representation, like dry or fan + # only, are not remembered so turning Active on brings back the + # mode the tile is showing instead of one it cannot display. + self._last_known_mode: HVACMode + if current_mode and self._hk_target_mode(current_mode) is not None: + self._last_known_mode = current_mode + else: + self._last_known_mode = self._hk_to_ha_target[default_target] + + self._write_lock = asyncio.Lock() + # A mode the entity accepted but does not report yet; push + # integrations can return from the service before their state + # callback arrives. + self._pending_mode: HVACMode | None = None + self._last_reported_mode = current_mode + + self.async_update_state(state) + + # A single service-level callback batches every characteristic write. + serv.setter_callback = self._set_chars + + def _set_chars(self, char_values: dict[str, Any]) -> None: + """Handle writes to multiple HeaterCooler characteristics at once.""" + _LOGGER.debug("HeaterCooler _set_chars: %s", char_values) + # Batches are resolved and applied under the accessory lock so a + # batch sees the outcome of the one before it and cannot overtake + # or interleave with it. + self.hass.async_create_task( + self._async_apply_batch(char_values), eager_start=True + ) + + @_locked_write + async def _async_apply_batch(self, char_values: dict[str, Any]) -> None: + """Resolve one characteristic batch and apply its writes in order. + + A failed write aborts the rest of the batch, since the tile was + already re-synced and later writes would target a mode the entity + refused to enter. + """ + service_calls: list[ClimateServiceCall] = [] + current_state = self.hass.states.get(self.entity_id) + active = char_values.get(CHAR_ACTIVE) + + # A mode written in the batch wins over the entity state, which + # still holds the pre-change mode. + requested_mode: HVACMode | None = None + if ( + target_mode := char_values.get(CHAR_TARGET_HEATER_COOLER_STATE) + ) is not None: + requested_mode = self._hk_to_ha_target.get(target_mode) + elif active == 1: + # Turning on activates the last known mode, so setpoints in + # the same batch resolve against it instead of the off state. + requested_mode = self._last_known_mode + + # Active/mode changes are handled first as they gate the others. + if self._handle_active_mode_changes( + active, target_mode, service_calls, current_state, requested_mode + ): + # A just accepted mode stays effective for the setpoints until + # the entity reports it, so a following batch does not resolve + # against the pre-switch state. + self._handle_temperature_changes( + char_values, + service_calls, + current_state, + requested_mode or self._pending_mode, + ) + # Fan and swing are queued last so they follow the mode switch. + self._queue_fan_swing_changes(char_values, service_calls) + + for call in service_calls: + reported_mode = self._last_reported_mode + known_mode = self._last_known_mode + if not await self.async_call_service_and_wait( + CLIMATE_DOMAIN, + call.service, + {ATTR_ENTITY_ID: self.entity_id, **call.data}, + ): + return + # A state callback during the blocking call is fresher than the + # queued values, so each one only applies when its counterpart + # was not updated while awaiting. The remembered mode mirrors + # the accepted target, so a rejected mode is not restored later. + if call.pending_mode and self._last_reported_mode == reported_mode: + self._pending_mode = call.pending_mode + if call.commit_mode and self._last_known_mode == known_mode: + self._last_known_mode = call.commit_mode + + @override + def _dispatch_climate_write(self, service: str, params: dict[str, Any]) -> None: + """Serialize the write behind any batch still being applied.""" + self.hass.async_create_task( + self._async_apply_locked_write(service, params), eager_start=True + ) + + @_locked_write + async def _async_apply_locked_write( + self, service: str, params: dict[str, Any] + ) -> None: + """Await one write under the accessory lock.""" + await self.async_call_service_and_wait( + CLIMATE_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id, **params} + ) + + def _queue_fan_swing_changes( + self, + char_values: dict[str, Any], + service_calls: list[ClimateServiceCall], + ) -> None: + """Queue fan speed and swing mode changes.""" + if ( + CHAR_ROTATION_SPEED in char_values + and (params := self._fan_speed_params(char_values[CHAR_ROTATION_SPEED])) + is not None + ): + service_calls.append(ClimateServiceCall(SERVICE_SET_FAN_MODE, params)) + if ( + CHAR_SWING_MODE in char_values + and (params := self._swing_mode_params(char_values[CHAR_SWING_MODE])) + is not None + ): + service_calls.append(ClimateServiceCall(SERVICE_SET_SWING_MODE, params)) + + def _handle_active_mode_changes( + self, + active: int | None, + target_mode: int | None, + service_calls: list[ClimateServiceCall], + current_state: State | None, + requested_mode: HVACMode | None, + ) -> bool: + """Handle active and mode changes. + + Returns False when an off write terminates the batch; a rejected + off leaves the entity running, so the rest still applies. + """ + if target_mode is not None and requested_mode is None: + # The write already changed the characteristic to a target the + # entity cannot enter, so put it back on the last mode. + if (restore := self._hk_target_mode(self._last_known_mode)) is not None: + self._reject_char_write(self.char_target_state, restore) + + if active == 0: + # climate.turn_off raises for entities without an OFF mode; set the + # OFF mode directly and only when it is supported, like the thermostat. + if self._supports_off: + # A target bundled with off is already on the tile rather + # than sent, so it is committed with the accepted off write + # and restored by the next Active on. + service_calls.append( + ClimateServiceCall( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.OFF}, + commit_mode=requested_mode, + pending_mode=HVACMode.OFF, + ) + ) + return False + _LOGGER.debug( + "%s: Ignoring off request; entity has no off mode", + self.entity_id, + ) + # The write already flipped the characteristic; flip it back so + # HomeKit keeps showing the unit as on, and let the rest of the + # batch apply since the entity keeps running. + self._reject_char_write(self.char_active, 1) + if requested_mode and ( + target_mode is not None + or current_state is None + or self._pending_mode == HVACMode.OFF + or current_state.state in CLIMATE_INACTIVE_STATES + ): + # An explicit target always goes out; Active on sends the last + # known mode only when the entity is not already running, where + # a pending off write means it is about to stop running. + service_calls.append( + ClimateServiceCall( + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: requested_mode}, + commit_mode=requested_mode, + pending_mode=requested_mode, + ) + ) + return True + + def _handle_temperature_changes( + self, + char_values: dict[str, Any], + service_calls: list[ClimateServiceCall], + current_state: State | None, + requested_mode: HVACMode | None, + ) -> None: + """Handle temperature changes.""" + cooling_temp = char_values.get(CHAR_COOLING_THRESHOLD_TEMPERATURE) + heating_temp = char_values.get(CHAR_HEATING_THRESHOLD_TEMPERATURE) + + if cooling_temp is None and heating_temp is None: + return + + # Entities that support both single and range targets publish the + # range keys even when they are unset, so the effective mode decides + # between a range and a single setpoint write; entities that only + # take a range always get one. + attributes = current_state.attributes if current_state else {} + effective_mode: HVACMode | str | None = requested_mode or ( + current_state.state if current_state else None + ) + use_range = ( + self._has_cool_threshold + and self._has_heat_threshold + and ( + ATTR_TARGET_TEMP_HIGH in attributes + or ATTR_TARGET_TEMP_LOW in attributes + ) + and (effective_mode in RANGE_MODES or ATTR_TEMPERATURE not in attributes) + ) + + if use_range: + service_calls.append( + ClimateServiceCall( + SERVICE_SET_TEMPERATURE, + self._dual_setpoint_params( + self.char_cool, self.char_heat, cooling_temp, heating_temp + ), + ) + ) + else: + self._handle_single_temp_changes( + service_calls, cooling_temp, heating_temp, current_state, effective_mode + ) + + def _handle_single_temp_changes( + self, + service_calls: list[ClimateServiceCall], + cooling_temp: float | None, + heating_temp: float | None, + current_state: State | None, + effective_mode: HVACMode | str | None, + ) -> None: + """Handle temperature changes for single-temperature entities.""" + if not current_state: + return + + # For a single setpoint the effective mode decides which threshold is + # the setpoint; Cool uses the cooling side and Heat the heating side, + # so a write to the other side is ignored. Range and other modes fall + # back to whichever threshold moved, and Auto picks the one furthest + # from the current setpoint. + selected_temp = None + if effective_mode == HVACMode.COOL: + selected_temp = cooling_temp + elif effective_mode == HVACMode.HEAT: + selected_temp = heating_temp + elif ( + effective_mode in RANGE_MODES + and cooling_temp is not None + and heating_temp is not None + ): + # Pick whichever threshold moved further from the entity's existing + # target setpoint. The thresholds are in HomeKit units, so convert + # the target setpoint before comparing. + target_temp = current_state.attributes.get(ATTR_TEMPERATURE) + if target_temp is None: + selected_temp = heating_temp + else: + target_temp_hk = self._temperature_to_homekit(target_temp) + if abs(cooling_temp - target_temp_hk) > abs( + heating_temp - target_temp_hk + ): + selected_temp = cooling_temp + else: + selected_temp = heating_temp + elif cooling_temp is not None: + selected_temp = cooling_temp + elif heating_temp is not None: + selected_temp = heating_temp + + if selected_temp is not None: + ha_temp = self._temperature_to_states(selected_temp) + service_calls.append( + ClimateServiceCall(SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: ha_temp}) + ) + + def _hk_target_mode(self, mode: HVACMode) -> int | None: + """Map HA hvac_mode to a HomeKit target heater-cooler state.""" + # HomeKit's HeaterCooler target only has Auto/Heat/Cool, so modes like + # dry and fan_only have no representation; they are intentionally + # collapsed to the Auto target (see the fallback in __init__) and cannot + # be selected or reflected individually from the Home app. + hk_value = HC_HASS_TO_HOMEKIT_TARGET.get(mode) + if hk_value is not None and hk_value in self._hk_to_ha_target: + return hk_value + return self._ha_to_hk_target.get(mode) + + @callback + @override + def async_update_state(self, new_state: State) -> None: + """Update state without rechecking the device features.""" + attributes = new_state.attributes + current_mode = try_parse_enum(HVACMode, new_state.state) + if current_mode is not None: + if current_mode != self._last_reported_mode: + # The entity moved to a new mode, so its state is + # authoritative again; re-reports of the pre-switch mode, + # like attribute updates mid transition, keep the bridge. + self._pending_mode = None + self._last_reported_mode = current_mode + # While a write is pending, the accepted mode stays the displayed + # and restore target so a stale re-report cannot flip the tile back. + display_mode = self._pending_mode or current_mode + if display_mode and (tgt := self._hk_target_mode(display_mode)) is not None: + self._last_known_mode = display_mode + self.char_target_state.set_value(tgt) + + if new_state.state in CLIMATE_INACTIVE_STATES: + # An off or unavailable entity is inactive, not idle. + self.char_active.set_value(0) + self.char_current_state.set_value(HC_INACTIVE) + else: + self.char_active.set_value(1) + action = attributes.get(ATTR_HVAC_ACTION) or self._derive_action( + new_state, current_mode + ) + self.char_current_state.set_value( + HC_HASS_TO_HOMEKIT_ACTION.get(action, HC_INACTIVE) + ) + + self._update_current_temperature_char(new_state) + self._update_temperature_thresholds(new_state) + if self._has_humidity and isinstance( + (humidity := attributes.get(ATTR_CURRENT_HUMIDITY)), (int, float) + ): + self.char_current_humidity.set_value(humidity) + if self.fan_chars: + self._async_update_fan_service(new_state) + else: + # The base char updaters no-op when the entity exposes no fan/swing. + self._update_fan_speed_char(attributes) + self._update_swing_char(attributes) + + def _update_temperature_thresholds(self, state: State) -> None: + """Update HomeKit temperature thresholds based on HA state.""" + if not self._has_cool_threshold and not self._has_heat_threshold: + return + attributes = state.attributes + # Dual capable entities publish the range keys even in single + # setpoint modes, so only values decide what is displayed. + supports_dual_temp = ( + attributes.get(ATTR_TARGET_TEMP_HIGH) is not None + or attributes.get(ATTR_TARGET_TEMP_LOW) is not None + ) + + if supports_dual_temp: + if self._has_cool_threshold: + self._update_temperature_char( + self.char_cool, state, ATTR_TARGET_TEMP_HIGH + ) + if self._has_heat_threshold: + self._update_temperature_char( + self.char_heat, state, ATTR_TARGET_TEMP_LOW + ) + elif ( + target_temp := temperature_attribute_to_homekit( + state, ATTR_TEMPERATURE, self._unit + ) + ) is not None: + if self._has_cool_threshold: + self.char_cool.set_value(target_temp) + if self._has_heat_threshold: + self.char_heat.set_value(target_temp) + + def _derive_action(self, state: State, mode: HVACMode | None) -> HVACAction: + """Infer heating / cooling when integration omits hvac_action.""" + attributes = state.attributes + cur = attributes.get(ATTR_CURRENT_TEMPERATURE) + if cur is None or mode is None: + return HVACAction.IDLE + + # Resolve the cool-above and heat-below setpoints for the active mode. + # Range modes have independent thresholds; single-target modes only + # drive one side. Any other mode (e.g. dry, fan_only) stays idle. + if mode in RANGE_MODES: + cool_above = attributes.get(ATTR_TARGET_TEMP_HIGH) + heat_below = attributes.get(ATTR_TARGET_TEMP_LOW) + if cool_above is None and heat_below is None: + # Some integrations run auto from a single setpoint. + cool_above = heat_below = attributes.get(ATTR_TEMPERATURE) + elif mode == HVACMode.COOL: + cool_above = attributes.get(ATTR_TEMPERATURE) + heat_below = None + elif mode == HVACMode.HEAT: + cool_above = None + heat_below = attributes.get(ATTR_TEMPERATURE) + else: + return HVACAction.IDLE + + # Compare in Celsius so the hysteresis band is unit independent. + cur_c = self._temperature_to_homekit(cur) + if ( + cool_above is not None + and cur_c > self._temperature_to_homekit(cool_above) + ACTION_HYSTERESIS + ): + return HVACAction.COOLING + if ( + heat_below is not None + and cur_c < self._temperature_to_homekit(heat_below) - ACTION_HYSTERESIS + ): + return HVACAction.HEATING + return HVACAction.IDLE diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index a0d9830de4f0..a9b2d45d6e87 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -556,6 +556,6 @@ class SelectSwitch(HomeAccessory): @override def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" - current_option = cleanup_name_for_homekit(new_state.state) + current_option = new_state.state for option, char in self.select_chars.items(): char.set_value(option == current_option) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 95d2da4d942d..72352dde87b0 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -8,8 +8,6 @@ from pyhap.const import CATEGORY_THERMOSTAT from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, - ATTR_FAN_MODE, - ATTR_FAN_MODES, ATTR_HUMIDITY, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, @@ -18,32 +16,16 @@ from homeassistant.components.climate import ( ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, - ATTR_SWING_MODE, - ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_HUMIDITY, - DEFAULT_MAX_TEMP, DEFAULT_MIN_HUMIDITY, - DEFAULT_MIN_TEMP, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - FAN_MIDDLE, - FAN_OFF, FAN_ON, - SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT, - SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_ON, - SWING_VERTICAL, ClimateEntityFeature, HVACAction, HVACMode, @@ -70,14 +52,14 @@ from homeassistant.const import ( ) from homeassistant.core import State, callback from homeassistant.util.enum import try_parse_enum -from homeassistant.util.percentage import ( - ordered_list_item_to_percentage, - percentage_to_ordered_list_item, -) from .accessories import TYPES, HomeAccessory +from .climate_base import HomeKitClimateAccessory +from .climate_util import ( + get_temperature_range_from_state, + temperature_attribute_to_homekit, +) from .const import ( - CHAR_ACTIVE, CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_FAN_STATE, CHAR_CURRENT_HEATING_COOLING, @@ -94,12 +76,10 @@ from .const import ( DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, PROP_MAX_VALUE, - PROP_MIN_STEP, PROP_MIN_VALUE, - SERV_FANV2, SERV_THERMOSTAT, ) -from .util import get_min_max, temperature_to_homekit, temperature_to_states +from .util import get_min_max, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -132,11 +112,6 @@ HC_HEAT_COOL_PREFER_COOL = [ HC_HEAT_COOL_OFF, ] -ORDERED_FAN_SPEEDS = [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH] -PRE_DEFINED_FAN_MODES = set(ORDERED_FAN_SPEEDS) -SWING_MODE_PREFERRED_ORDER = [SWING_ON, SWING_BOTH, SWING_HORIZONTAL, SWING_VERTICAL] -PRE_DEFINED_SWING_MODES = set(SWING_MODE_PREFERRED_ORDER) - HC_MIN_TEMP = 10 HC_MAX_TEMP = 38 @@ -163,23 +138,6 @@ HC_HASS_TO_HOMEKIT_ACTION = { HVACAction.DEFROSTING: HC_HEAT_COOL_HEAT, } -FAN_STATE_INACTIVE = 0 -FAN_STATE_IDLE = 1 -FAN_STATE_ACTIVE = 2 - -HC_HASS_TO_HOMEKIT_FAN_STATE = { - HVACAction.OFF: FAN_STATE_INACTIVE, - HVACAction.IDLE: FAN_STATE_IDLE, - HVACAction.HEATING: FAN_STATE_ACTIVE, - HVACAction.COOLING: FAN_STATE_ACTIVE, - HVACAction.DRYING: FAN_STATE_ACTIVE, - HVACAction.FAN: FAN_STATE_ACTIVE, - HVACAction.PREHEATING: FAN_STATE_IDLE, - HVACAction.DEFROSTING: FAN_STATE_IDLE, -} - -HEAT_COOL_DEADBAND = 5 - def _hk_hvac_mode_from_state(state: State) -> int | None: """Return the equivalent HomeKit HVAC mode for a given state.""" @@ -194,29 +152,20 @@ def _hk_hvac_mode_from_state(state: State) -> int | None: @TYPES.register("Thermostat") -class Thermostat(HomeAccessory): +class Thermostat(HomeKitClimateAccessory): """Generate a Thermostat accessory for a climate.""" def __init__(self, *args: Any) -> None: """Initialize a Thermostat accessory object.""" - super().__init__(*args, category=CATEGORY_THERMOSTAT) - self._unit = self.hass.config.units.temperature_unit + super().__init__(*args) state = self.hass.states.get(self.entity_id) assert state hc_min_temp, hc_max_temp = self.get_temperature_range(state) - self._reload_on_change_attrs.extend( - ( - ATTR_MIN_HUMIDITY, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, - ATTR_FAN_MODES, - ATTR_HVAC_MODES, - ) - ) + # The common climate reload attributes are added by the base class. + self._reload_on_change_attrs.append(ATTR_MIN_HUMIDITY) # Add additional characteristics if auto mode is supported self.chars: list[str] = [] - self.fan_chars: list[str] = [] attributes = state.attributes min_humidity, _ = get_min_max( @@ -240,7 +189,6 @@ class Thermostat(HomeAccessory): self.chars.append(CHAR_TARGET_HUMIDITY) serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars) - self.set_primary_service(serv_thermostat) # Current mode characteristics self.char_current_heat_cool = serv_thermostat.configure_char( @@ -248,22 +196,14 @@ class Thermostat(HomeAccessory): ) self._configure_hvac_modes(state) - # Must set the value first as setting - # valid_values happens before setting - # the value and if 0 is not a valid - # value this will throw - self.char_target_heat_cool = serv_thermostat.configure_char( - CHAR_TARGET_HEATING_COOLING, value=list(self.hc_homekit_to_hass)[0] + self.char_target_heat_cool = self._configure_target_mode_char( + serv_thermostat, + CHAR_TARGET_HEATING_COOLING, + list(self.hc_homekit_to_hass)[0], + self.hc_hass_to_homekit, ) - self.char_target_heat_cool.override_properties( - valid_values=self.hc_hass_to_homekit - ) - self.char_target_heat_cool.allow_invalid_client_values = True - # Current and target temperature characteristics - self.char_current_temp = serv_thermostat.configure_char( - CHAR_CURRENT_TEMPERATURE, value=21.0 - ) + self._configure_current_temperature_char(serv_thermostat) self.char_target_temp = serv_thermostat.configure_char( CHAR_TARGET_TEMPERATURE, @@ -318,123 +258,31 @@ class Thermostat(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=50 ) - fan_modes: dict[str, str] = {} - self.ordered_fan_speeds: list[str] = [] + # Fan/swing modes are detected in the base class. + if self.ordered_fan_speeds: + self.fan_chars.append(CHAR_ROTATION_SPEED) - if features & ClimateEntityFeature.FAN_MODE: - fan_modes = { - fan_mode.lower(): fan_mode - for fan_mode in attributes.get(ATTR_FAN_MODES) or [] - } - if fan_modes and PRE_DEFINED_FAN_MODES.intersection(fan_modes): - self.ordered_fan_speeds = [ - speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes - ] - self.fan_chars.append(CHAR_ROTATION_SPEED) - - if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speeds): + if FAN_AUTO in self.fan_modes and ( + FAN_ON in self.fan_modes or self.ordered_fan_speeds + ): self.fan_chars.append(CHAR_TARGET_FAN_STATE) - self.fan_modes = fan_modes - if ( - features & ClimateEntityFeature.SWING_MODE - and (swing_modes := attributes.get(ATTR_SWING_MODES)) - and PRE_DEFINED_SWING_MODES.intersection(swing_modes) - ): - self.swing_on_mode = next( - iter( - swing_mode - for swing_mode in SWING_MODE_PREFERRED_ORDER - if swing_mode in swing_modes - ) - ) + if self.swing_on_mode: self.fan_chars.append(CHAR_SWING_MODE) if self.fan_chars: if attributes.get(ATTR_HVAC_ACTION) is not None: self.fan_chars.append(CHAR_CURRENT_FAN_STATE) - serv_fan = self.add_preload_service(SERV_FANV2, self.fan_chars) - serv_thermostat.add_linked_service(serv_fan) - self.char_active = serv_fan.configure_char( - CHAR_ACTIVE, value=1, setter_callback=self._set_fan_active - ) - if CHAR_SWING_MODE in self.fan_chars: - self.char_swing = serv_fan.configure_char( - CHAR_SWING_MODE, - value=0, - setter_callback=self._set_fan_swing_mode, - ) - self.char_swing.display_name = "Swing Mode" - if CHAR_ROTATION_SPEED in self.fan_chars: - self.char_speed = serv_fan.configure_char( - CHAR_ROTATION_SPEED, - value=100, - properties={PROP_MIN_STEP: 100 / len(self.ordered_fan_speeds)}, - setter_callback=self._set_fan_speed, - ) - self.char_speed.display_name = "Fan Mode" - if CHAR_CURRENT_FAN_STATE in self.fan_chars: - self.char_current_fan_state = serv_fan.configure_char( - CHAR_CURRENT_FAN_STATE, - value=0, - ) - self.char_current_fan_state.display_name = "Fan State" - if CHAR_TARGET_FAN_STATE in self.fan_chars and FAN_AUTO in self.fan_modes: - self.char_target_fan_state = serv_fan.configure_char( - CHAR_TARGET_FAN_STATE, - value=0, - setter_callback=self._set_fan_auto, - ) - self.char_target_fan_state.display_name = "Fan Auto" + self._configure_fan_service(serv_thermostat) + + # Every service exists now, so they all get an explicit primary + # flag; without one the Home app can pick its own tile service. + self.set_primary_service(serv_thermostat) self.async_update_state(state) serv_thermostat.setter_callback = self._set_chars - def _set_fan_swing_mode(self, swing_on: int) -> None: - _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) - mode = self.swing_on_mode if swing_on else SWING_OFF - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, params) - - def _set_fan_speed(self, speed: int) -> None: - _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) - speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, speed - 1) - mode = self.fan_modes[speed_key] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) - - def _get_on_mode(self) -> str: - if self.ordered_fan_speeds: - speed_key = percentage_to_ordered_list_item(self.ordered_fan_speeds, 50) - return self.fan_modes[speed_key] - return self.fan_modes[FAN_ON] - - def _set_fan_active(self, active: int) -> None: - _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active) - if FAN_OFF not in self.fan_modes: - _LOGGER.debug( - "%s: Fan does not support off, resetting to on", self.entity_id - ) - self.char_active.value = 1 - self.char_active.notify() - return - mode = self._get_on_mode() if active else self.fan_modes[FAN_OFF] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) - - def _set_fan_auto(self, auto: int) -> None: - _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) - mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} - self.async_call_service(CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, params) - - def _temperature_to_homekit(self, temp: float) -> float: - return temperature_to_homekit(temp, self._unit) - - def _temperature_to_states(self, temp: float) -> float: - return temperature_to_states(temp, self._unit) - def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Thermostat _set_chars: %s", char_values) events = [] @@ -458,7 +306,9 @@ class Thermostat(HomeAccessory): # siri will always send HC_HEAT_COOL_AUTO in this case # and hope for the best. hc_target_temp = char_values.get(CHAR_TARGET_TEMPERATURE) - hc_current_temp = _get_current_temperature(state, self._unit) + hc_current_temp = temperature_attribute_to_homekit( + state, ATTR_CURRENT_TEMPERATURE, self._unit + ) hc_fallback_order = HC_HEAT_COOL_PREFER_HEAT if ( hc_target_temp is not None @@ -531,38 +381,20 @@ class Thermostat(HomeAccessory): assert self.char_cooling_thresh_temp assert self.char_heating_thresh_temp service = SERVICE_SET_TEMPERATURE_THERMOSTAT - high = self.char_cooling_thresh_temp.value - low = self.char_heating_thresh_temp.value - min_temp, max_temp = self.get_temperature_range(state) - if CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values: - events.append( - f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to" - f" {char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]}°C" - ) - high = char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE] - # If the device doesn't support TARGET_TEMPATURE - # this can happen - if high < low: - low = high - HEAT_COOL_DEADBAND - if CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values: - events.append( - f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to" - f" {char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]}°C" - ) - low = char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE] - # If the device doesn't support TARGET_TEMPATURE - # this can happen - if low > high: - high = low + HEAT_COOL_DEADBAND - - high = min(high, max_temp) - low = max(low, min_temp) - + new_high = char_values.get(CHAR_COOLING_THRESHOLD_TEMPERATURE) + new_low = char_values.get(CHAR_HEATING_THRESHOLD_TEMPERATURE) + if new_high is not None: + events.append(f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to {new_high}°C") + if new_low is not None: + events.append(f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to {new_low}°C") + # A device without TARGET_TEMPERATURE can send an inverted pair. params.update( - { - ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high), - ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low), - } + self._dual_setpoint_params( + self.char_cooling_thresh_temp, + self.char_heating_thresh_temp, + new_high, + new_low, + ) ) if service: @@ -604,15 +436,6 @@ class Thermostat(HomeAccessory): } self.hc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hass.items()} - def get_temperature_range(self, state: State) -> tuple[float, float]: - """Return min and max temperature range.""" - return _get_temperature_range_from_state( - state, - self._unit, - DEFAULT_MIN_TEMP, - DEFAULT_MAX_TEMP, - ) - def set_target_humidity(self, value: float) -> None: """Set target humidity to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) @@ -648,10 +471,7 @@ class Thermostat(HomeAccessory): HC_HASS_TO_HOMEKIT_ACTION.get(hvac_action, HC_HEAT_COOL_OFF) ) - # Update current temperature - current_temp = _get_current_temperature(new_state, self._unit) - if current_temp is not None: - self.char_current_temp.set_value(current_temp) + self._update_current_temperature_char(new_state) # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: @@ -667,22 +487,20 @@ class Thermostat(HomeAccessory): if isinstance(target_humdity, (int, float)): self.char_target_humidity.set_value(target_humdity) - # Update cooling threshold temperature if characteristic exists + # Update threshold temperatures if the characteristics exist if self.char_cooling_thresh_temp: - cooling_thresh = attributes.get(ATTR_TARGET_TEMP_HIGH) - if isinstance(cooling_thresh, (int, float)): - cooling_thresh = self._temperature_to_homekit(cooling_thresh) - self.char_cooling_thresh_temp.set_value(cooling_thresh) - - # Update heating threshold temperature if characteristic exists + self._update_temperature_char( + self.char_cooling_thresh_temp, new_state, ATTR_TARGET_TEMP_HIGH + ) if self.char_heating_thresh_temp: - heating_thresh = attributes.get(ATTR_TARGET_TEMP_LOW) - if isinstance(heating_thresh, (int, float)): - heating_thresh = self._temperature_to_homekit(heating_thresh) - self.char_heating_thresh_temp.set_value(heating_thresh) + self._update_temperature_char( + self.char_heating_thresh_temp, new_state, ATTR_TARGET_TEMP_LOW + ) # Update target temperature - target_temp = _get_target_temperature(new_state, self._unit) + target_temp = temperature_attribute_to_homekit( + new_state, ATTR_TEMPERATURE, self._unit + ) if ( target_temp is None and features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -707,42 +525,7 @@ class Thermostat(HomeAccessory): self.char_display_units.set_value(unit) if self.fan_chars: - self._async_update_fan_state(new_state) - - @callback - def _async_update_fan_state(self, new_state: State) -> None: - """Update state without rechecking the device features.""" - attributes = new_state.attributes - - if CHAR_SWING_MODE in self.fan_chars and ( - swing_mode := attributes.get(ATTR_SWING_MODE) - ): - swing = 1 if swing_mode in PRE_DEFINED_SWING_MODES else 0 - self.char_swing.set_value(swing) - - fan_mode = attributes.get(ATTR_FAN_MODE) - fan_mode_lower = fan_mode.lower() if isinstance(fan_mode, str) else None - if ( - CHAR_ROTATION_SPEED in self.fan_chars - and fan_mode_lower in self.ordered_fan_speeds - ): - self.char_speed.set_value( - ordered_list_item_to_percentage(self.ordered_fan_speeds, fan_mode_lower) - ) - - if CHAR_TARGET_FAN_STATE in self.fan_chars: - self.char_target_fan_state.set_value(1 if fan_mode_lower == FAN_AUTO else 0) - - if CHAR_CURRENT_FAN_STATE in self.fan_chars and ( - hvac_action := attributes.get(ATTR_HVAC_ACTION) - ): - self.char_current_fan_state.set_value( - HC_HASS_TO_HOMEKIT_FAN_STATE[hvac_action] - ) - - self.char_active.set_value( - int(new_state.state != HVACMode.OFF and fan_mode_lower != FAN_OFF) - ) + self._async_update_fan_service(new_state) @TYPES.register("WaterHeater") @@ -811,7 +594,7 @@ class WaterHeater(HomeAccessory): def get_temperature_range(self, state: State) -> tuple[float, float]: """Return min and max temperature range.""" - return _get_temperature_range_from_state( + return get_temperature_range_from_state( state, self._unit, DEFAULT_MIN_TEMP_WATER_HEATER, @@ -881,11 +664,15 @@ class WaterHeater(HomeAccessory): def async_update_state(self, new_state: State) -> None: """Update water_heater state after state change.""" # Update current and target temperature - target_temperature = _get_target_temperature(new_state, self._unit) + target_temperature = temperature_attribute_to_homekit( + new_state, ATTR_TEMPERATURE, self._unit + ) if target_temperature is not None: self.char_target_temp.set_value(target_temperature) - current_temperature = _get_current_temperature(new_state, self._unit) + current_temperature = temperature_attribute_to_homekit( + new_state, ATTR_CURRENT_TEMPERATURE, self._unit + ) if current_temperature is not None: self.char_current_temp.set_value(current_temperature) @@ -902,45 +689,3 @@ class WaterHeater(HomeAccessory): else: self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) self.char_current_heat_cool.set_value(HC_HEAT_COOL_HEAT) - - -def _get_temperature_range_from_state( - state: State, unit: str, default_min: float, default_max: float -) -> tuple[float, float]: - """Calculate the temperature range from a state.""" - if min_temp := state.attributes.get(ATTR_MIN_TEMP): - min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2 - else: - min_temp = default_min - - if max_temp := state.attributes.get(ATTR_MAX_TEMP): - max_temp = round(temperature_to_homekit(max_temp, unit) * 2) / 2 - else: - max_temp = default_max - - # Handle reversed temperature range - min_temp, max_temp = get_min_max(min_temp, max_temp) - - # Homekit only supports 10-38, overwriting - # the max to appears to work, but less than 0 causes - # a crash on the home app - min_temp = max(min_temp, 0) - max_temp = max(max_temp, min_temp) - - return min_temp, max_temp - - -def _get_target_temperature(state: State, unit: str) -> float | None: - """Calculate the target temperature from a state.""" - target_temp = state.attributes.get(ATTR_TEMPERATURE) - if isinstance(target_temp, (int, float)): - return temperature_to_homekit(target_temp, unit) - return None - - -def _get_current_temperature(state: State, unit: str) -> float | None: - """Calculate the current temperature from a state.""" - current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) - if isinstance(current_temp, (int, float)): - return temperature_to_homekit(current_temp, unit) - return None diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 97269181d58a..bc1b65a365c9 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -106,10 +106,12 @@ from .const import ( TYPE_AIR_PURIFIER, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, @@ -225,6 +227,21 @@ COVER_SCHEMA = BASIC_INFO_SCHEMA.extend( } ) +# No default so an unset type keeps the automatic Thermostat/HeaterCooler routing. +CLIMATE_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE): vol.All( + cv.string, + vol.In( + ( + TYPE_HEATER_COOLER, + TYPE_THERMOSTAT, + ) + ), + ), + } +) + CODE_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)} ) @@ -360,6 +377,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "humidifier": config = HUMIDIFIER_SCHEMA(config) + elif domain == "climate": + config = CLIMATE_SCHEMA(config) + elif domain == "cover": config = COVER_SCHEMA(config) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index e18631c7049b..46934718b7ef 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -48,7 +48,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the HomematicIP Cloud component.""" + """Set up the HomematicIP Cloud integration.""" accesspoints = config.get(DOMAIN, []) for conf in accesspoints: diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index ae948ce5b4c9..3a017690ee26 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -2,6 +2,7 @@ from typing import override +from homematicip.base.functionalChannels import AccessAuthorizationChannel from homematicip.device import WallMountedGarageDoorController from homeassistant.components.button import ButtonEntity @@ -12,11 +13,17 @@ from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP -def _is_full_flush_lock_controller(device: object) -> bool: - """Return whether the device is an HmIP-FLC.""" - return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( - device, "send_start_impulse_async" - ) +def _door_opener_authorization_channel( + device: object, +) -> AccessAuthorizationChannel | None: + """Return the AccessAuthorizationChannel routed to the door opener.""" + for channel in getattr(device, "functionalChannels", []): + if ( + isinstance(channel, AccessAuthorizationChannel) + and getattr(channel, "channelRole", None) == "DOOR_OPENER_ACTUATOR" + ): + return channel + return None async def async_setup_entry( @@ -33,9 +40,10 @@ async def async_setup_entry( if isinstance(device, WallMountedGarageDoorController) ] entities.extend( - HomematicipFullFlushLockControllerButton(hap, device) + HomematicipFullFlushLockControllerButton(hap, device, auth_channel) for device in hap.home.devices - if _is_full_flush_lock_controller(device) + if getattr(device, "modelType", None) == "HmIP-FLC" + and (auth_channel := _door_opener_authorization_channel(device)) is not None ) async_add_entities(entities) @@ -57,14 +65,24 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity): """Representation of the HomematicIP full flush lock controller opener.""" - def __init__(self, hap: HomematicipHAP, device) -> None: + def __init__( + self, + hap: HomematicipHAP, + device, + auth_channel: AccessAuthorizationChannel, + ) -> None: """Initialize the full flush lock controller opener button.""" super().__init__( hap, device, post="Door opener", feature_id="lock_opener_button" ) self._attr_icon = "mdi:door-open" + self._auth_channel = auth_channel @override async def async_press(self) -> None: - """Handle the button press.""" - await self._device.send_start_impulse_async() + """Pull the latch via the access-authorization channel. + + This is the only path non-admin clients may use; the door-switch + channel rejects them with CLIENT_ACCESS_DENIED. + """ + await self._auth_channel.async_pull_latch() diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index aae5a6227e37..1fe8b126ea62 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the HomematicIP Cloud component.""" +"""Config flow to configure the HomematicIP Cloud integration.""" from collections.abc import Mapping from typing import Any, override @@ -12,7 +12,7 @@ from .hap import HomematicipAuth class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): - """Config flow for the HomematicIP Cloud component.""" + """Config flow for the HomematicIP Cloud integration.""" VERSION = 2 diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 07e4fbadeb7a..60caa168311e 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -1,4 +1,4 @@ -"""Constants for the HomematicIP Cloud component.""" +"""Constants for the HomematicIP Cloud integration.""" import logging diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 2e4889a53d8c..5e2947b902fe 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -1,4 +1,4 @@ -"""Generic entity for the HomematicIP Cloud component.""" +"""Generic entity for the HomematicIP Cloud integration.""" import contextlib import logging diff --git a/homeassistant/components/homematicip_cloud/errors.py b/homeassistant/components/homematicip_cloud/errors.py index bbee58f7a417..dc753cf62cb4 100644 --- a/homeassistant/components/homematicip_cloud/errors.py +++ b/homeassistant/components/homematicip_cloud/errors.py @@ -1,4 +1,4 @@ -"""Errors for the HomematicIP Cloud component.""" +"""Errors for the HomematicIP Cloud integration.""" from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index df54e669a584..65da63cd344f 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -1,4 +1,4 @@ -"""Access point for the HomematicIP Cloud component.""" +"""Access point for the HomematicIP Cloud integration.""" import asyncio from collections.abc import Callable diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 06dd60bfe513..0d2124a7fed6 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -23,7 +23,6 @@ from homematicip.device import ( LightSensor, MotionDetectorIndoor, MotionDetectorOutdoor, - MotionDetectorPushButton, PassageDetector, PresenceDetectorIndoor, RoomControlDeviceAnalog, @@ -217,9 +216,6 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]: MotionDetectorOutdoor: lambda device: [ HomematicipIlluminanceSensor(hap, device), ], - MotionDetectorPushButton: lambda device: [ - HomematicipIlluminanceSensor(hap, device), - ], PresenceDetectorIndoor: lambda device: [ HomematicipIlluminanceSensor(hap, device), ], diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index a1741d5d2319..f9a56ea3db9d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -1,7 +1,7 @@ { "domain": "homewizard", "name": "HomeWizard", - "codeowners": ["@DCSBL"], + "codeowners": ["@DCSBL", "@lexpostma"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 3309e8931531..41baaf1a5823 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -152,10 +152,8 @@ def remove_stale_devices( # If device_id is None an invalid device entry was # found for this config entry. If the device_id is not # in existing device ids it's a stale device entry. - # Remove config entry from this device entry in either case. - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) + # Remove the device entry in either case. + device_registry.async_remove_device(device_entry.id) class HoneywellUSThermostat(ClimateEntity): diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 79ee0ffc91f6..6804e6ef202f 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.35"] + "requirements": ["AIOSomecomfort==0.0.38"] } diff --git a/homeassistant/components/honeywell_string_lights/entity.py b/homeassistant/components/honeywell_string_lights/entity.py index 9002b8713528..983e134be29b 100644 --- a/homeassistant/components/honeywell_string_lights/entity.py +++ b/homeassistant/components/honeywell_string_lights/entity.py @@ -1,76 +1,24 @@ """Common entity for Honeywell String Lights integration.""" -import logging -from typing import override - +from homeassistant.components.radio_frequency import ( + RadioFrequencyTransmitterConsumerEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Event, EventStateChangedData, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_TRANSMITTER, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -class HoneywellStringLightsEntity(Entity): +class HoneywellStringLightsEntity(RadioFrequencyTransmitterConsumerEntity): """Honeywell String Lights base entity.""" _attr_has_entity_name = True def __init__(self, entry: ConfigEntry) -> None: """Initialize the entity.""" - self._transmitter = entry.data[CONF_TRANSMITTER] self._attr_unique_id = entry.entry_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Honeywell", model="String Lights", ) - - @override - async def async_added_to_hass(self) -> None: - """Subscribe to transmitter entity state changes.""" - await super().async_added_to_hass() - - transmitter_entity_id = er.async_validate_entity_id( - er.async_get(self.hass), self._transmitter - ) - - @callback - def _async_transmitter_state_changed( - event: Event[EventStateChangedData], - ) -> None: - """Handle transmitter entity state changes.""" - new_state = event.data["new_state"] - transmitter_available = ( - new_state is not None and new_state.state != STATE_UNAVAILABLE - ) - if transmitter_available != self.available: - _LOGGER.info( - "Transmitter %s used by %s is %s", - transmitter_entity_id, - self.entity_id, - "available" if transmitter_available else "unavailable", - ) - - self._attr_available = transmitter_available - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [transmitter_entity_id], - _async_transmitter_state_changed, - ) - ) - - # Set initial availability based on current transmitter entity state - transmitter_state = self.hass.states.get(transmitter_entity_id) - self._attr_available = ( - transmitter_state is not None - and transmitter_state.state != STATE_UNAVAILABLE - ) diff --git a/homeassistant/components/honeywell_string_lights/light.py b/homeassistant/components/honeywell_string_lights/light.py index 0e8dee04458b..51a45cdfbcc0 100644 --- a/homeassistant/components/honeywell_string_lights/light.py +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -5,13 +5,13 @@ from typing import Any, override from rf_protocols.codes.honeywell.string_lights import CODES from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.components.radio_frequency import async_send_command 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 homeassistant.helpers.restore_state import RestoreEntity +from .const import CONF_TRANSMITTER from .entity import HoneywellStringLightsEntity PARALLEL_UPDATES = 1 @@ -33,7 +33,11 @@ class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEnti _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} _attr_name = None - _attr_should_poll = False + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + super().__init__(entry) + self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER] @override async def async_added_to_hass(self) -> None: @@ -45,20 +49,18 @@ class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEnti @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - await self._async_send_command("turn_on") + await self._async_send_rf_command("turn_on") self._attr_is_on = True self.async_write_ha_state() @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - await self._async_send_command("turn_off") + await self._async_send_rf_command("turn_off") self._attr_is_on = False self.async_write_ha_state() - async def _async_send_command(self, name: str) -> None: + async def _async_send_rf_command(self, name: str) -> None: """Load the named command and send it via the configured transmitter.""" command = await CODES.async_load_command(name) - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) + await self._send_command(command) diff --git a/homeassistant/components/hr_energy_qube/manifest.json b/homeassistant/components/hr_energy_qube/manifest.json index 5992fbed0793..0fe661bdbbe1 100644 --- a/homeassistant/components/hr_energy_qube/manifest.json +++ b/homeassistant/components/hr_energy_qube/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["python_qube_heatpump"], "quality_scale": "bronze", - "requirements": ["python-qube-heatpump==1.11.0"] + "requirements": ["python-qube-heatpump==1.12.0"] } diff --git a/homeassistant/components/html5/issue.py b/homeassistant/components/html5/issue.py index 66c11f5c7421..37cb84fbff7f 100644 --- a/homeassistant/components/html5/issue.py +++ b/homeassistant/components/html5/issue.py @@ -52,3 +52,33 @@ def deprecated_dismiss_action_call(hass: HomeAssistant) -> None: "new_action": "html5.dismiss_message", }, ) + + +@callback +def deprecated_event_bus(hass: HomeAssistant, event: str) -> None: + """Raise a deprecation issue for listeners on the event bus.""" + + if listeners := hass.bus.async_listeners().get(event): + async_create_issue( + hass, + DOMAIN, + f"deprecated_event_bus_{event}", + breaks_in_ha_version="2027.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_event_bus", + translation_placeholders={ + "event": event, + "listeners": str(listeners), + "example_yaml": """```yaml +triggers: + - trigger: event.received + target: + entity_id: event.my_device + options: + event_type: + - received +``` +""", + }, + ) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 00e5762b3ddc..1e6c1b743035 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -60,7 +60,11 @@ from .const import ( SERVICE_DISMISS, ) from .entity import HTML5Entity, Registration -from .issue import deprecated_dismiss_action_call, deprecated_notify_action_call +from .issue import ( + deprecated_dismiss_action_call, + deprecated_event_bus, + deprecated_notify_action_call, +) _LOGGER = logging.getLogger(__name__) @@ -409,6 +413,9 @@ class HTML5PushCallbackView(HomeAssistantView): event_payload[ATTR_TYPE], event_payload, ) + + deprecated_event_bus(hass, event_name) + return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]}) diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 3dea89e6ead2..1bc8941c65e6 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -55,6 +55,10 @@ "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action}` action instead.", "title": "[%key:component::html5::issues::deprecated_notify_action::title%]" }, + "deprecated_event_bus": { + "description": "Detected **{listeners}** listener(s) for the event `{event}`.\n\nThe HTML5 Push Notifications integration firing events on the event bus is deprecated and this functionality will be removed in a future release.\n\nPlease update your automations and scripts to use the event entities instead.\n\n## Example automation:\n\n{example_yaml}", + "title": "Detected use of deprecated event {event}" + }, "deprecated_notify_action": { "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action_1}` or `{new_action_2}` actions instead.", "title": "Detected use of deprecated action {action}" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 247da3e1b6d2..04b622df32bb 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -12,7 +12,7 @@ from pathlib import Path import socket import ssl from tempfile import NamedTemporaryFile -from typing import Any, Final, TypedDict, cast, override +from typing import Any, Final, cast, override from aiohttp import web from aiohttp.abc import AbstractStreamWriter @@ -33,11 +33,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME, - SERVER_PORT, ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.http import ( KEY_ALLOW_CONFIGURED_CORS, @@ -60,7 +59,36 @@ from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans -from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401 +from .config import ( + _DEFAULT_CONFIG, + ConfData, + HTTPConfigStore, + async_get_and_load_store, + async_load_config, + default_server_port, +) +from .const import ( # noqa: F401 + CONF_BASE_URL, + CONF_CORS_ORIGINS, + CONF_IP_BAN_ENABLED, + CONF_LOGIN_ATTEMPTS_THRESHOLD, + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + CONF_SSL_PEER_CERTIFICATE, + CONF_SSL_PROFILE, + CONF_TRUSTED_PROXIES, + CONF_USE_X_FORWARDED_FOR, + CONF_USE_X_FRAME_OPTIONS, + DEFAULT_CORS, + DOMAIN, + KEY_HASS_REFRESH_TOKEN_ID, + KEY_HASS_USER, + NO_LOGIN_ATTEMPT_THRESHOLD, + SSL_INTERMEDIATE, + SSL_MODERN, +) from .cors import setup_cors from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded @@ -68,40 +96,15 @@ from .headers import setup_headers from .request_context import setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource -from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite - -CONF_SERVER_HOST: Final = "server_host" -CONF_SERVER_PORT: Final = "server_port" -CONF_BASE_URL: Final = "base_url" -CONF_SSL_CERTIFICATE: Final = "ssl_certificate" -CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate" -CONF_SSL_KEY: Final = "ssl_key" -CONF_CORS_ORIGINS: Final = "cors_allowed_origins" -CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for" -CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options" -CONF_TRUSTED_PROXIES: Final = "trusted_proxies" -CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" -CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" -CONF_SSL_PROFILE: Final = "ssl_profile" - -SSL_MODERN: Final = "modern" -SSL_INTERMEDIATE: Final = "intermediate" +from .web_runner import HomeAssistantUnixSite _LOGGER: Final = logging.getLogger(__name__) DEFAULT_DEVELOPMENT: Final = "0" -# Cast to be able to load custom cards. -# My to be able to check url and version info. -DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"] -NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1 MAX_CLIENT_SIZE: Final = 1024**2 * 16 MAX_LINE_SIZE: Final = 24570 -STORAGE_KEY: Final = DOMAIN -STORAGE_VERSION: Final = 1 -SAVE_DELAY: Final = 180 - _HAS_IPV6 = hasattr(socket, "AF_INET6") _DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] @@ -112,7 +115,7 @@ HTTP_SCHEMA: Final = vol.All( vol.Optional(CONF_SERVER_HOST): vol.All( cv.ensure_list, vol.Length(min=1), [cv.string] ), - vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, + vol.Optional(CONF_SERVER_PORT, default=default_server_port): cv.port, vol.Optional(CONF_BASE_URL): cv.string, vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, @@ -154,30 +157,6 @@ _STATIC_CLASSES = { } -class ConfData(TypedDict, total=False): - """Typed dict for config data.""" - - server_host: list[str] - server_port: int - base_url: str - ssl_certificate: str - ssl_peer_certificate: str - ssl_key: str - cors_allowed_origins: list[str] - use_x_forwarded_for: bool - use_x_frame_options: bool - trusted_proxies: list[IPv4Network | IPv6Network] - login_attempts_threshold: int - ip_ban_enabled: bool - ssl_profile: str - - -async def async_get_last_config(hass: HomeAssistant) -> dict[str, Any] | None: - """Return the last known working config.""" - store = storage.Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - return await store.async_load() - - class ApiConfig: """Configuration settings for API server.""" @@ -195,16 +174,143 @@ class ApiConfig: self.use_ssl = use_ssl +async def _async_fallback_config( + hass: HomeAssistant, + store: HTTPConfigStore, + conf: ConfData, + err: HomeAssistantError | OSError, +) -> ConfData: + """Return the next config to try after ``conf`` could not be applied. + + Implements the fallback chain pending -> stable -> default config, where + the last step is only taken in recovery mode. Raises when there is no + (acceptable) fallback left, failing setup: on a normal boot this + activates recovery mode, in recovery mode it makes the failure visible + to the outside (e.g. the Supervisor rolls back a Core update whose API + does not come up). + """ + if store.revert_deadline is not None: + # An unconfirmed pending config is under trial and cannot even be + # applied, so it is known to be bad: revert to the stable config + # right away and continue this same start with it, instead of + # waiting out the trial window and restarting. + _LOGGER.error( + "The new HTTP configuration could not be applied, reverting to " + "the previous configuration: %s", + err, + ) + await store.async_abort_trial() + return store.stable + + if ( + # In normal mode, fail setup so recovery mode can take over with a + # reachable configuration. + not hass.config.recovery_mode + # The chain is exhausted; nothing left to fall back to. + or conf is _DEFAULT_CONFIG + # With peer certificate verification configured, connections must + # never be accepted without a verified client certificate; there is + # no acceptable fallback config. + or CONF_SSL_PEER_CERTIFICATE in conf + ): + # An unusable SSL configuration already carries a descriptive + # HomeAssistantError. + if isinstance(err, HomeAssistantError): + raise err + raise HomeAssistantError( + f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}" + ) from err + + # The config cannot be applied in recovery mode; fall back to the + # default config so the recovery UI stays reachable. + _LOGGER.error( + "The HTTP configuration could not be applied in recovery mode, " + "falling back to the default configuration: %s", + err, + ) + return _DEFAULT_CONFIG + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" # Late import to ensure isal is updated before # we import aiohttp_fast_zlib (await async_import_module(hass, "aiohttp_fast_zlib")).enable() - conf: ConfData | None = config.get(DOMAIN) + # Deferred import: websocket_api declares http as its manifest + # dependency and imports back into this package at module load + # (websocket_api/http.py -> homeassistant.components.http). A top-level + # import of .websocket_api here would re-enter the still-loading + # websocket_api package and fail when applying its decorators + # (e.g. @websocket_api.require_admin). + websocket_api_module = await async_import_module( + hass, "homeassistant.components.http.websocket_api" + ) - if conf is None: - conf = cast(ConfData, HTTP_SCHEMA({})) + conf = await async_load_config(hass, config) + + websocket_api_module.async_register_websocket_commands(hass) + + supervisor_unix_socket_path: Path | None = None + if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"): + socket_path = Path(socket_env) + if socket_path.is_absolute(): + supervisor_unix_socket_path = socket_path + else: + _LOGGER.error( + "Invalid Supervisor Unix socket path %s: path must be absolute", + socket_env, + ) + + def _make_server(conf: ConfData) -> HomeAssistantHTTP: + return HomeAssistantHTTP( + hass, + server_host=conf.get(CONF_SERVER_HOST, _DEFAULT_BIND), + server_port=conf[CONF_SERVER_PORT], + ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), + ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), + ssl_key=conf.get(CONF_SSL_KEY), + # The loaded config stores trusted proxies as strings + # (JSON-serializable); the forwarded middleware needs + # IPv4Network/IPv6Network objects. + trusted_proxies=[ + ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] + ], + ssl_profile=conf[CONF_SSL_PROFILE], + supervisor_unix_socket_path=supervisor_unix_socket_path, + ) + + server = _make_server(conf) + trial_reverted = False + while True: + try: + await server.async_bind() + except (HomeAssistantError, OSError) as err: + store = await async_get_and_load_store(hass) + trial_reverted = store.revert_deadline is not None + conf = await _async_fallback_config(hass, store, conf, err) + server = _make_server(conf) + continue + if trial_reverted: + _LOGGER.warning( + "The previous HTTP configuration has been restored (server port %d)", + conf[CONF_SERVER_PORT], + ) + break + + # Created only after the fallback chain succeeded: if setup fails above, + # an already running task would be left behind unawaited. + source_ip_task = create_eager_task(async_get_source_ip(hass)) + + async def stop_server(event: Event) -> None: + """Stop the server.""" + await server.stop() + + # Register the stop listener right away, not only once serving starts: + # sockets are already bound, and if the remainder of startup fails the + # recovery-mode teardown (which fires the stop event) must release them, + # or the recovery boot cannot bind the same address again. + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) if CONF_SERVER_HOST in conf and is_hassio(hass): issue_id = "server_host_deprecated_hassio" @@ -221,59 +327,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND) server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) - ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) - ssl_key = conf.get(CONF_SSL_KEY) - cors_origins = conf[CONF_CORS_ORIGINS] - use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) - use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS] - trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or [] - is_ban_enabled = conf[CONF_IP_BAN_ENABLED] - login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] - ssl_profile = conf[CONF_SSL_PROFILE] - source_ip_task = create_eager_task(async_get_source_ip(hass)) - - supervisor_unix_socket_path: Path | None = None - if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"): - socket_path = Path(socket_env) - if socket_path.is_absolute(): - supervisor_unix_socket_path = socket_path - else: - _LOGGER.error( - "Invalid Supervisor Unix socket path %s: path must be absolute", - socket_env, - ) - - server = HomeAssistantHTTP( - hass, - server_host=server_host, - server_port=server_port, - ssl_certificate=ssl_certificate, - ssl_peer_certificate=ssl_peer_certificate, - ssl_key=ssl_key, - trusted_proxies=trusted_proxies, - ssl_profile=ssl_profile, - supervisor_unix_socket_path=supervisor_unix_socket_path, - ) await server.async_initialize( - cors_origins=cors_origins, - use_x_forwarded_for=use_x_forwarded_for, - login_threshold=login_threshold, - is_ban_enabled=is_ban_enabled, - use_x_frame_options=use_x_frame_options, + cors_origins=conf[CONF_CORS_ORIGINS], + use_x_forwarded_for=conf.get(CONF_USE_X_FORWARDED_FOR, False), + login_threshold=conf[CONF_LOGIN_ATTEMPTS_THRESHOLD], + is_ban_enabled=conf[CONF_IP_BAN_ENABLED], + use_x_frame_options=conf[CONF_USE_X_FRAME_OPTIONS], ) - async def stop_server(event: Event) -> None: - """Stop the server.""" - await server.stop() - async def start_server(*_: Any) -> None: """Start the server.""" with async_start_setup(hass, integration="http", phase=SetupPhases.SETUP): - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) - # We already checked it's not None. - assert conf is not None - await start_http_server_and_save_config(hass, dict(conf), server) + await server.start() async_when_setup_or_start(hass, "frontend", start_server) @@ -414,9 +480,51 @@ class HomeAssistantHTTP: self.ssl_profile = ssl_profile self.supervisor_unix_socket_path = supervisor_unix_socket_path self.runner: web.AppRunner | None = None - self.site: HomeAssistantTCPSite | None = None self.supervisor_site: HomeAssistantUnixSite | None = None self.context: ssl.SSLContext | None = None + self._server: asyncio.Server | None = None + + async def async_bind(self) -> None: + """Create the SSL context and the server, binding its sockets. + + Called during setup so that an unusable configuration surfaces before + it is applied; serving starts later in ``start()``. Raises + ``HomeAssistantError`` if the SSL configuration is unusable and + ``OSError`` if the configured address cannot be bound. + """ + if self.ssl_certificate: + self.context = await self.hass.async_add_executor_job( + self._create_ssl_context + ) + self._server = await self._async_create_server() + + async def _async_create_server(self) -> asyncio.Server: + """Create the (not yet serving) HTTP server, binding its sockets.""" + try: + return await self.hass.loop.create_server( + self._make_protocol, + self.server_host if self.server_host is not None else _DEFAULT_BIND, + self.server_port, + ssl=self.context, + backlog=128, + start_serving=False, + ) + except UnicodeError as err: + # create_server() raises UnicodeError for hosts the IDNA codec + # cannot encode (e.g. a label longer than 63 characters); + # normalize to OSError so callers only need to handle one error + # type. + raise OSError(f"error while resolving host: {err}") from err + + def _make_protocol(self) -> RequestHandler: + """Create a protocol instance for an accepted connection. + + Connections are only accepted once ``start()`` has run, so the + runner is set up by the time this is called. + """ + runner = self.runner + assert runner is not None and runner.server is not None + return runner.server() async def async_initialize( self, @@ -447,11 +555,6 @@ class HomeAssistantHTTP: setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) - if self.ssl_certificate: - self.context = await self.hass.async_add_executor_job( - self._create_ssl_context - ) - def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: """Register a view with the WSGI server. @@ -492,7 +595,7 @@ class HomeAssistantHTTP: async def redirect(request: web.Request) -> web.StreamResponse: """Redirect to location.""" # Should be instance of aiohttp.web_exceptions._HTTPMove. - raise redirect_exc(redirect_to) # type: ignore[arg-type,misc] + raise redirect_exc(redirect_to) # type: ignore[arg-type,call-arg] self.app[KEY_ALLOW_CONFIGURED_CORS]( self.app.router.add_route("GET", url, redirect) @@ -572,12 +675,13 @@ class HomeAssistantHTTP: ) context = None else: + # Fall through: a configured peer certificate must still be + # enforced on the emergency context. _LOGGER.critical( "Home Assistant is running in recovery mode with an emergency self" " signed ssl certificate because the configured SSL certificate was" " not usable" ) - return context if self.ssl_peer_certificate: if context is None: @@ -587,7 +691,15 @@ class HomeAssistantHTTP: ) context.verify_mode = ssl.CERT_REQUIRED - context.load_verify_locations(self.ssl_peer_certificate) + try: + context.load_verify_locations(self.ssl_peer_certificate) + except OSError as error: + # Raise HomeAssistantError so the caller can tell an unusable + # SSL configuration apart from a socket bind failure (OSError). + raise HomeAssistantError( + f"Could not use SSL peer certificate from" + f" {self.ssl_peer_certificate}: {error}" + ) from error return context @@ -680,15 +792,10 @@ class HomeAssistantHTTP: ) await self.runner.setup() - self.site = HomeAssistantTCPSite( - self.runner, self.server_host, self.server_port, ssl_context=self.context - ) - try: - await self.site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server at port %d: %s", self.server_port, error - ) + # Setup either binds the server or fails, so it is always available + # here. + assert self._server is not None + await self._server.start_serving() _LOGGER.info("Now listening on port %d", self.server_port) @@ -707,27 +814,8 @@ class HomeAssistantHTTP: self.supervisor_unix_socket_path, err, ) - if self.site is not None: - await self.site.stop() + if self._server is not None: + self._server.close() + await self._server.wait_closed() if self.runner is not None: await self.runner.cleanup() - - -async def start_http_server_and_save_config( - hass: HomeAssistant, conf: dict, server: HomeAssistantHTTP -) -> None: - """Startup the http server and save the config.""" - await server.start() - - # If we are set up successful, we store the HTTP settings for recovery mode. - store: storage.Store[dict[str, Any]] = storage.Store( - hass, STORAGE_VERSION, STORAGE_KEY - ) - - if CONF_TRUSTED_PROXIES in conf: - conf[CONF_TRUSTED_PROXIES] = [ - str(cast(IPv4Network | IPv6Network, ip).network_address) - for ip in conf[CONF_TRUSTED_PROXIES] - ] - - store.async_delay_save(lambda: conf, SAVE_DELAY) diff --git a/homeassistant/components/http/config.py b/homeassistant/components/http/config.py new file mode 100644 index 000000000000..3406ad4d793f --- /dev/null +++ b/homeassistant/components/http/config.py @@ -0,0 +1,425 @@ +"""User-managed HTTP configuration store.""" + +import asyncio +from datetime import datetime, timedelta +from ipaddress import ip_network +import logging +import os +from typing import Any, Final, TypedDict, cast, override + +import voluptuous as vol + +from homeassistant.const import SERVER_PORT +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey + +from .const import ( + CONF_BASE_URL, + CONF_CORS_ORIGINS, + CONF_IP_BAN_ENABLED, + CONF_LOGIN_ATTEMPTS_THRESHOLD, + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + CONF_SSL_PEER_CERTIFICATE, + CONF_SSL_PROFILE, + CONF_TRUSTED_PROXIES, + CONF_USE_X_FORWARDED_FOR, + CONF_USE_X_FRAME_OPTIONS, + DEFAULT_CORS, + DOMAIN, + ENV_SETUP_PORT, + NO_LOGIN_ATTEMPT_THRESHOLD, + SSL_INTERMEDIATE, + SSL_MODERN, +) + +_LOGGER = logging.getLogger(__name__) + + +def default_server_port() -> int: + """Return the default HTTP server port. + + The built-in default port can be overridden via the + ``SETUP_PORT`` environment variable. An invalid value is ignored in favor + of the built-in default. + """ + if (env_value := os.environ.get(ENV_SETUP_PORT)) is None: + return SERVER_PORT + try: + return cast(int, cv.port(env_value)) + except vol.Invalid: + _LOGGER.warning( + "Invalid port %r in %s environment variable; falling back to %s", + env_value, + ENV_SETUP_PORT, + SERVER_PORT, + ) + return SERVER_PORT + + +STORAGE_KEY: Final = DOMAIN +STORAGE_VERSION: Final = 2 + +KEY_STABLE: Final = "stable" +KEY_PENDING: Final = "pending" +KEY_YAML_MIGRATION_DONE: Final = "yaml_migration_done" + +AUTO_REVERT_DELAY: Final = timedelta(minutes=5) + +DATA_STORE: HassKey[HTTPConfigStore] = HassKey(STORAGE_KEY) + + +class ConfData(TypedDict, total=False): + """Typed dict for the validated HTTP config (matches ``HTTP_STORAGE_SCHEMA``).""" + + server_host: list[str] + server_port: int + ssl_certificate: str + ssl_peer_certificate: str + ssl_key: str + cors_allowed_origins: list[str] + use_x_forwarded_for: bool + trusted_proxies: list[str] + login_attempts_threshold: int + ip_ban_enabled: bool + ssl_profile: str + use_x_frame_options: bool + + +class _HTTPStoreData(TypedDict): + """Data structure for HTTP config storage.""" + + stable: ConfData + pending: ConfData | None + yaml_migration_done: bool + + +def _ip_network_str(value: Any) -> str: + """Validate the value is a valid IP network and return its string form.""" + return str(ip_network(value)) + + +HTTP_STORAGE_SCHEMA: Final = vol.Schema( + { + # YAML used to allow base_url (deprecated); strip it on the way in so + # the stored config never contains it. + vol.Remove(CONF_BASE_URL): object, + vol.Optional(CONF_SERVER_HOST): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), + vol.Optional(CONF_SERVER_PORT, default=default_server_port): cv.port, + vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_KEY): cv.isfile, + vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean, + vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All( + cv.ensure_list, [_ip_network_str] + ), + vol.Optional( + CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD + ): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), + vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In( + [SSL_INTERMEDIATE, SSL_MODERN] + ), + vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, + } +) +_DEFAULT_CONFIG: Final[ConfData] = cast(ConfData, HTTP_STORAGE_SCHEMA({})) + + +async def async_load_config(hass: HomeAssistant, config: ConfigType) -> ConfData: + """Load the HTTP config to apply on this startup. + + YAML config is only migrated once. Subsequent boots will ignore YAML and + use the store exclusively. + + Resolution order: + - Recovery mode: always use ``stable`` so HA stays reachable after a bad + config; YAML is ignored entirely (any pending YAML migration is + deferred to the next normal boot). + - Normal mode: prefer ``pending`` if set, otherwise ``stable``. + """ + store = await async_get_and_load_store(hass) + if hass.config.recovery_mode: + _LOGGER.info("Recovery mode active; using stable HTTP config") + return store.stable + + yaml_conf: ConfData | None = config.get(DOMAIN) + if store.yaml_migration_done: + if yaml_conf is not None: + # YAML is still present after migration completed; surface a repair + # issue so the user knows their YAML is being ignored. + ir.async_create_issue( + hass, + DOMAIN, + "yaml_still_present_after_migration", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="yaml_still_present_after_migration", + ) + else: + # Clear any leftover deprecation issues if YAML was removed after migration. + ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml_import_error") + ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml") + ir.async_delete_issue(hass, DOMAIN, "yaml_still_present_after_migration") + else: + # Migrate YAML to storage and use it directly for this start. The + # migration function also marks the migration as done so future + # starts will ignore any remaining YAML. + conf_in_yaml = yaml_conf is not None + if yaml_conf is None: + yaml_conf = cast(ConfData, HTTP_STORAGE_SCHEMA({})) + + try: + await store.async_migrate_yaml(yaml_conf) + except Exception: + _LOGGER.exception("Failed to migrate HTTP YAML configuration to storage") + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_error", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="deprecated_yaml_import_error", + ) + else: + if conf_in_yaml: + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2027.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + if store.pending is not None: + _LOGGER.info("Using pending HTTP config") + store.async_schedule_revert_to_stable() + return store.pending + + _LOGGER.info("Using stable HTTP config") + return store.stable + + +async def async_get_and_load_store(hass: HomeAssistant) -> HTTPConfigStore: + """Return the singleton HTTP config store and load it.""" + if (store := hass.data.get(DATA_STORE)) is None: + store = HTTPConfigStore(hass) + hass.data[DATA_STORE] = store + await store.async_load() + return store + + +class HTTPConfigStore: + """Persist HTTP config as a stable/pending pair. + + ``stable`` holds the last config the user confirmed as working; + ``pending`` holds an unconfirmed config the user wants to try on + the next start. Normal startup prefers ``pending`` so the new + config gets exercised; recovery mode falls back to ``stable`` so + Home Assistant can still come up after a bad config. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the store.""" + self._hass = hass + self._store = _HTTPStore( + hass, + STORAGE_VERSION, + STORAGE_KEY, + private=True, + atomic_writes=True, + ) + self._stable: ConfData = _DEFAULT_CONFIG + self._pending: ConfData | None = None + self._yaml_migration_done = False + self._loaded = False + self._load_lock = asyncio.Lock() + self._revert_unsub: CALLBACK_TYPE | None = None + self._revert_deadline: datetime | None = None + + @property + def stable(self) -> ConfData: + """Return the last confirmed-working config.""" + return self._stable + + @property + def pending(self) -> ConfData | None: + """Return the unconfirmed config awaiting promotion, if any.""" + return self._pending + + @property + def revert_deadline(self) -> datetime | None: + """Return when the pending config auto-reverts to stable, if scheduled.""" + return self._revert_deadline + + @property + def yaml_migration_done(self) -> bool: + """Return whether the YAML migration has been completed.""" + return self._yaml_migration_done + + async def async_load(self) -> None: + """Load the stable and pending configs from disk.""" + if self._loaded: + return + async with self._load_lock: + if self._loaded: + # Another coroutine may have loaded the config while we were waiting + # for the lock; check again to avoid unnecessary disk I/O. + return # type: ignore[unreachable] + raw = await self._store.async_load() + if raw is not None: + self._stable = raw[KEY_STABLE] + self._pending = raw[KEY_PENDING] + self._yaml_migration_done = raw[KEY_YAML_MIGRATION_DONE] + self._loaded = True + + async def async_set_pending(self, config: ConfData | None) -> None: + """Set (or clear) the pending config.""" + await self.async_load() + if config == self.stable: + # No need to save a pending config that is the same as stable. + config = None + self._pending = config + await self._async_persist() + + async def async_promote_pending(self) -> None: + """Promote the pending config to stable. + + Raises ``HomeAssistantError`` if there is nothing to promote. + """ + await self.async_load() + if self._pending is None: + raise HomeAssistantError("No pending HTTP config to promote") + self._stable = self._pending + self._pending = None + # The config is now confirmed; no need to revert it anymore. + self._async_cancel_revert() + await self._async_persist() + + @callback + def async_schedule_revert_to_stable(self) -> None: + """Schedule reverting the pending config back to stable. + + Loading a pending config is a trial. If the user does not promote it + within ``AUTO_REVERT_DELAY`` (e.g. because the new config made Home + Assistant unreachable), automatically clear it and restart so the last + known-good stable config is restored. + """ + self._async_cancel_revert() + self._revert_deadline = dt_util.utcnow() + AUTO_REVERT_DELAY + self._revert_unsub = async_call_later( + self._hass, + AUTO_REVERT_DELAY, + HassJob( + self._async_revert_to_stable, + "http config auto-revert", + cancel_on_shutdown=True, + ), + ) + + @callback + def _async_cancel_revert(self) -> None: + """Cancel a scheduled revert, if any. + + Also clears the deadline so ``revert_deadline`` no longer reports a + revert that will not happen (e.g. after the config is promoted). + """ + if self._revert_unsub is not None: + self._revert_unsub() + self._revert_unsub = None + self._revert_deadline = None + + async def _async_revert_to_stable(self, _now: datetime) -> None: + """Clear the unconfirmed pending config and restart to apply stable.""" + self._async_cancel_revert() + if self._pending is None: + return + _LOGGER.warning( + "Pending HTTP config was not confirmed within %s; reverting to the " + "stable config and restarting", + AUTO_REVERT_DELAY, + ) + self._pending = None + await self._async_persist() + # Imported here to avoid a circular import at module load time. + from homeassistant.components.homeassistant import ( # noqa: PLC0415 + DOMAIN as HASS_DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, + ) + + await self._hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART) + + async def async_abort_trial(self) -> None: + """Abort the running pending-config trial and reinstate stable. + + Called during setup when the pending config cannot be applied at all + (its address cannot be bound or its SSL configuration is unusable). + Clears the pending config so this and future starts use stable. + """ + await self.async_load() + self._async_cancel_revert() + self._pending = None + await self._async_persist() + + async def async_migrate_yaml(self, config: ConfData) -> None: + """Migrate YAML config to storage as pending if not the same as the config used for recovery.""" + await self.async_load() + validated_config = cast(ConfData, HTTP_STORAGE_SCHEMA(config)) + self._pending = None if validated_config == self._stable else validated_config + self._yaml_migration_done = True + await self._async_persist() + + async def _async_persist(self) -> None: + """Write the current state to disk (or remove the file if empty).""" + await self._store.async_save( + { + KEY_STABLE: self._stable, + KEY_PENDING: self._pending, + KEY_YAML_MIGRATION_DONE: self._yaml_migration_done, + } + ) + + +class _HTTPStore(Store[_HTTPStoreData]): + """Http store.""" + + @override + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + if old_major_version == 1: + # Run the v1 payload through the storage schema so the v2 ``stable`` + # slot is well-formed (all keys present, values normalised) and the + # load step can rely on direct key access. + try: + stable = HTTP_STORAGE_SCHEMA(old_data) + except vol.Invalid: + _LOGGER.warning( + "Discarding invalid v1 HTTP config during migration; " + "falling back to defaults" + ) + stable = _DEFAULT_CONFIG + return { + KEY_STABLE: stable, + KEY_PENDING: None, + KEY_YAML_MIGRATION_DONE: False, + } + return old_data diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index c89751a62aff..415d9237e4a8 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -10,15 +10,54 @@ DOMAIN: Final = "http" KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" +KEY_SUPERVISOR_UNIX_SOCKET: Final = "ha_supervisor_unix_socket" + +CONF_SERVER_HOST: Final = "server_host" +CONF_SERVER_PORT: Final = "server_port" +CONF_BASE_URL: Final = "base_url" +CONF_SSL_CERTIFICATE: Final = "ssl_certificate" +CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate" +CONF_SSL_KEY: Final = "ssl_key" +CONF_CORS_ORIGINS: Final = "cors_allowed_origins" +CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for" +CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options" +CONF_TRUSTED_PROXIES: Final = "trusted_proxies" +CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" +CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" +CONF_SSL_PROFILE: Final = "ssl_profile" + +SSL_MODERN: Final = "modern" +SSL_INTERMEDIATE: Final = "intermediate" + +ENV_SETUP_PORT: Final = "SETUP_PORT" + +# Cast to be able to load custom cards. +# My to be able to check url and version info. +DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"] +NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1 + +ATTR_CONFIG = "config" def is_supervisor_unix_socket_request(request: Request) -> bool: - """Check if request arrived over the Supervisor Unix socket.""" - if (transport := request.transport) is None: - return False - if (http := request.app[KEY_HASS].http) is None or ( - supervisor_path := http.supervisor_unix_socket_path - ) is None: - return False - sockname: str | None = transport.get_extra_info("sockname") - return sockname == str(supervisor_path) + """Check if request arrived over the Supervisor Unix socket. + + The result is cached on the request since it is checked by both the ban + and auth middlewares. + """ + cached: bool | None = request.get(KEY_SUPERVISOR_UNIX_SOCKET) + if cached is not None: + return cached + # Cheapest check first: without a configured socket path this can never be + # a Supervisor Unix socket request, so we avoid probing the transport. + if ( + (http := request.app[KEY_HASS].http) is None + or (supervisor_path := http.supervisor_unix_socket_path) is None + or (transport := request.transport) is None + ): + result = False + else: + sockname: str | None = transport.get_extra_info("sockname") + result = sockname == str(supervisor_path) + request[KEY_SUPERVISOR_UNIX_SOCKET] = result + return result diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index 98df4a815d3e..a1a7e23423f0 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -55,11 +55,17 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" - path_with_query_string = f"{request.path}?{request.query_string}" + query_string = request.query_string + # Most requests (WebSocket/API traffic) have no query string; avoid the + # concat and only scan the path in that case. + if query_string: + path_with_query_string = f"{request.path}?{query_string}" + else: + path_with_query_string = request.path for unsafe_byte in UNSAFE_URL_BYTES: if unsafe_byte in path_with_query_string: - if unsafe_byte in request.query_string: + if unsafe_byte in query_string: _LOGGER.warning( "Filtered a request with unsafe byte query string: %s", request.raw_path, @@ -75,7 +81,7 @@ def setup_security_filter(app: Application) -> None: # Check the full path with query string first, if its # a hit, than check just the query string to give a more # specific warning. - if FILTERS.search(_recursive_unquote(request.query_string)): + if FILTERS.search(_recursive_unquote(query_string)): _LOGGER.warning( "Filtered a request with a potential harmful query string: %s", request.raw_path, diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json index b74cfd457b22..2ef22ae18052 100644 --- a/homeassistant/components/http/strings.json +++ b/homeassistant/components/http/strings.json @@ -1,5 +1,13 @@ { "issues": { + "deprecated_yaml": { + "description": "Your existing HTTP configuration from `configuration.yaml` has been imported. The `http` integration is now configured from the UI under **Settings** > **System** > **Network**.\n\nPlease remove the `http:` block from your `configuration.yaml` and restart Home Assistant.", + "title": "The HTTP YAML configuration is deprecated" + }, + "deprecated_yaml_import_error": { + "description": "Migrating the `http` configuration from `configuration.yaml` to the integration's storage failed. Please check the logs for details and configure the `http` integration from the UI under **Settings** > **System** > **Network**.", + "title": "Failed to import HTTP YAML configuration" + }, "server_host_deprecated_hassio": { "description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.", "title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication" @@ -7,6 +15,10 @@ "ssl_configured_without_configured_urls": { "description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.", "title": "SSL is configured without an external URL or internal URL" + }, + "yaml_still_present_after_migration": { + "description": "The HTTP configuration in `configuration.yaml` has already been migrated and is now being ignored. Please remove the `http:` block from your `configuration.yaml`. Manage the HTTP configuration from the UI under **Settings** > **System** > **Network**.", + "title": "HTTP YAML configuration is ignored after migration" } } } diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 0348021e1382..fd07e2df66b8 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -3,74 +3,9 @@ import asyncio from pathlib import Path import socket -from ssl import SSLContext from typing import override from aiohttp import web -from yarl import URL - - -class HomeAssistantTCPSite(web.BaseSite): - """HomeAssistant specific aiohttp Site. - - Vanilla TCPSite accepts only str as host. However, the underlying asyncio's - create_server() implementation does take a list of strings to bind to multiple - host IP's. To support multiple server_host entries (e.g. to enable dual-stack - explicitly), we would like to pass an array of strings. Bring our own - implementation inspired by TCPSite. - - Custom TCPSite can be dropped when https://github.com/aio-libs/aiohttp/pull/4894 - is merged. - """ - - __slots__ = ("_host", "_hosturl", "_port", "_reuse_address", "_reuse_port") - - def __init__( - self, - runner: web.BaseRunner, - host: str | list[str] | None, - port: int, - *, - ssl_context: SSLContext | None = None, - backlog: int = 128, - reuse_address: bool | None = None, - reuse_port: bool | None = None, - ) -> None: - """Initialize HomeAssistantTCPSite.""" - super().__init__( - runner, - ssl_context=ssl_context, - backlog=backlog, - ) - self._host = host - self._port = port - self._reuse_address = reuse_address - self._reuse_port = reuse_port - - @property - @override - def name(self) -> str: - """Return server URL.""" - scheme = "https" if self._ssl_context else "http" - host = self._host[0] if isinstance(self._host, list) else "0.0.0.0" - return str(URL.build(scheme=scheme, host=host, port=self._port)) - - @override - async def start(self) -> None: - """Start server.""" - await super().start() - loop = asyncio.get_running_loop() - server = self._runner.server - assert server is not None - self._server = await loop.create_server( - server, - self._host, - self._port, - ssl=self._ssl_context, - backlog=self._backlog, - reuse_address=self._reuse_address, - reuse_port=self._reuse_port, - ) class HomeAssistantUnixSite(web.BaseSite): diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py new file mode 100644 index 000000000000..9aff44f1f682 --- /dev/null +++ b/homeassistant/components/http/websocket_api.py @@ -0,0 +1,107 @@ +"""WebSocket API for the HTTP integration user config.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.homeassistant import ( + DOMAIN as HASS_DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from .config import HTTP_STORAGE_SCHEMA, async_get_and_load_store +from .const import ATTR_CONFIG + + +@callback +def async_register_websocket_commands(hass: HomeAssistant) -> None: + """Register the HTTP config websocket commands.""" + websocket_api.async_register_command(hass, websocket_get_config) + websocket_api.async_register_command(hass, websocket_set_config) + websocket_api.async_register_command(hass, websocket_promote_config) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "http/config"}) +@websocket_api.async_response +async def websocket_get_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return the HTTP configuration. + + ``stable`` is the confirmed-working config + ``pending`` is an unconfirmed config awaiting promotion, or ``None``. + ``revert_at`` is when an unconfirmed pending config auto-reverts to + stable, or ``None`` when no revert is scheduled. + """ + store = await async_get_and_load_store(hass) + connection.send_result( + msg["id"], + { + "stable": store.stable, + "pending": store.pending, + "revert_at": store.revert_deadline, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "http/config/configure", + vol.Required(ATTR_CONFIG): vol.Any(None, HTTP_STORAGE_SCHEMA), + } +) +@websocket_api.async_response +async def websocket_set_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Store a new pending HTTP configuration and restart to apply it. + + Restart whenever the pending slot changes, so the runtime config is + refreshed. The result reports whether a restart was triggered via + ``{"restart": bool}``. + """ + store = await async_get_and_load_store(hass) + previous_pending = store.pending + await store.async_set_pending(msg[ATTR_CONFIG]) + restart = store.pending != previous_pending + connection.send_result(msg["id"], {"restart": restart}) + + if restart: + await hass.services.async_call(HASS_DOMAIN, SERVICE_HOMEASSISTANT_RESTART) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "http/config/promote"}) +@websocket_api.async_response +async def websocket_promote_config( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Promote the pending HTTP config to stable. + + Called by the user after they have verified Home Assistant is + working correctly with the pending config. The stable config is + the one used by recovery mode, so promotion must be explicit. + """ + store = await async_get_and_load_store(hass) + try: + await store.async_promote_pending() + except HomeAssistantError as err: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_ALLOWED, + str(err), + ) + return + + connection.send_result(msg["id"]) diff --git a/homeassistant/components/humidifier/condition.py b/homeassistant/components/humidifier/condition.py index ac9e37270a72..55ddc6eb3044 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -4,14 +4,7 @@ from typing import TYPE_CHECKING, override import voluptuous as vol -from homeassistant.const import ( - ATTR_MODE, - CONF_MODE, - CONF_OPTIONS, - PERCENTAGE, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import CONF_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -27,11 +20,10 @@ from homeassistant.helpers.condition import ( from homeassistant.helpers.entity import get_supported_features from .const import ( - ATTR_ACTION, - ATTR_HUMIDITY, DOMAIN, HumidifierAction, HumidifierEntityFeature, + HumidifierEntityStateAttribute, ) IS_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( @@ -54,7 +46,9 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo class IsTargetHumidityCondition(EntityNumericalConditionBase): """Condition for humidifier target humidity.""" - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.HUMIDITY) + } _valid_unit = PERCENTAGE @override @@ -62,14 +56,17 @@ class IsTargetHumidityCondition(EntityNumericalConditionBase): """Skip humidifier entities that do not expose a target humidity.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_HUMIDITY) is not None + and state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY) + is not None ) class IsModeCondition(EntityStateConditionBase): """Condition for humidifier mode.""" - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.MODE) + } _schema = IS_MODE_CONDITION_SCHEMA def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: @@ -94,10 +91,12 @@ CONDITIONS: dict[str, type[Condition]] = { "is_off": make_entity_state_condition(DOMAIN, STATE_OFF), "is_on": make_entity_state_condition(DOMAIN, STATE_ON), "is_drying": make_entity_state_condition( - {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING + {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)}, + HumidifierAction.DRYING, ), "is_humidifying": make_entity_state_condition( - {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING + {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)}, + HumidifierAction.HUMIDIFYING, ), "is_mode": IsModeCondition, "is_target_humidity": IsTargetHumidityCondition, diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index d3ae95cfc5d0..3e2124e129a3 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -122,7 +122,12 @@ async def async_get_action_capabilities( hass, config[CONF_ENTITY_ID] ) available_modes = ( - get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or [] + get_capability( + hass, + entry.entity_id, + const.HumidifierEntityCapabilityAttribute.AVAILABLE_MODES, + ) + or [] ) except HomeAssistantError: available_modes = [] diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index f3ac0c116058..193f3f54d3ca 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -42,6 +42,11 @@ MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( CONDITION_SCHEMA = vol.Any(TOGGLE_CONDITION, MODE_CONDITION) +# Maps a state attribute to the condition config key used to compare against it. +_STATE_ATTRIBUTE_TO_CONFIG_KEY = { + const.HumidifierEntityStateAttribute.MODE: ATTR_MODE, +} + async def async_get_conditions( hass: HomeAssistant, device_id: str @@ -77,7 +82,7 @@ def async_condition_from_config( ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" if config[CONF_TYPE] == "is_mode": - attribute = ATTR_MODE + attribute = const.HumidifierEntityStateAttribute.MODE else: return toggle_entity.async_condition_from_config(hass, config) @@ -89,7 +94,8 @@ def async_condition_from_config( return ( entity_id is not None and (state := hass.states.get(entity_id)) is not None - and state.attributes.get(attribute) == config[attribute] + and state.attributes.get(attribute) + == config[_STATE_ATTRIBUTE_TO_CONFIG_KEY[attribute]] ) return test_is_state @@ -109,7 +115,12 @@ async def async_get_condition_capabilities( hass, config[CONF_ENTITY_ID] ) modes = ( - get_capability(hass, entry.entity_id, const.ATTR_AVAILABLE_MODES) or [] + get_capability( + hass, + entry.entity_id, + const.HumidifierEntityCapabilityAttribute.AVAILABLE_MODES, + ) + or [] ) except HomeAssistantError: modes = [] diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index 7fe5adac40fb..bfd2fe00844a 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -25,7 +25,8 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import ATTR_CURRENT_HUMIDITY, DOMAIN +from . import DOMAIN +from .const import HumidifierEntityStateAttribute # mypy: disallow-any-generics @@ -94,7 +95,10 @@ async def async_get_triggers( } ) - if state and ATTR_CURRENT_HUMIDITY in state.attributes: + if ( + state + and HumidifierEntityStateAttribute.CURRENT_HUMIDITY in state.attributes + ): triggers.append( { **base_trigger, diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index 48bafdb1113c..70ed62260155 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent from . import ( - ATTR_AVAILABLE_MODES, ATTR_HUMIDITY, DOMAIN, SERVICE_SET_HUMIDITY, @@ -17,6 +16,7 @@ from . import ( SERVICE_TURN_ON, HumidifierEntityFeature, ) +from .const import HumidifierEntityCapabilityAttribute INTENT_HUMIDITY = "HassHumidifierSetpoint" INTENT_MODE = "HassHumidifierMode" @@ -117,7 +117,10 @@ class SetModeHandler(intent.IntentHandler): intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes") mode = slots["mode"]["value"] - if mode not in (state.attributes.get(ATTR_AVAILABLE_MODES) or []): + if mode not in ( + state.attributes.get(HumidifierEntityCapabilityAttribute.AVAILABLE_MODES) + or [] + ): raise intent.IntentHandleError( f"Entity {state.name} does not support {mode} mode" ) diff --git a/homeassistant/components/humidifier/llm.py b/homeassistant/components/humidifier/llm.py new file mode 100644 index 000000000000..d799345b7e76 --- /dev/null +++ b/homeassistant/components/humidifier/llm.py @@ -0,0 +1,35 @@ +"""LLM tools for the humidifier integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import INTENT_HUMIDITY, INTENT_MODE + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_MODE, INTENT_HUMIDITY) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/humidifier/reproduce_state.py b/homeassistant/components/humidifier/reproduce_state.py index 45941f327374..f4eb37d33a1b 100644 --- a/homeassistant/components/humidifier/reproduce_state.py +++ b/homeassistant/components/humidifier/reproduce_state.py @@ -14,10 +14,22 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, State -from .const import ATTR_HUMIDITY, DOMAIN, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE +from .const import ( + ATTR_HUMIDITY, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + HumidifierEntityStateAttribute, +) _LOGGER = logging.getLogger(__name__) +# Maps a state attribute to the service call argument used to restore it. +_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[HumidifierEntityStateAttribute, str] = { + HumidifierEntityStateAttribute.MODE: ATTR_MODE, + HumidifierEntityStateAttribute.HUMIDITY: ATTR_HUMIDITY, +} + async def _async_reproduce_states( hass: HomeAssistant, @@ -31,12 +43,16 @@ async def _async_reproduce_states( _LOGGER.warning("Unable to find entity %s", state.entity_id) return - async def call_service(service: str, keys: Iterable[str]) -> None: - """Call service with set of attributes given.""" + async def call_service( + service: str, attributes: Iterable[HumidifierEntityStateAttribute] + ) -> None: + """Call service with the given state attributes.""" data = {"entity_id": state.entity_id} - for key in keys: - if key in state.attributes: - data[key] = state.attributes[key] + for attribute in attributes: + if attribute in state.attributes: + data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = state.attributes[ + attribute + ] await hass.services.async_call( DOMAIN, service, data, blocking=True, context=context @@ -66,16 +82,18 @@ async def _async_reproduce_states( # Then set the mode before target humidity, because switching modes # may invalidate target humidity - if ATTR_MODE in state.attributes and state.attributes[ - ATTR_MODE - ] != cur_state.attributes.get(ATTR_MODE): - await call_service(SERVICE_SET_MODE, [ATTR_MODE]) + if HumidifierEntityStateAttribute.MODE in state.attributes and state.attributes[ + HumidifierEntityStateAttribute.MODE + ] != cur_state.attributes.get(HumidifierEntityStateAttribute.MODE): + await call_service(SERVICE_SET_MODE, [HumidifierEntityStateAttribute.MODE]) # Next, restore target humidity for the current mode - if ATTR_HUMIDITY in state.attributes and state.attributes[ - ATTR_HUMIDITY - ] != cur_state.attributes.get(ATTR_HUMIDITY): - await call_service(SERVICE_SET_HUMIDITY, [ATTR_HUMIDITY]) + if HumidifierEntityStateAttribute.HUMIDITY in state.attributes and state.attributes[ + HumidifierEntityStateAttribute.HUMIDITY + ] != cur_state.attributes.get(HumidifierEntityStateAttribute.HUMIDITY): + await call_service( + SERVICE_SET_HUMIDITY, [HumidifierEntityStateAttribute.HUMIDITY] + ) async def async_reproduce_states( diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py index c8d588512a04..ff61fc6962e8 100644 --- a/homeassistant/components/humidifier/significant_change.py +++ b/homeassistant/components/humidifier/significant_change.py @@ -8,13 +8,13 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE +from .const import HumidifierEntityStateAttribute SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_ACTION, - ATTR_CURRENT_HUMIDITY, - ATTR_HUMIDITY, - ATTR_MODE, + HumidifierEntityStateAttribute.ACTION, + HumidifierEntityStateAttribute.CURRENT_HUMIDITY, + HumidifierEntityStateAttribute.HUMIDITY, + HumidifierEntityStateAttribute.MODE, } @@ -40,7 +40,10 @@ def async_check_significant_change( changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name in [ATTR_ACTION, ATTR_MODE]: + if attr_name in [ + HumidifierEntityStateAttribute.ACTION, + HumidifierEntityStateAttribute.MODE, + ]: return True old_attr_value = old_attrs.get(attr_name) diff --git a/homeassistant/components/humidifier/trigger.py b/homeassistant/components/humidifier/trigger.py index 23c4e5476b25..3b01529de6b5 100644 --- a/homeassistant/components/humidifier/trigger.py +++ b/homeassistant/components/humidifier/trigger.py @@ -4,7 +4,7 @@ from typing import override import voluptuous as vol -from homeassistant.const import ATTR_MODE, CONF_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON +from homeassistant.const import CONF_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -18,7 +18,12 @@ from homeassistant.helpers.trigger import ( make_entity_target_state_trigger, ) -from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature +from .const import ( + DOMAIN, + HumidifierAction, + HumidifierEntityFeature, + HumidifierEntityStateAttribute, +) MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend( { @@ -40,7 +45,9 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo class ModeChangedTrigger(EntityTargetStateTriggerBase): """Trigger for humidifier mode changes.""" - _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)} + _domain_specs = { + DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.MODE) + } _schema = MODE_CHANGED_TRIGGER_SCHEMA def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: @@ -62,10 +69,12 @@ class ModeChangedTrigger(EntityTargetStateTriggerBase): TRIGGERS: dict[str, type[Trigger]] = { "mode_changed": ModeChangedTrigger, "started_drying": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING + {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)}, + HumidifierAction.DRYING, ), "started_humidifying": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING + {DOMAIN: DomainSpec(value_source=HumidifierEntityStateAttribute.ACTION)}, + HumidifierAction.HUMIDIFYING, ), "turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF), "turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON), diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 35c955fa5c95..0cd4816f379d 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -139,7 +139,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): @override def available(self) -> bool: """Return True if shade position data is available.""" - return super().available and self.positions.primary is not None + return super().available and self._is_position_available + + @property + def _is_position_available(self) -> bool: + """Return if the cover contains positional data.""" + return self.positions.primary is not None @property @override @@ -567,9 +572,9 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): @property @override - def available(self) -> bool: - """Return True if shade position data is available.""" - return super().available and self.positions.tilt is not None + def _is_position_available(self) -> bool: + """Return if the cover contains positional data.""" + return self.positions.tilt is not None class PowerViewShadeTopDown(PowerViewShadeBase): diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index eb90737faba3..89a04a4b143d 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -3,9 +3,11 @@ from dataclasses import asdict from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -75,7 +77,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, # Gather information how this device is represented in Home Assistant entity_registry = er.async_get(hass) - data = async_redact_data(attr.asdict(device), REDACT_CONFIG) + data = async_redact_data(device_entry_as_dict(device), REDACT_CONFIG) data["entities"] = [] entities: list[dict[str, Any]] = data["entities"] diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index c6f19c79e252..670ad3bcb54d 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -142,17 +142,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): if removed_zones := previous_zones - current_zones: LOGGER.debug("Removed zones: %s", ", ".join(removed_zones)) for zone_id in removed_zones: - device_registry.async_update_device( - device_id=previous_zones_by_id[zone_id].id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(previous_zones_by_id[zone_id].id) if removed_controllers := previous_controllers - current_controllers: LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers)) for controller_id in removed_controllers: - device_registry.async_update_device( - device_id=previous_controllers_by_id[controller_id].id, - remove_config_entry_id=self.config_entry.entry_id, + device_registry.async_remove_device( + previous_controllers_by_id[controller_id].id ) if new_controller_ids := current_controllers - previous_controllers: diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index d98ec46d1daf..8c04d071d402 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -16,7 +16,7 @@ from pyicloud.services.findmyiphone import AppleDevice from homeassistant.components.zone import async_active_zone from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EntityStateAttribute from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send @@ -256,8 +256,8 @@ class IcloudAccount: for zone_state in zones: if zone_state is None: continue - zone_state_lat = zone_state.attributes[DEVICE_LOCATION_LATITUDE] - zone_state_long = zone_state.attributes[DEVICE_LOCATION_LONGITUDE] + zone_state_lat = zone_state.attributes[EntityStateAttribute.LATITUDE] + zone_state_long = zone_state.attributes[EntityStateAttribute.LONGITUDE] zone_distance = distance( device.location[DEVICE_LOCATION_LATITUDE], device.location[DEVICE_LOCATION_LONGITUDE], diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 8379e224a0a0..ad8824f23300 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==12.2.0"] + "requirements": ["Pillow==12.3.0"] } diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 2ff27cbb0259..8cc18fbd7dd0 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["imgw_pib==2.4.0"] + "requirements": ["imgw_pib==2.4.3"] } diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index d0974f3e3bb2..1f438e8e3d12 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["aioimmich"], "quality_scale": "platinum", - "requirements": ["aioimmich==0.15.1"] + "requirements": ["aioimmich==0.16.1"] } diff --git a/homeassistant/components/imou/button.py b/homeassistant/components/imou/button.py index 972dee03f3c3..dd7242ae2bc7 100644 --- a/homeassistant/components/imou/button.py +++ b/homeassistant/components/imou/button.py @@ -5,7 +5,11 @@ from typing import override from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,15 +27,6 @@ PARAM_PTZ_DOWN = "ptz_down" PARAM_PTZ_LEFT = "ptz_left" PARAM_PTZ_RIGHT = "ptz_right" -BUTTON_TYPES = ( - PARAM_RESTART_DEVICE, - PARAM_MUTE, - PARAM_PTZ_UP, - PARAM_PTZ_DOWN, - PARAM_PTZ_LEFT, - PARAM_PTZ_RIGHT, -) - PTZ_BUTTON_TYPES = ( PARAM_PTZ_UP, PARAM_PTZ_DOWN, @@ -39,20 +34,43 @@ PTZ_BUTTON_TYPES = ( PARAM_PTZ_RIGHT, ) -BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = { - PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART, -} +BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key=PARAM_RESTART_DEVICE, + device_class=ButtonDeviceClass.RESTART, + ), + ButtonEntityDescription( + key=PARAM_MUTE, + translation_key=PARAM_MUTE, + ), + ButtonEntityDescription( + key=PARAM_PTZ_UP, + translation_key=PARAM_PTZ_UP, + ), + ButtonEntityDescription( + key=PARAM_PTZ_DOWN, + translation_key=PARAM_PTZ_DOWN, + ), + ButtonEntityDescription( + key=PARAM_PTZ_LEFT, + translation_key=PARAM_PTZ_LEFT, + ), + ButtonEntityDescription( + key=PARAM_PTZ_RIGHT, + translation_key=PARAM_PTZ_RIGHT, + ), +) def _iter_buttons( coordinator: ImouDataUpdateCoordinator, -) -> list[tuple[str, ImouHaDevice]]: - """Return (button_type, device) pairs for supported buttons.""" +) -> list[tuple[ButtonEntityDescription, ImouHaDevice]]: + """Return (description, device) pairs for supported buttons.""" return [ - (button_type, device) + (description, device) for device in coordinator.devices - for button_type in device.buttons - if button_type in BUTTON_TYPES + for description in BUTTON_TYPES + if description.key in device.buttons ] @@ -67,8 +85,8 @@ async def async_setup_entry( def _add_buttons(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouButton(coordinator, button_type, device) - for button_type, device in _iter_buttons(coordinator) + ImouButton(coordinator, description, device) + for description, device in _iter_buttons(coordinator) if imou_device_identifier(device) in device_keys ) @@ -86,17 +104,7 @@ async def async_setup_entry( class ImouButton(ImouEntity, ButtonEntity): """Imou button entity.""" - def __init__( - self, - coordinator: ImouDataUpdateCoordinator, - entity_type: str, - device: ImouHaDevice, - ) -> None: - """Initialize the Imou button entity.""" - super().__init__(coordinator, entity_type, device) - if device_class := BUTTON_DEVICE_CLASS.get(entity_type): - self._attr_device_class = device_class - self._attr_translation_key = None + entity_description: ButtonEntityDescription @override async def async_press(self) -> None: diff --git a/homeassistant/components/imou/camera.py b/homeassistant/components/imou/camera.py index a06a413b80b0..79acdedc9c44 100644 --- a/homeassistant/components/imou/camera.py +++ b/homeassistant/components/imou/camera.py @@ -1,12 +1,17 @@ """Support for Imou camera entities.""" +from dataclasses import dataclass from typing import override from pyimouapi.const import PARAM_HD, PARAM_MOTION_DETECT, PARAM_STATE from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.components.camera import ( + Camera, + CameraEntityDescription, + CameraEntityFeature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,9 +28,25 @@ CAMERA_STREAM_RESOLUTION_SD = "SD" PYIMOUAPI_LIVE_PROTOCOL = "https" PYIMOUAPI_SNAPSHOT_WAIT_SECONDS = 3 -CAMERA_TYPES = ( - ("camera_sd", CAMERA_STREAM_RESOLUTION_SD), - ("camera_hd", PARAM_HD), + +@dataclass(frozen=True, kw_only=True) +class ImouCameraEntityDescription(CameraEntityDescription): + """Describes an Imou camera entity.""" + + resolution: str + + +CAMERA_TYPES: tuple[ImouCameraEntityDescription, ...] = ( + ImouCameraEntityDescription( + key="camera_sd", + translation_key="camera_sd", + resolution=CAMERA_STREAM_RESOLUTION_SD, + ), + ImouCameraEntityDescription( + key="camera_hd", + translation_key="camera_hd", + resolution=PARAM_HD, + ), ) @@ -40,11 +61,11 @@ async def async_setup_entry( def _add_cameras(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouCamera(coordinator, entity_type, device, resolution) + ImouCamera(coordinator, description, device) for device in coordinator.devices if device.channel_id is not None if imou_device_identifier(device) in device_keys - for entity_type, resolution in CAMERA_TYPES + for description in CAMERA_TYPES ) coordinator.new_device_callbacks.append(_add_cameras) @@ -61,19 +82,18 @@ async def async_setup_entry( class ImouCamera(ImouEntity, Camera): """Representation of an Imou camera stream.""" + entity_description: ImouCameraEntityDescription _attr_supported_features = CameraEntityFeature.STREAM def __init__( self, coordinator: ImouDataUpdateCoordinator, - entity_type: str, + description: ImouCameraEntityDescription, device: ImouHaDevice, - resolution: str, ) -> None: """Initialize the camera entity.""" - self._resolution = resolution Camera.__init__(self) - super().__init__(coordinator, entity_type, device) + super().__init__(coordinator, description, device) @override async def stream_source(self) -> str | None: @@ -81,7 +101,7 @@ class ImouCamera(ImouEntity, Camera): try: return await self.coordinator.device_manager.async_get_device_stream( self.device, - self._resolution, + self.entity_description.resolution, PYIMOUAPI_LIVE_PROTOCOL, ) except ImouException as err: diff --git a/homeassistant/components/imou/entity.py b/homeassistant/components/imou/entity.py index ea21763eb946..e9c25f64dbeb 100644 --- a/homeassistant/components/imou/entity.py +++ b/homeassistant/components/imou/entity.py @@ -5,6 +5,7 @@ from typing import override from pyimouapi.ha_device import DeviceStatus, ImouHaDevice from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier @@ -19,15 +20,15 @@ class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]): def __init__( self, coordinator: ImouDataUpdateCoordinator, - entity_type: str, + description: EntityDescription, device: ImouHaDevice, ) -> None: """Initialize the Imou entity.""" super().__init__(coordinator) - self._entity_type = entity_type + self.entity_description = description + self._entity_type = description.key self._device_key = imou_device_identifier(device) - self._attr_unique_id = f"{self._device_key}${entity_type}" - self._attr_translation_key = entity_type + self._attr_unique_id = f"{self._device_key}${description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_key)}, name=device.channel_name or device.device_name, diff --git a/homeassistant/components/imou/manifest.json b/homeassistant/components/imou/manifest.json index 7d25d81e79ed..0ef2893fc15a 100644 --- a/homeassistant/components/imou/manifest.json +++ b/homeassistant/components/imou/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["pyimouapi==1.2.8"] + "requirements": ["pyimouapi==1.3.0"] } diff --git a/homeassistant/components/imou/switch.py b/homeassistant/components/imou/switch.py index caed3462950b..be6b7764127d 100644 --- a/homeassistant/components/imou/switch.py +++ b/homeassistant/components/imou/switch.py @@ -5,7 +5,11 @@ from typing import Any, override from pyimouapi.exceptions import ImouException from pyimouapi.ha_device import ImouHaDevice -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,32 +31,53 @@ from .entity import ImouEntity PARALLEL_UPDATES = 0 -SWITCH_TYPES = ( - PARAM_AB_ALARM_SOUND, - PARAM_AUDIO_ENCODE_CONTROL, - PARAM_CLOSE_CAMERA, - PARAM_HEADER_DETECT, - PARAM_LIGHT, - PARAM_MOTION_DETECT, - PARAM_PLUG_SWITCH, - PARAM_WHITE_LIGHT, +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key=PARAM_AB_ALARM_SOUND, + translation_key=PARAM_AB_ALARM_SOUND, + ), + SwitchEntityDescription( + key=PARAM_AUDIO_ENCODE_CONTROL, + translation_key=PARAM_AUDIO_ENCODE_CONTROL, + ), + SwitchEntityDescription( + key=PARAM_CLOSE_CAMERA, + translation_key=PARAM_CLOSE_CAMERA, + ), + SwitchEntityDescription( + key=PARAM_HEADER_DETECT, + translation_key=PARAM_HEADER_DETECT, + ), + SwitchEntityDescription( + key=PARAM_LIGHT, + translation_key=PARAM_LIGHT, + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=PARAM_MOTION_DETECT, + translation_key=PARAM_MOTION_DETECT, + ), + SwitchEntityDescription( + key=PARAM_PLUG_SWITCH, + translation_key=PARAM_PLUG_SWITCH, + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=PARAM_WHITE_LIGHT, + translation_key=PARAM_WHITE_LIGHT, + ), ) -SWITCH_DEVICE_CLASS: dict[str, SwitchDeviceClass] = { - PARAM_LIGHT: SwitchDeviceClass.SWITCH, - PARAM_PLUG_SWITCH: SwitchDeviceClass.SWITCH, -} - def _iter_switches( coordinator: ImouDataUpdateCoordinator, -) -> list[tuple[str, ImouHaDevice]]: - """Return (switch_type, device) pairs for supported switches.""" +) -> list[tuple[SwitchEntityDescription, ImouHaDevice]]: + """Return (description, device) pairs for supported switches.""" return [ - (switch_type, device) + (description, device) for device in coordinator.devices - for switch_type in device.switches - if switch_type in SWITCH_TYPES + for description in SWITCH_TYPES + if description.key in device.switches ] @@ -67,8 +92,8 @@ async def async_setup_entry( def _add_switches(new_devices: list[ImouHaDevice]) -> None: device_keys = {imou_device_identifier(device) for device in new_devices} async_add_entities( - ImouSwitch(coordinator, switch_type, device) - for switch_type, device in _iter_switches(coordinator) + ImouSwitch(coordinator, description, device) + for description, device in _iter_switches(coordinator) if imou_device_identifier(device) in device_keys ) @@ -86,15 +111,7 @@ async def async_setup_entry( class ImouSwitch(ImouEntity, SwitchEntity): """Imou switch entity.""" - def __init__( - self, - coordinator: ImouDataUpdateCoordinator, - entity_type: str, - device: ImouHaDevice, - ) -> None: - """Initialize the Imou switch entity.""" - super().__init__(coordinator, entity_type, device) - self._attr_device_class = SWITCH_DEVICE_CLASS.get(entity_type) + entity_description: SwitchEntityDescription @property @override diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 09f3b795cbd9..fb11066fbb09 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -39,6 +39,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.data_entry_flow import FlowResultType @@ -252,7 +253,9 @@ def _generate_event_to_json(conf: dict) -> Callable[[Event], dict[str, Any] | No if measurement_attr == "entity_id": measurement = state.entity_id elif measurement_attr == "domain__device_class": - device_class = state.attributes.get("device_class") + device_class = state.attributes.get( + EntityStateAttribute.DEVICE_CLASS + ) if device_class is None: # This entity doesn't have a device_class set, use only domain measurement = state.domain diff --git a/homeassistant/components/infrared/entity.py b/homeassistant/components/infrared/entity.py index 473c3a0d2a32..5fa7c085c9bd 100644 --- a/homeassistant/components/infrared/entity.py +++ b/homeassistant/components/infrared/entity.py @@ -49,6 +49,14 @@ class InfraredEmitterEntity(RestoreEntity): __last_command_sent: str | None = None + @override + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For infrared emitters this is True if the entity has a device class. + """ + return self.device_class is not None + @property @final @override @@ -101,6 +109,14 @@ class InfraredReceiverEntity(RestoreEntity): __last_signal_received: str | None = None + @override + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For infrared receivers this is True if the entity has a device class. + """ + return self.device_class is not None + @cached_property def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]: """Subscriber callback set, lazily initialized on first access.""" diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index c8d05986266a..8958e81d9eba 100644 --- a/homeassistant/components/infrared/manifest.json +++ b/homeassistant/components/infrared/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/infrared", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["infrared-protocols==6.3.1"] + "requirements": ["infrared-protocols==7.5.0"] } diff --git a/homeassistant/components/infrared/strings.json b/homeassistant/components/infrared/strings.json index 09d705d53cc0..3a21a72eac63 100644 --- a/homeassistant/components/infrared/strings.json +++ b/homeassistant/components/infrared/strings.json @@ -1,6 +1,6 @@ { "entity_component": { - "_": { + "emitter": { "name": "Infrared emitter" }, "receiver": { diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 74754be4ed06..68e18e3d1d0f 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -5,7 +5,7 @@ from typing import Any, Self, override import voluptuous as vol -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, CONF_ICON, CONF_ID, @@ -25,6 +25,8 @@ import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import InputBooleanEntityStateAttribute + DOMAIN = "input_boolean" _LOGGER = logging.getLogger(__name__) @@ -146,7 +148,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity): """Representation of a boolean input.""" - _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _unrecorded_attributes = frozenset({InputBooleanEntityStateAttribute.EDITABLE}) _attr_should_poll = False editable: bool @@ -190,7 +192,7 @@ class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity): @override def extra_state_attributes(self) -> dict[str, bool]: """Return the state attributes of the entity.""" - return {ATTR_EDITABLE: self.editable} + return {InputBooleanEntityStateAttribute.EDITABLE: self.editable} @override async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/input_boolean/const.py b/homeassistant/components/input_boolean/const.py new file mode 100644 index 000000000000..c2be02dff532 --- /dev/null +++ b/homeassistant/components/input_boolean/const.py @@ -0,0 +1,9 @@ +"""Constants for the input_boolean integration.""" + +from enum import StrEnum + + +class InputBooleanEntityStateAttribute(StrEnum): + """State attributes for input boolean entities.""" + + EDITABLE = "editable" diff --git a/homeassistant/components/input_datetime/reproduce_state.py b/homeassistant/components/input_datetime/reproduce_state.py index ea087f7d8369..f01ab5ed3cc4 100644 --- a/homeassistant/components/input_datetime/reproduce_state.py +++ b/homeassistant/components/input_datetime/reproduce_state.py @@ -9,7 +9,8 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import Context, HomeAssistant, State from homeassistant.util import dt as dt_util -from . import ATTR_DATE, ATTR_DATETIME, ATTR_TIME, CONF_HAS_DATE, CONF_HAS_TIME, DOMAIN +from . import ATTR_DATE, ATTR_DATETIME, ATTR_TIME, DOMAIN +from .const import InputDatetimeEntityCapabilityAttribute _LOGGER = logging.getLogger(__name__) @@ -44,8 +45,8 @@ async def _async_reproduce_state( _LOGGER.warning("Unable to find entity %s", state.entity_id) return - has_time = cur_state.attributes.get(CONF_HAS_TIME) - has_date = cur_state.attributes.get(CONF_HAS_DATE) + has_time = cur_state.attributes.get(InputDatetimeEntityCapabilityAttribute.HAS_TIME) + has_date = cur_state.attributes.get(InputDatetimeEntityCapabilityAttribute.HAS_DATE) if not ( (is_valid_datetime(state.state) and has_date and has_time) diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 92bd543122e6..8558f203d12a 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -6,8 +6,8 @@ from typing import Any, Self, override import voluptuous as vol -from homeassistant.const import ( - ATTR_EDITABLE, +from homeassistant.components.number import NumberEntity +from homeassistant.const import ( # noqa: F401 ATTR_MODE, CONF_ICON, CONF_ID, @@ -24,6 +24,8 @@ import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import InputNumberEntityStateAttribute + _LOGGER = logging.getLogger(__name__) DOMAIN = "input_number" @@ -149,7 +151,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_VALUE, {vol.Required(ATTR_VALUE): vol.Coerce(float)}, - "async_set_value", + "async_set_native_value", ) component.async_register_entity_service(SERVICE_INCREMENT, None, "async_increment") @@ -199,20 +201,31 @@ class NumberStorageCollection(collection.DictStorageCollection): return {CONF_ID: item[CONF_ID]} | update_data -class InputNumber(collection.CollectionEntity, RestoreEntity): +# pylint: disable-next=home-assistant-enforce-class-module +class InputNumber(collection.CollectionEntity, NumberEntity, RestoreEntity): """Representation of a slider.""" - _unrecorded_attributes = frozenset( - {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP} - ) + _unrecorded_attributes = frozenset({InputNumberEntityStateAttribute.EDITABLE}) _attr_should_poll = False editable: bool def __init__(self, config: ConfigType) -> None: """Initialize an input number.""" - self._config = config - self._current_value: float | None = config.get(CONF_INITIAL) + self._initial_value: float | None = config.get(CONF_INITIAL) + self._attr_native_value = self._initial_value + self._update_config_attributes(config) + + def _update_config_attributes(self, config: ConfigType) -> None: + """Update attributes based on the config.""" + self._attr_icon = config.get(CONF_ICON) + self._attr_mode = config[CONF_MODE] + self._attr_name = config.get(CONF_NAME) + self._attr_native_min_value = config[CONF_MIN] + self._attr_native_max_value = config[CONF_MAX] + self._attr_native_step = config[CONF_STEP] + self._attr_unique_id = config[CONF_ID] + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @classmethod @override @@ -231,69 +244,20 @@ class InputNumber(collection.CollectionEntity, RestoreEntity): input_num.editable = False return input_num - @property - def _minimum(self) -> float: - """Return minimum allowed value.""" - return self._config[CONF_MIN] - - @property - def _maximum(self) -> float: - """Return maximum allowed value.""" - return self._config[CONF_MAX] - - @property - @override - def name(self): - """Return the name of the input slider.""" - return self._config.get(CONF_NAME) - - @property - @override - def icon(self) -> str | None: - """Return the icon to be used for this entity.""" - return self._config.get(CONF_ICON) - - @property - @override - def state(self): - """Return the state of the component.""" - return self._current_value - - @property - def _step(self) -> int: - """Return entity's increment/decrement step.""" - return self._config[CONF_STEP] - - @property - @override - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._config.get(CONF_UNIT_OF_MEASUREMENT) - - @property - @override - def unique_id(self) -> str | None: - """Return unique id of the entity.""" - return self._config[CONF_ID] - @property @override def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { - ATTR_INITIAL: self._config.get(CONF_INITIAL), - ATTR_EDITABLE: self.editable, - ATTR_MIN: self._minimum, - ATTR_MAX: self._maximum, - ATTR_STEP: self._step, - ATTR_MODE: self._config[CONF_MODE], + InputNumberEntityStateAttribute.INITIAL: self._initial_value, + InputNumberEntityStateAttribute.EDITABLE: self.editable, } @override async def async_added_to_hass(self): """Run when entity about to be added to hass.""" await super().async_added_to_hass() - if self._current_value is not None: + if self._attr_native_value is not None: return value: float | None = None @@ -302,39 +266,47 @@ class InputNumber(collection.CollectionEntity, RestoreEntity): value = float(state.state) # Check against None because value can be 0 - if value is not None and self._minimum <= value <= self._maximum: - self._current_value = value + if ( + value is not None + and self.native_min_value <= value <= self.native_max_value + ): + self._attr_native_value = value else: - self._current_value = self._minimum + self._attr_native_value = self.native_min_value - async def async_set_value(self, value): + @override + async def async_set_native_value(self, value): """Set new value.""" num_value = float(value) - if num_value < self._minimum or num_value > self._maximum: + if num_value < self.native_min_value or num_value > self.native_max_value: raise vol.Invalid( - f"Invalid value for {self.entity_id}: {value} (range {self._minimum} -" - f" {self._maximum})" + f"Invalid value for {self.entity_id}: {value} (range " + f"{self.native_min_value} - {self.native_max_value})" ) - self._current_value = num_value + self._attr_native_value = num_value self.async_write_ha_state() async def async_increment(self): """Increment value.""" - await self.async_set_value(min(self._current_value + self._step, self._maximum)) + await self.async_set_native_value( + min(self._attr_native_value + self.native_step, self.native_max_value) + ) async def async_decrement(self): """Decrement value.""" - await self.async_set_value(max(self._current_value - self._step, self._minimum)) + await self.async_set_native_value( + max(self._attr_native_value - self.native_step, self.native_min_value) + ) @override async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" - self._config = config + self._update_config_attributes(config) # just in case min/max values changed - if self._current_value is None: + if self._attr_native_value is None: return - self._current_value = min(self._current_value, self._maximum) - self._current_value = max(self._current_value, self._minimum) + self._attr_native_value = min(self._attr_native_value, self.native_max_value) + self._attr_native_value = max(self._attr_native_value, self.native_min_value) self.async_write_ha_state() diff --git a/homeassistant/components/input_number/const.py b/homeassistant/components/input_number/const.py new file mode 100644 index 000000000000..c052ef8afdaa --- /dev/null +++ b/homeassistant/components/input_number/const.py @@ -0,0 +1,10 @@ +"""Constants for the input_number integration.""" + +from enum import StrEnum + + +class InputNumberEntityStateAttribute(StrEnum): + """State attributes for input number entities.""" + + INITIAL = "initial" + EDITABLE = "editable" diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 7d7c809c68e5..ac220c1a24b6 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -15,8 +15,9 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION, SERVICE_SELECT_PREVIOUS, SelectEntity, + SelectEntityCapabilityAttribute, ) -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, CONF_ICON, CONF_ID, @@ -33,6 +34,8 @@ import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import InputSelectEntityStateAttribute + _LOGGER = logging.getLogger(__name__) DOMAIN = "input_select" @@ -250,9 +253,10 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Representation of a select input.""" _entity_component_unrecorded_attributes = ( - SelectEntity._entity_component_unrecorded_attributes - {ATTR_OPTIONS} # noqa: SLF001 + SelectEntity._entity_component_unrecorded_attributes # noqa: SLF001 + - {SelectEntityCapabilityAttribute.OPTIONS} ) - _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _unrecorded_attributes = frozenset({InputSelectEntityStateAttribute.EDITABLE}) _attr_should_poll = False editable: bool @@ -299,7 +303,7 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): @override def extra_state_attributes(self) -> dict[str, bool]: """Return the state attributes.""" - return {ATTR_EDITABLE: self.editable} + return {InputSelectEntityStateAttribute.EDITABLE: self.editable} @override async def async_select_option(self, option: str) -> None: diff --git a/homeassistant/components/input_select/const.py b/homeassistant/components/input_select/const.py new file mode 100644 index 000000000000..46e2e69d3371 --- /dev/null +++ b/homeassistant/components/input_select/const.py @@ -0,0 +1,9 @@ +"""Constants for the input_select integration.""" + +from enum import StrEnum + + +class InputSelectEntityStateAttribute(StrEnum): + """State attributes for input select entities.""" + + EDITABLE = "editable" diff --git a/homeassistant/components/input_select/reproduce_state.py b/homeassistant/components/input_select/reproduce_state.py index f3781d70cd4f..a02dab5ea15b 100644 --- a/homeassistant/components/input_select/reproduce_state.py +++ b/homeassistant/components/input_select/reproduce_state.py @@ -5,6 +5,7 @@ from collections.abc import Iterable, Mapping import logging from typing import Any +from homeassistant.components.select import SelectEntityCapabilityAttribute from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION from homeassistant.core import Context, HomeAssistant, State @@ -39,9 +40,11 @@ async def _async_reproduce_state( service_data = {ATTR_ENTITY_ID: state.entity_id} # If options are specified, call SERVICE_SET_OPTIONS - if ATTR_OPTIONS in state.attributes: + if SelectEntityCapabilityAttribute.OPTIONS in state.attributes: service = SERVICE_SET_OPTIONS - service_data[ATTR_OPTIONS] = state.attributes[ATTR_OPTIONS] + service_data[ATTR_OPTIONS] = state.attributes[ + SelectEntityCapabilityAttribute.OPTIONS + ] await hass.services.async_call( DOMAIN, service, service_data, context=context, blocking=True diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 365e489a79a4..577f703c3a3a 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -5,8 +5,8 @@ from typing import Any, Self, override import voluptuous as vol -from homeassistant.const import ( - ATTR_EDITABLE, +from homeassistant.components.text import TextEntity +from homeassistant.const import ( # noqa: F401 ATTR_MODE, CONF_ICON, CONF_ID, @@ -24,6 +24,8 @@ import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType +from .const import InputTextEntityStateAttribute + _LOGGER = logging.getLogger(__name__) DOMAIN = "input_text" @@ -187,21 +189,30 @@ class InputTextStorageCollection(collection.DictStorageCollection): return {CONF_ID: item[CONF_ID]} | update_data -class InputText(collection.CollectionEntity, RestoreEntity): +# pylint: disable-next=home-assistant-enforce-class-module +class InputText(collection.CollectionEntity, TextEntity, RestoreEntity): """Represent a text box.""" - _unrecorded_attributes = frozenset( - {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} - ) + _unrecorded_attributes = frozenset({InputTextEntityStateAttribute.EDITABLE}) _attr_should_poll = False - _current_value: str | None editable: bool def __init__(self, config: ConfigType) -> None: """Initialize a text input.""" - self._config = config - self._current_value = config.get(CONF_INITIAL) + self._attr_native_value = config.get(CONF_INITIAL) + self._update_config_attributes(config) + + def _update_config_attributes(self, config: ConfigType) -> None: + """Update attributes based on the config.""" + self._attr_icon = config.get(CONF_ICON) + self._attr_mode = config[CONF_MODE] + self._attr_name = config.get(CONF_NAME) + self._attr_native_min = config[CONF_MIN] + self._attr_native_max = config[CONF_MAX] + self._attr_pattern = config.get(CONF_PATTERN) + self._attr_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_unique_id = config[CONF_ID] @classmethod @override @@ -220,87 +231,42 @@ class InputText(collection.CollectionEntity, RestoreEntity): input_text.editable = False return input_text - @property - @override - def name(self) -> str | None: - """Return the name of the text input entity.""" - return self._config.get(CONF_NAME) - - @property - @override - def icon(self) -> str | None: - """Return the icon to be used for this entity.""" - return self._config.get(CONF_ICON) - - @property - def _maximum(self) -> int: - """Return max len of the text.""" - return self._config[CONF_MAX] # type: ignore[no-any-return] - - @property - def _minimum(self) -> int: - """Return min len of the text.""" - return self._config[CONF_MIN] # type: ignore[no-any-return] - - @property - @override - def state(self) -> str | None: - """Return the state of the component.""" - return self._current_value - - @property - @override - def unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._config.get(CONF_UNIT_OF_MEASUREMENT) - - @property - @override - def unique_id(self) -> str: - """Return unique id for the entity.""" - return self._config[CONF_ID] # type: ignore[no-any-return] - @property @override def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return { - ATTR_EDITABLE: self.editable, - ATTR_MIN: self._minimum, - ATTR_MAX: self._maximum, - ATTR_PATTERN: self._config.get(CONF_PATTERN), - ATTR_MODE: self._config[CONF_MODE], - } + return {InputTextEntityStateAttribute.EDITABLE: self.editable} @override async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() - if self._current_value is not None: + if self._attr_native_value is not None: return state = await self.async_get_last_state() value = state.state if state else None # Check against None because value can be 0 - if value is not None and self._minimum <= len(value) <= self._maximum: - self._current_value = value + if value is not None and self.native_min <= len(value) <= self.native_max: + self._attr_native_value = value + @override async def async_set_value(self, value: str) -> None: """Select new value.""" - if len(value) < self._minimum or len(value) > self._maximum: + if len(value) < self.native_min or len(value) > self.native_max: _LOGGER.warning( "Invalid value: %s (length range %s - %s)", value, - self._minimum, - self._maximum, + self.native_min, + self.native_max, ) return - self._current_value = value + self._attr_native_value = value self.async_write_ha_state() @override async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" - self._config = config + self._update_config_attributes(config) self.async_write_ha_state() diff --git a/homeassistant/components/input_text/const.py b/homeassistant/components/input_text/const.py new file mode 100644 index 000000000000..25760de29eab --- /dev/null +++ b/homeassistant/components/input_text/const.py @@ -0,0 +1,9 @@ +"""Constants for the input_text integration.""" + +from enum import StrEnum + + +class InputTextEntityStateAttribute(StrEnum): + """State attributes for input text entities.""" + + EDITABLE = "editable" diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index eb8650dc6490..1a0bf8401f76 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 7d00d2161cc0..718fee7893c5 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -8,12 +8,7 @@ import voluptuous as vol from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_METHOD, - CONF_NAME, - UnitOfTime, -) +from homeassistant.const import CONF_METHOD, CONF_NAME, EntityStateAttribute, UnitOfTime from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( @@ -62,13 +57,16 @@ def entity_selector_compatible( """Return an entity selector which compatible entities.""" current = handler.hass.states.get(handler.options[CONF_SOURCE_SENSOR]) unit_of_measurement = ( - current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None + current.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + if current + else None ) entities = [ ent.entity_id for ent in handler.hass.states.async_all(ALLOWED_DOMAINS) - if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement + if ent.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + == unit_of_measurement and ent.domain in ALLOWED_DOMAINS ] diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 83b93c4191d0..edd7c5d0c1d0 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -20,12 +20,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_METHOD, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, + EntityStateAttribute, UnitOfTime, ) from homeassistant.core import ( @@ -389,7 +388,9 @@ class IntegrationSensor(RestoreSensor): return device_class def _derive_and_set_attributes_from_state(self, source_state: State) -> None: - source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + source_unit = source_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) if source_unit is not None: self._unit_of_measurement = self._calculate_unit(source_unit) else: @@ -397,7 +398,8 @@ class IntegrationSensor(RestoreSensor): self._unit_of_measurement = None self._attr_device_class = self._calculate_device_class( - source_state.attributes.get(ATTR_DEVICE_CLASS), self.unit_of_measurement + source_state.attributes.get(EntityStateAttribute.DEVICE_CLASS), + self.unit_of_measurement, ) if self._attr_device_class: # Remove this sensors icon default and allow diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index 4feef90a7f72..ffe8bed9117f 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["intellifire4py"], - "requirements": ["intellifire4py==4.4.0"] + "requirements": ["intellifire4py==4.5.0"] } diff --git a/homeassistant/components/intent/llm.py b/homeassistant/components/intent/llm.py new file mode 100644 index 000000000000..082987914327 --- /dev/null +++ b/homeassistant/components/intent/llm.py @@ -0,0 +1,118 @@ +"""LLM tools for the intent integration. + +Exposes the generic, cross-domain intents owned by the intent integration +(device on/off, position, timers) as LLM tools. Domain-specific intents are +exposed by their own integration's ``llm.py`` platform. +""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + floor_registry as fr, + intent, +) +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .timers import async_device_supports_timers + +# Generic intents exposed as LLM tools regardless of a timer-capable device. +LLM_INTENTS = ( + intent.INTENT_TURN_ON, + intent.INTENT_TURN_OFF, + intent.INTENT_CANCEL_ALL_TIMERS, + intent.INTENT_SET_POSITION, + intent.INTENT_STOP_MOVING, +) + +# Timer intents, only exposed for a device that supports timers. +TIMER_INTENTS = ( + intent.INTENT_START_TIMER, + intent.INTENT_CANCEL_TIMER, + intent.INTENT_INCREASE_TIMER, + intent.INTENT_DECREASE_TIMER, + intent.INTENT_PAUSE_TIMER, + intent.INTENT_UNPAUSE_TIMER, + intent.INTENT_TIMER_STATUS, +) + +DEVICE_CONTROL_TOOL_USAGE_PROMPT = ( + "When controlling Home Assistant always call the intent tools. " + "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " + "When controlling a device, prefer passing just name and domain. " + "When controlling an area, prefer passing just area name and domain." +) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the generic intent tools and the device control prompt.""" + if api_id != LLM_API_ASSIST: + return None + + supports_timers = ( + llm_context.device_id is not None + and async_device_supports_timers(hass, llm_context.device_id) + ) + wanted = set(LLM_INTENTS) + if supports_timers: + wanted.update(TIMER_INTENTS) + + exposed_domains = { + state.domain + for state in hass.states.async_all() + if async_should_expose(hass, llm_context.assistant, state.entity_id) + } + handlers = [ + handler + for handler in intent.async_get(hass) + if handler.intent_type in wanted + and (handler.platforms is None or handler.platforms & exposed_domains) + ] + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) for handler in handlers + ] + if not tools: + return None + + # Only guide device control once something is exposed to control. + if not exposed_domains: + return LLMTools(tools=tools) + + # Tell the voice satellite which area it is in so generic commands target it. + floor: fr.FloorEntry | None = None + area: ar.AreaEntry | None = None + if llm_context.device_id and ( + device := dr.async_get(hass).async_get(llm_context.device_id) + ): + area_reg = ar.async_get(hass) + if device.area_id and (area := area_reg.async_get_area(device.area_id)): + if area.floor_id: + floor = fr.async_get(hass).async_get_floor(area.floor_id) + + if area and floor: + area_prompt = ( + f"You are in area {area.name} (floor {floor.name}) and all generic" + " commands like 'turn on the lights' should target this area." + ) + elif area: + area_prompt = ( + f"You are in area {area.name} and all generic commands like" + " 'turn on the lights' should target this area." + ) + else: + area_prompt = ( + "When a user asks to turn on all devices of a specific type, " + "ask the user to specify an area, unless there is only one device" + " of that type." + ) + + prompt_parts = [DEVICE_CONTROL_TOOL_USAGE_PROMPT, area_prompt] + if not supports_timers: + prompt_parts.append("This device is not able to start timers.") + return LLMTools(tools=tools, prompt="\n".join(prompt_parts)) diff --git a/homeassistant/components/intent_script/llm.py b/homeassistant/components/intent_script/llm.py new file mode 100644 index 000000000000..bc13382248da --- /dev/null +++ b/homeassistant/components/intent_script/llm.py @@ -0,0 +1,51 @@ +"""LLM tools for the intent_script integration.""" + +import slugify as unicode_slug + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from . import ScriptIntentHandler + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return an LLM tool for each configured intent script.""" + if api_id != LLM_API_ASSIST: + return None + + handlers = [ + handler + for handler in intent.async_get(hass) + if isinstance(handler, ScriptIntentHandler) + ] + + exposed_domains = { + state.domain + for state in hass.states.async_all() + if async_should_expose(hass, llm_context.assistant, state.entity_id) + } + handlers = [ + handler + for handler in handlers + if handler.platforms is None or handler.platforms & exposed_domains + ] + + if not handlers: + return None + + # Intent script names come from user configuration, so slugify them into + # valid tool names. + tools: list[Tool] = [ + IntentTool( + unicode_slug.slugify(handler.intent_type, separator="_", lowercase=False), + handler, + ) + for handler in handlers + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index e751f511043b..d2089c236503 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/components/iotawatt/coordinator.py @@ -78,6 +78,9 @@ class IotawattUpdater(DataUpdateCoordinator): self.api = api - await self.api.update(lastUpdate=self._last_run) + try: + await self.api.update(lastUpdate=self._last_run) + except CONNECTION_ERRORS as err: + raise UpdateFailed("Connection failed") from err self._last_run = None return self.api.getSensors() diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index f6f9efb16320..ac0d705ab898 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["iotawattpy"], - "requirements": ["ha-iotawattpy==0.1.2"] + "requirements": ["ha-iotawattpy==0.2.1"] } diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py index 628781f6bd88..e6ea6160ffdf 100644 --- a/homeassistant/components/iqvia/config_flow.py +++ b/homeassistant/components/iqvia/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the IQVIA component.""" +"""Config flow to configure the IQVIA integration.""" from typing import Any, override diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index e87711263460..2fd0c0dc7ad7 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -3,11 +3,11 @@ from typing import override from homeassistant.components.update import ( - ATTR_INSTALLED_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -97,7 +97,9 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity, RestoreEntity): Register extra update listener for the firmware update coordinator. """ if state := await self.async_get_last_state(): - self._attr_installed_version = state.attributes.get(ATTR_INSTALLED_VERSION) + self._attr_installed_version = state.attributes.get( + UpdateEntityStateAttribute.INSTALLED_VERSION + ) await super().async_added_to_hass() self.async_on_remove( diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py index 0241d1b4e160..980fa166e727 100644 --- a/homeassistant/components/iss/sensor.py +++ b/homeassistant/components/iss/sensor.py @@ -4,7 +4,7 @@ import logging from typing import Any, override from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP +from homeassistant.const import CONF_SHOW_ON_MAP, EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -61,16 +61,13 @@ class IssSensor(CoordinatorEntity[IssDataUpdateCoordinator], SensorEntity): @override def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - attrs = {} + attrs: dict[str, Any] = {} + location = self.coordinator.data.current_location if self._show_on_map: - attrs[ATTR_LONGITUDE] = self.coordinator.data.current_location.get( - "longitude" - ) - attrs[ATTR_LATITUDE] = self.coordinator.data.current_location.get( - "latitude" - ) + attrs[EntityStateAttribute.LONGITUDE] = location.get("longitude") + attrs[EntityStateAttribute.LATITUDE] = location.get("latitude") else: - attrs["long"] = self.coordinator.data.current_location.get("longitude") - attrs["lat"] = self.coordinator.data.current_location.get("latitude") + attrs["long"] = location.get("longitude") + attrs["lat"] = location.get("latitude") return attrs diff --git a/homeassistant/components/ituran/coordinator.py b/homeassistant/components/ituran/coordinator.py index 2664e3e12d25..0cd48b263f05 100644 --- a/homeassistant/components/ituran/coordinator.py +++ b/homeassistant/components/ituran/coordinator.py @@ -73,6 +73,4 @@ class IturanDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]): ) for device in device_entries: if not device.identifiers.intersection(account_vehicles): - device_registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device.id) diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 9f5e0d2e9c36..c3d17460e31d 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -4,13 +4,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_EXCLUDE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from . import discovery from .const import DATA_CONFIG, DOMAIN -from .discovery import async_start_discovery_service, async_stop_discovery_service PLATFORMS = [Platform.CLIMATE] @@ -30,35 +31,101 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" - - # Check for manually added config, this may exclude some devices if conf := config.get(DOMAIN): hass.data[DATA_CONFIG] = conf - # Explicitly added in the config file, create a config entry. hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} ) ) - # Start the discovery service - await async_start_discovery_service(hass) - - async def shutdown_event(event): - await async_stop_discovery_service(hass) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) - return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + try: + await discovery.async_start_discovery_service(hass) + except OSError as err: + raise ConfigEntryNotReady("iZone discovery service failed to start") from err + + if entry.unique_id == DOMAIN: + # Legacy v1-migrated entry: resolve to a real controller UID at setup time. + # + # Doing this work here (rather than in async_migrate_entry) is intentional: + # ConfigEntryNotReady raised from async_migrate_entry becomes a permanent + # MIGRATION_ERROR — HA does not retry failed migrations. Raising it from + # async_setup_entry correctly schedules a retry on the next HA start. + # + # Raising ConfigEntryError (multiple eligible controllers) is permanent either + # way; those controllers are not lost — the discovery fan-out will surface them + # as individual flows once HA restarts. This is not a breaking change: a v1 + # entry with multiple controllers was already broken before this PR. + # async_discover_controllers reuses the already-running service (idempotent + # start), so OSError here means fetch_controllers() itself failed — rare but + # kept as a defensive guard. + try: + controllers = await discovery.async_discover_controllers(hass) + except OSError as err: + raise ConfigEntryNotReady( + "iZone discovery failed while resolving legacy config entry" + ) from err + + conf: ConfigType | None = hass.data.get(DATA_CONFIG) + excluded_uids: set[str] = set(conf.get(CONF_EXCLUDE, [])) if conf else set() + configured_uids = { + config_entry.unique_id + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + and config_entry.unique_id not in (None, DOMAIN) + } + eligible = [ + controller + for controller in controllers.values() + if controller.device_uid not in excluded_uids + and controller.device_uid not in configured_uids + ] + + if not eligible: + raise ConfigEntryNotReady( + "No eligible iZone controller found to bind to legacy config entry" + ) + + if len(eligible) > 1: + raise ConfigEntryError( + "Multiple eligible iZone controllers found for a legacy config entry; " + "delete this entry and re-add each controller individually" + ) + + controller = eligible[0] + new_title = ( + f"iZone {controller.device_uid}" + if entry.title == "iZone Aircon" + else entry.title + ) + hass.config_entries.async_update_entry( + entry, + unique_id=controller.device_uid, + title=new_title, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entry schema to the current version.""" + if entry.version == 1: + # Clear legacy data only — UID and title binding is deferred to + # async_setup_entry where ConfigEntryNotReady retry semantics work correctly. + # Raising ConfigEntryNotReady from async_migrate_entry would permanently land + # the entry in MIGRATION_ERROR with no retry path. + hass.config_entries.async_update_entry(entry, version=2, data={}) + return True + return False + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload the config entry and stop discovery process.""" + """Unload the config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index a1cbf3078fc5..74fab9e02799 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -22,7 +22,6 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, - CONF_EXCLUDE, PRECISION_HALVES, PRECISION_TENTHS, UnitOfTemperature, @@ -33,10 +32,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.temperature import display_temp as show_temp -from homeassistant.helpers.typing import ConfigType, VolDictType +from homeassistant.helpers.typing import VolDictType from .const import ( - DATA_CONFIG, DATA_DISCOVERY_SERVICE, DISPATCH_CONTROLLER_DISCONNECTED, DISPATCH_CONTROLLER_DISCOVERED, @@ -44,6 +42,7 @@ from .const import ( DISPATCH_CONTROLLER_UPDATE, DISPATCH_ZONE_UPDATE, DOMAIN, + TIMEOUT_DISCOVERY, ) type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] @@ -77,25 +76,29 @@ async def async_setup_entry( ) -> None: """Initialize an IZone Controller.""" disco = hass.data[DATA_DISCOVERY_SERVICE] + entry_unique_id = config.unique_id + initialized = False @callback def init_controller(ctrl: Controller): """Register the controller device and the containing zones.""" - conf: ConfigType | None = hass.data.get(DATA_CONFIG) - - # Filter out any entities excluded in the config file - if conf and ctrl.device_uid in conf[CONF_EXCLUDE]: - _LOGGER.debug("Controller UID=%s ignored as excluded", ctrl.device_uid) + nonlocal initialized + if entry_unique_id and ctrl.device_uid != entry_unique_id: + return + if initialized: return - _LOGGER.debug("Controller UID=%s discovered", ctrl.device_uid) + initialized = True device = ControllerDevice(ctrl) async_add_entities([device]) async_add_entities(device.zones.values()) + _LOGGER.debug("Controller UID=%s initialized", ctrl.device_uid) - # create any components not yet created - for controller in (await disco.pi_disco.fetch_controllers()).values(): - init_controller(controller) + # Fetch the controller for this entry, waiting for discovery if it hasn't been found yet + if ctrl := await disco.pi_disco.fetch_controller( + entry_unique_id, timeout=TIMEOUT_DISCOVERY + ): + init_controller(ctrl) # connect to register any further components config.async_on_unload( @@ -305,10 +308,10 @@ class ControllerDevice(ClimateEntity): """Return current operation ie. heat, cool, idle.""" if not self._controller.is_on: return HVACMode.OFF - if (mode := self._controller.mode) == Controller.Mode.FREE_AIR: + if (mode := self._controller.mode) is Controller.Mode.FREE_AIR: return HVACMode.FAN_ONLY for key, value in self._state_to_pizone.items(): - if value == mode: + if value is mode: return key raise RuntimeError("Should be unreachable") @@ -342,7 +345,7 @@ class ControllerDevice(ClimateEntity): @override def current_temperature(self) -> float | None: """Return the current temperature.""" - if self._controller.mode == Controller.Mode.FREE_AIR: + if self._controller.mode is Controller.Mode.FREE_AIR: return self._controller.temp_supply return self._controller.temp_return @@ -387,7 +390,7 @@ class ControllerDevice(ClimateEntity): return self.control_zone_setpoint @property - def supply_temperature(self) -> float: + def supply_temperature(self) -> float | None: """Return the current supply, or in duct, temperature.""" return self._controller.temp_supply @@ -485,7 +488,7 @@ class ZoneDevice(ClimateEntity): self._controller = controller self._zone = zone - if zone.type != Zone.Type.AUTO: + if zone.type is not Zone.Type.AUTO: self._state_to_pizone = { HVACMode.OFF: Zone.Mode.CLOSE, HVACMode.FAN_ONLY: Zone.Mode.OPEN, @@ -552,7 +555,7 @@ class ZoneDevice(ClimateEntity): @override def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" - if self._zone.mode == Zone.Mode.AUTO: + if self._zone.mode is Zone.Mode.AUTO: return self._attr_supported_features return self._attr_supported_features & ~ClimateEntityFeature.TARGET_TEMPERATURE @@ -562,7 +565,7 @@ class ZoneDevice(ClimateEntity): """Return current operation ie. heat, cool, idle.""" mode = self._zone.mode for key, value in self._state_to_pizone.items(): - if value == mode: + if value is mode: return key return None @@ -574,7 +577,7 @@ class ZoneDevice(ClimateEntity): @property @override - def current_temperature(self) -> float: + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._zone.temp_current @@ -582,7 +585,7 @@ class ZoneDevice(ClimateEntity): @override def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if self._zone.type != Zone.Type.AUTO: + if self._zone.type is not Zone.Type.AUTO: return None return self._zone.temp_setpoint @@ -625,7 +628,7 @@ class ZoneDevice(ClimateEntity): @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if self._zone.mode != Zone.Mode.AUTO: + if self._zone.mode is not Zone.Mode.AUTO: return if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: await self._controller.wrap_and_catch(self._zone.set_temp_setpoint(temp)) @@ -640,12 +643,12 @@ class ZoneDevice(ClimateEntity): @property def is_on(self) -> bool: """Return true if on.""" - return self._zone.mode != Zone.Mode.CLOSE + return self._zone.mode is not Zone.Mode.CLOSE @override async def async_turn_on(self) -> None: """Turn device on (open zone).""" - if self._zone.type == Zone.Type.AUTO: + if self._zone.type is Zone.Type.AUTO: await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.AUTO)) else: await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.OPEN)) diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 762949b7e184..8525473c34df 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -1,42 +1,387 @@ """Config flow for izone.""" -import asyncio -from contextlib import suppress +from collections.abc import Iterable import logging +from typing import Any, Self, override +import pizone +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_entry_flow -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import discovery_flow +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from homeassistant.helpers.typing import DiscoveryInfoType -from .const import DISPATCH_CONTROLLER_DISCOVERED, DOMAIN, TIMEOUT_DISCOVERY -from .discovery import async_start_discovery_service, async_stop_discovery_service +from . import discovery as izone_discovery +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +SELECTED_CONTROLLER_UID = "selected_controller_uid" -async def _async_has_devices(hass: HomeAssistant) -> bool: - controller_ready = asyncio.Event() + +def _flow_uid_for_matching(flow: ConfigFlow) -> str | None: + """Return a stable controller UID for deduplicating in-progress flows.""" + ctx_uid = flow.context.get("unique_id") + if isinstance(ctx_uid, str): + return ctx_uid + return None + + +class IZoneConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow: user, YAML import, HomeKit, and integration discovery.""" + + VERSION = 2 + + _user_discovered_controllers: list[pizone.Controller] | None = None + _discovered_controller_ip: str | None = None + + @override + def is_matching(self, other_flow: Self) -> bool: + """Match in-progress flows for the same controller UID.""" + self_uid = _flow_uid_for_matching(self) + other_uid = _flow_uid_for_matching(other_flow) + if self_uid is None or other_uid is None: + return False + return self_uid == other_uid + + # -- User-visible and internal steps (roughly: import → user → discovery UI → HK → fan-out → confirm) + + async def async_step_import( + self, _import_data: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """YAML import: start discovery and let runtime discovery offer flows. + + The import step runs exactly once (Home Assistant calls it only when the YAML + key is present at startup). We start the discovery service so every controller + surfaced by the service's normal listener appears in discovered devices and + still requires normal confirmation. + + No explicit rescan is issued: the service will broadcast its own discovery + request as part of start-up, and the import step itself will not be repeated. + """ + if self._async_in_progress(include_uninitialized=True): + return self.async_abort(reason="already_in_progress") + + try: + await izone_discovery.async_start_discovery_service(self.hass) + except OSError: + _LOGGER.debug("Unable to start iZone discovery from import", exc_info=True) + return self.async_abort(reason="discovery_failed") + + # Discovery is now running; each controller will surface as an individual + # integration_discovery flow. Use a dedicated abort reason so the UI does + # not misleadingly show "No devices found" when setup is actually in progress. + return self.async_abort(reason="discovery_started") + + @override + async def async_step_user( + self, _user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User-started flow: offer configuration choices for discovered controllers. + + Discovery is started if not yet running, then a fresh discovery cycle is triggered + and this step waits briefly for replies. The pizone library's built-in coalescing + avoids redundant broadcasts when discovery was just started. + + While this interactive flow is active, runtime integration discovery remains + blocked by ``_async_blocks_runtime_integration_discovery`` to avoid UI races. + """ + + if self._async_in_progress(include_uninitialized=True): + return self.async_abort(reason="already_in_progress") + + try: + controllers = await izone_discovery.async_discover_controllers( + self.hass, refresh=True + ) + except OSError: + _LOGGER.debug("Unable to start iZone discovery service", exc_info=True) + return self.async_abort(reason="discovery_failed") + if not controllers: + _LOGGER.debug("No controllers found") + return self.async_abort(reason="no_devices_found") + + self._user_discovered_controllers = self._async_get_unconfigured_controllers( + controllers + ) + if not self._user_discovered_controllers: + return self.async_abort(reason="already_configured") + if len(self._user_discovered_controllers) > 1: + return await self.async_step_select_controller() + + sole = self._user_discovered_controllers[0] + await self.async_set_unique_id(sole.device_uid) + self._discovered_controller_ip = sole.device_ip + return await self.async_step_confirm() + + async def async_step_select_controller( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Choose one unconfigured controller after broadcast discovery.""" + if not self._user_discovered_controllers: + return self.async_abort(reason="no_devices_found") + + by_uid = { + controller.device_uid: controller + for controller in self._user_discovered_controllers + } + selection_schema = vol.Schema( + { + vol.Required( + SELECTED_CONTROLLER_UID, + default=self._user_discovered_controllers[0].device_uid, + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=controller.device_uid, + label=( + f"{controller.device_uid} ({controller.device_ip})" + ), + ) + for controller in self._user_discovered_controllers + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ) + + if user_input is not None: + selected_uid = user_input[SELECTED_CONTROLLER_UID] + if (primary := by_uid.get(selected_uid)) is None: + return self.async_abort(reason="no_devices_found") + + for ctrl in self._user_discovered_controllers: + if ctrl.device_uid == primary.device_uid: + continue + # Using integration_discovery lets HA's deduplication guard prevent stacking + # flows for UIDs already in progress or already configured. + self._async_schedule_integration_discovery_flow( + ctrl.device_uid, + ctrl.device_ip, + ) + return await self._async_create_controller_entry(primary) + + controllers_lines = "\n".join( + f"- {controller.device_uid} ({controller.device_ip})" + for controller in self._user_discovered_controllers + ) + return self.async_show_form( + step_id="select_controller", + data_schema=selection_schema, + description_placeholders={"controllers": controllers_lines}, + ) + + @override + async def async_step_homekit( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Map HomeKit ``md`` to an iZone UID, discover LAN controllers, then confirm.""" + model = discovery_info.properties.get("md", "") + if not model.startswith("iZone "): + return self.async_abort(reason="no_devices_found") + + device_uid = model.split(" ", 1)[1] + + if device_uid in izone_discovery.yaml_excluded_uids(self.hass): + return self.async_abort(reason="no_devices_found") + + # async_set_unique_id + _abort_if_unique_id_configured handles both existing + # entries (including SOURCE_IGNORE) and stale in-progress flows for this UID. + # A direct async_entry_for_domain_unique_id pre-check would miss the + # flow-deduplication side effect of async_set_unique_id(raise_on_progress=True). + await self.async_set_unique_id(device_uid) + self._abort_if_unique_id_configured() + + # A HomeKit advertisement implies a specific UID is on the LAN. Wait for it. + try: + controllers = await izone_discovery.async_discover_controllers( + self.hass, + refresh=True, + wait_for_uid=device_uid, + ) + except OSError: + _LOGGER.debug("Unable to start iZone discovery service", exc_info=True) + return self.async_abort(reason="discovery_failed") + controller = controllers.get(device_uid) + if controller is None: + return self.async_abort(reason="no_devices_found") + + self._discovered_controller_ip = controller.device_ip + + # Re-check after awaiting discovery to catch mid-flight configuration. + self._abort_if_unique_id_configured() + + self._async_fan_out_discovered_controllers( + controllers.values(), + selected_uid=device_uid, + ) + + return await self.async_step_confirm() + + @override + async def async_step_integration_discovery( + self, discovery_info: DiscoveryInfoType + ) -> ConfigFlowResult: + """Handle fan-out, YAML import secondaries, and runtime discovery.""" + uid = self.context["unique_id"] + host = discovery_info[CONF_HOST] + if uid in izone_discovery.yaml_excluded_uids(self.hass): + return self.async_abort(reason="no_devices_found") + + await self.async_set_unique_id(uid) + self._abort_if_unique_id_configured() + # Discovery host is for confirm-step context only; runtime discovery owns + # current device IP state and keeps it up to date independently of entry data. + self._discovered_controller_ip = host + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm adding a controller found via HomeKit or manual host.""" + if user_input is None: + controller_uid = self.unique_id + host = self._discovered_controller_ip + assert isinstance(controller_uid, str) + assert controller_uid + assert host is not None + host_str = str(host) + self.context["title_placeholders"] = { + "name": self._entry_title(controller_uid), + } + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "controller_uid": controller_uid, + "host": host_str, + }, + ) + + try: + controllers = await izone_discovery.async_discover_controllers(self.hass) + except OSError: + _LOGGER.debug("Unable to start iZone discovery service", exc_info=True) + return self.async_abort(reason="discovery_failed") + if not controllers: + _LOGGER.debug("No controllers found") + return self.async_abort(reason="no_devices_found") + + uid = self.unique_id + assert isinstance(uid, str) + + controller = controllers.get(uid) + if controller is None: + _LOGGER.debug( + "Discovered controller UID %s was not found during confirmation", + uid, + ) + return self.async_abort(reason="no_devices_found") + return await self._async_create_controller_entry( + controller, + ) + + # -- Private helpers @callback - def dispatch_discovered(_): - controller_ready.set() + def _async_schedule_integration_discovery_flow( + self, + uid: str, + host: str, + ) -> None: + """Queue integration discovery (import fan-out or manual discovery pick).""" + discovery_flow.async_create_flow( + self.hass, + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": uid, + }, + data={CONF_HOST: host}, + ) - async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, dispatch_discovered) + @staticmethod + def _entry_title(device_uid: str) -> str: + """Standard config entry title for a controller UID.""" + return f"iZone {device_uid}" - disco = await async_start_discovery_service(hass) + @staticmethod + def _filter_yaml_exclude( + hass: HomeAssistant, controllers: dict[str, pizone.Controller] + ) -> dict[str, pizone.Controller]: + """Remove UIDs listed in deprecated YAML ``exclude``.""" + excluded = izone_discovery.yaml_excluded_uids(hass) + if not excluded: + return controllers + return { + uid: ctrl + for uid, ctrl in controllers.items() + if ctrl.device_uid not in excluded + } - with suppress(TimeoutError): - async with asyncio.timeout(TIMEOUT_DISCOVERY): - await controller_ready.wait() + @callback + def _async_get_unconfigured_controllers( + self, controllers: dict[str, pizone.Controller] + ) -> list[pizone.Controller]: + """Return sorted unconfigured controllers for the interactive user flow.""" + controllers = self._filter_yaml_exclude(self.hass, controllers) + # include_ignore=True ensures controllers whose entries have been explicitly + # ignored by the user (SOURCE_IGNORE) are not re-offered as configurable. + configured_uids = self._async_current_ids(include_ignore=True) + return sorted( + ( + controller + for controller in controllers.values() + if controller.device_uid not in configured_uids + ), + key=lambda controller: (controller.device_uid, controller.device_ip), + ) - controllers = await disco.pi_disco.fetch_controllers() - if not controllers: - await async_stop_discovery_service(hass) - _LOGGER.debug("No controllers found") - return False + async def _async_create_controller_entry( + self, + controller: pizone.Controller, + ) -> ConfigFlowResult: + """Create the config entry for a chosen :class:`pizone.Controller` instance.""" + await self.async_set_unique_id(controller.device_uid) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._entry_title(controller.device_uid), + data={}, + ) - _LOGGER.debug("Controllers %s", controllers) - return True - - -config_entry_flow.register_discovery_flow(DOMAIN, "iZone Aircon", _async_has_devices) + @callback + def _async_fan_out_discovered_controllers( + self, + controllers: Iterable[pizone.Controller], + *, + selected_uid: str, + ) -> None: + """Start confirm flows for every other discovered UID (import uses its own path).""" + current_ids = self._async_current_ids(include_ignore=True) + in_progress_ids = { + flow["context"].get("unique_id") + for flow in self._async_in_progress(include_uninitialized=True) + } + for candidate in controllers: + if candidate.device_uid == selected_uid: + continue + if ( + candidate.device_uid in current_ids + or candidate.device_uid in in_progress_ids + ): + continue + self._async_schedule_integration_discovery_flow( + candidate.device_uid, + candidate.device_ip, + ) diff --git a/homeassistant/components/izone/const.py b/homeassistant/components/izone/const.py index 99d75bf92b54..eb8f76ed1daa 100644 --- a/homeassistant/components/izone/const.py +++ b/homeassistant/components/izone/const.py @@ -11,4 +11,5 @@ DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_reconnected" DISPATCH_CONTROLLER_UPDATE = "izone_controller_update" DISPATCH_ZONE_UPDATE = "izone_zone_update" -TIMEOUT_DISCOVERY = 20 +TIMEOUT_DISCOVERY = 5 +DISCOVERY_IDLE_SECONDS = 4 * TIMEOUT_DISCOVERY diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index 81537bbdbbcf..862577307c58 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -1,25 +1,108 @@ -"""Internal discovery service for iZone AC.""" +"""Internal discovery service for iZone AC.""" +import asyncio +from collections.abc import Callable import logging +from typing import override import pizone -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant import config_entries +from homeassistant.const import CONF_EXCLUDE, CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import aiohttp_client, discovery_flow +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.typing import ConfigType from .const import ( + DATA_CONFIG, DATA_DISCOVERY_SERVICE, + DISCOVERY_IDLE_SECONDS, DISPATCH_CONTROLLER_DISCONNECTED, DISPATCH_CONTROLLER_DISCOVERED, DISPATCH_CONTROLLER_RECONNECTED, DISPATCH_CONTROLLER_UPDATE, DISPATCH_ZONE_UPDATE, + DOMAIN, + TIMEOUT_DISCOVERY, ) _LOGGER = logging.getLogger(__name__) +async def async_discover_controllers( + hass: HomeAssistant, + *, + refresh: bool = False, + wait_for_uid: str | None = None, +) -> dict[str, pizone.Controller]: + """Return currently known controllers, optionally waiting for a UID during rescan. + + If ``refresh`` is true, waits for fresh discovery data using the pizone library's + built-in coalescing and cool-down logic. When ``wait_for_uid`` is provided, returns + as soon as that specific controller appears (or after the timeout). + + If discovery is not yet running, it is started first. + + Raises: + OSError: Discovery service failed to start or controller fetch failed. + """ + disco = await async_start_discovery_service(hass) + assert disco.pi_disco is not None + + if not refresh: + return await disco.pi_disco.fetch_controllers() + + if wait_for_uid is not None: + await disco.pi_disco.fetch_controller(wait_for_uid, timeout=TIMEOUT_DISCOVERY) + return await disco.pi_disco.fetch_controllers() + + return await disco.pi_disco.fetch_controllers(timeout=TIMEOUT_DISCOVERY) + + +def yaml_excluded_uids(hass: HomeAssistant) -> set[str]: + """Return controller UIDs listed in deprecated YAML ``exclude``.""" + conf: ConfigType | None = hass.data.get(DATA_CONFIG) + if not conf: + return set() + return set(conf.get(CONF_EXCLUDE, ())) + + +@callback +def async_note_integration_discovery( + hass: HomeAssistant, ctrl: pizone.Controller +) -> None: + """Start a config flow when the shared discovery service reports a controller.""" + if ctrl.device_uid in yaml_excluded_uids(hass): + return + if _async_blocks_runtime_integration_discovery(hass): + return + discovery_flow.async_create_flow( + hass, + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": ctrl.device_uid, + }, + data={CONF_HOST: ctrl.device_ip}, + ) + + +@callback +def _async_blocks_runtime_integration_discovery(hass: HomeAssistant) -> bool: + """Return True when an interactive setup flow should own the UI.""" + for flw in hass.config_entries.flow.async_progress_by_handler( + DOMAIN, include_uninitialized=True + ): + src = flw["context"].get("source") + if src == config_entries.SOURCE_USER: + return True + return False + + class DiscoveryService(pizone.Listener): """Discovery data and interfacing with pizone library.""" @@ -28,30 +111,59 @@ class DiscoveryService(pizone.Listener): super().__init__() self.hass = hass self.pi_disco: pizone.DiscoveryService | None = None + self.remove_stop_listener: Callable[[], None] | None = None + self.remove_config_flow_listener: Callable[[], None] | None = None + self._idle_stop_handle: asyncio.TimerHandle | None = None + + @callback + def async_schedule_idle_stop(self) -> None: + """Schedule a delayed shutdown check for discovery service.""" + if self._idle_stop_handle is not None: + self._idle_stop_handle.cancel() + + self._idle_stop_handle = self.hass.loop.call_later( + DISCOVERY_IDLE_SECONDS, + lambda: self.hass.async_create_task( + async_maybe_stop_discovery_service(self.hass) + ), + ) + + @callback + def async_cancel_idle_stop(self) -> None: + """Cancel any pending idle-stop timer.""" + if self._idle_stop_handle is not None: + self._idle_stop_handle.cancel() + self._idle_stop_handle = None # Listener interface + @override def controller_discovered(self, ctrl: pizone.Controller) -> None: """Handle new controller discovery.""" + self.async_schedule_idle_stop() async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCOVERED, ctrl) + @override def controller_disconnected(self, ctrl: pizone.Controller, ex: Exception) -> None: """On disconnect from controller.""" async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCONNECTED, ctrl, ex) + @override def controller_reconnected(self, ctrl: pizone.Controller) -> None: """On reconnect to controller.""" async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_RECONNECTED, ctrl) + @override def controller_update(self, ctrl: pizone.Controller) -> None: """System update message is received from the controller.""" async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_UPDATE, ctrl) + @override def zone_update(self, ctrl: pizone.Controller, zone: pizone.Zone) -> None: """Zone update message is received from the controller.""" async_dispatcher_send(self.hass, DISPATCH_ZONE_UPDATE, ctrl, zone) -async def async_start_discovery_service(hass: HomeAssistant): +async def async_start_discovery_service(hass: HomeAssistant) -> DiscoveryService: """Set up the pizone internal discovery.""" if disco := hass.data.get(DATA_DISCOVERY_SERVICE): # Already started @@ -60,21 +172,116 @@ async def async_start_discovery_service(hass: HomeAssistant): # discovery local services disco = DiscoveryService(hass) - hass.data[DATA_DISCOVERY_SERVICE] = disco # Start the pizone discovery service, disco is the listener session = aiohttp_client.async_get_clientsession(hass) disco.pi_disco = pizone.discovery(disco, session=session) + + @callback + def _async_on_controller_discovered(ctrl: pizone.Controller) -> None: + async_note_integration_discovery(hass, ctrl) + + disco.remove_config_flow_listener = async_dispatcher_connect( + hass, DISPATCH_CONTROLLER_DISCOVERED, _async_on_controller_discovered + ) + await disco.pi_disco.start_discovery() + # Stored after start_discovery() so concurrent callers never receive a + # partially-initialised DiscoveryService (no active UDP transport or scan loop). + hass.data[DATA_DISCOVERY_SERVICE] = disco + + async def async_stop_discovery_on_shutdown(event: Event) -> None: + """Stop discovery on Home Assistant shutdown.""" + # async_listen_once removes its own listener before running this callback. + # Clear our handle so async_stop_discovery_service does not try to remove it + # a second time, which logs an "unknown job listener" error. + disco.remove_stop_listener = None + await async_stop_discovery_service(hass) + + disco.remove_stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_stop_discovery_on_shutdown + ) + disco.async_schedule_idle_stop() return disco -async def async_stop_discovery_service(hass: HomeAssistant): +@callback +def _async_is_ignored_or_excluded_uid(hass: HomeAssistant, uid: str) -> bool: + """Return True when UID is excluded by YAML or ignored/disabled by config entries.""" + if uid in yaml_excluded_uids(hass): + return True + + return any( + entry.unique_id == uid + and ( + entry.source == config_entries.SOURCE_IGNORE + or entry.disabled_by is not None + ) + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +@callback +def _async_has_actionable_entries(hass: HomeAssistant) -> bool: + """Return True when there is at least one enabled, non-ignored iZone entry.""" + return any( + hass.config_entries.async_entries( + DOMAIN, include_ignore=False, include_disabled=False + ) + ) + + +@callback +def _async_has_actionable_flows(hass: HomeAssistant) -> bool: + """Return True when there is an in-progress iZone flow that can create/update state.""" + return any( + flow["context"].get("source") != config_entries.SOURCE_IGNORE + for flow in hass.config_entries.flow.async_progress_by_handler( + DOMAIN, include_uninitialized=True + ) + ) + + +async def async_maybe_stop_discovery_service(hass: HomeAssistant) -> None: + """Stop discovery after idle delay when no actionable controllers remain.""" + if not (disco := hass.data.get(DATA_DISCOVERY_SERVICE)): + return + + if _async_has_actionable_flows(hass) or _async_has_actionable_entries(hass): + disco.async_schedule_idle_stop() + return + + controllers_map = await disco.pi_disco.fetch_controllers() + if not controllers_map: + await async_stop_discovery_service(hass) + return + + if all( + _async_is_ignored_or_excluded_uid(hass, c.device_uid) + for c in controllers_map.values() + ): + await async_stop_discovery_service(hass) + return + + disco.async_schedule_idle_stop() + + +async def async_stop_discovery_service(hass: HomeAssistant) -> None: """Stop the discovery service.""" if not (disco := hass.data.get(DATA_DISCOVERY_SERVICE)): return + if disco.remove_stop_listener is not None: + disco.remove_stop_listener() + disco.remove_stop_listener = None + + if disco.remove_config_flow_listener is not None: + disco.remove_config_flow_listener() + disco.remove_config_flow_listener = None + + disco.async_cancel_idle_stop() + await disco.pi_disco.close() del hass.data[DATA_DISCOVERY_SERVICE] diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json index 47fc9b2f9267..da55de678ce9 100644 --- a/homeassistant/components/izone/manifest.json +++ b/homeassistant/components/izone/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pizone"], - "requirements": ["python-izone==1.2.10"] + "requirements": ["python-izone==1.3.4"] } diff --git a/homeassistant/components/izone/strings.json b/homeassistant/components/izone/strings.json index 0716bee80747..42b91a22a357 100644 --- a/homeassistant/components/izone/strings.json +++ b/homeassistant/components/izone/strings.json @@ -1,12 +1,22 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "discovery_failed": "Failed to start iZone discovery. Make sure your network is properly configured.", + "discovery_started": "iZone discovery has started. Your controllers will appear as discovered devices under Settings \u003e Devices \u0026 services.", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, + "flow_title": "{name}", "step": { "confirm": { - "description": "Do you want to set up iZone?" + "description": "Do you want to set up iZone?\n\nController UID: {controller_uid}\nController IP: {host}" + }, + "select_controller": { + "data": { + "selected_controller_uid": "Controller" + }, + "description": "Multiple unconfigured iZone controllers were found:\n{controllers}\n\nChoose the controller you want to set up now. Any controller you do not select will remain available as a discovered device you can set up later under **Settings** > **Devices & services**." } } }, diff --git a/homeassistant/components/karakeep/__init__.py b/homeassistant/components/karakeep/__init__.py new file mode 100644 index 000000000000..6710fe6dfc5b --- /dev/null +++ b/homeassistant/components/karakeep/__init__.py @@ -0,0 +1,33 @@ +"""The Karakeep integration.""" + +from aiokarakeep import KarakeepClient + +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import KarakeepConfigEntry, KarakeepDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: KarakeepConfigEntry) -> bool: + """Set up Karakeep from a config entry.""" + client = KarakeepClient( + entry.data[CONF_URL], + entry.data[CONF_TOKEN], + async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), + ) + coordinator = KarakeepDataUpdateCoordinator(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: KarakeepConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/karakeep/config_flow.py b/homeassistant/components/karakeep/config_flow.py new file mode 100644 index 000000000000..b1413a4c0858 --- /dev/null +++ b/homeassistant/components/karakeep/config_flow.py @@ -0,0 +1,104 @@ +"""Config flow for Karakeep.""" + +import logging +from typing import Any, override + +from aiokarakeep import ( + KarakeepApiError, + KarakeepAuthError, + KarakeepClient, + KarakeepConnectionError, + KarakeepInvalidResponseError, +) +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_VERIFY_SSL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +class KarakeepConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Karakeep.""" + + VERSION = 1 + + async def _async_validate_input( + self, url: str, token: str, verify_ssl: bool + ) -> dict[str, str]: + """Validate the user input allows us to connect.""" + errors: dict[str, str] = {} + + session = async_get_clientsession(self.hass, verify_ssl) + client = KarakeepClient(url, token, session) + + try: + await client.async_get_stats() + except KarakeepAuthError: + errors["base"] = "invalid_auth" + except KarakeepConnectionError: + errors["base"] = "cannot_connect" + except KarakeepApiError, KarakeepInvalidResponseError: + errors["base"] = "api_error" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors + + @override + 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: + url = _normalize_url(user_input[CONF_URL]) + token = user_input[CONF_TOKEN].strip() + verify_ssl = user_input[CONF_VERIFY_SSL] + + if url is None: + errors["base"] = "invalid_url_format" + else: + self._async_abort_entries_match({CONF_URL: url}) + + errors = await self._async_validate_input(url, token, verify_ssl) + if not errors: + return self.async_create_entry( + title="Karakeep", + data={ + CONF_URL: url, + CONF_TOKEN: token, + CONF_VERIFY_SSL: verify_ssl, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + +def _normalize_url(raw_url: str) -> str | None: + """Return the normalized base URL, or None if it is not a valid URL.""" + try: + parsed_url = URL(raw_url.strip()) + except ValueError: + return None + if parsed_url.scheme not in ("http", "https") or not parsed_url.host: + return None + return str(parsed_url).rstrip("/") diff --git a/homeassistant/components/karakeep/const.py b/homeassistant/components/karakeep/const.py new file mode 100644 index 000000000000..b2c5fe1c0604 --- /dev/null +++ b/homeassistant/components/karakeep/const.py @@ -0,0 +1,12 @@ +"""Constants for the Karakeep integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "karakeep" + +DEFAULT_VERIFY_SSL = True +UPDATE_INTERVAL = timedelta(seconds=300) + +PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/karakeep/coordinator.py b/homeassistant/components/karakeep/coordinator.py new file mode 100644 index 000000000000..01033def739f --- /dev/null +++ b/homeassistant/components/karakeep/coordinator.py @@ -0,0 +1,67 @@ +"""Data update coordinator for the Karakeep integration.""" + +import logging +from typing import override + +from aiokarakeep import ( + KarakeepApiError, + KarakeepAuthError, + KarakeepClient, + KarakeepConnectionError, + KarakeepInvalidResponseError, + KarakeepStats, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +type KarakeepConfigEntry = ConfigEntry[KarakeepDataUpdateCoordinator] + + +class KarakeepDataUpdateCoordinator(DataUpdateCoordinator[KarakeepStats]): + """Class to manage fetching Karakeep data.""" + + config_entry: KarakeepConfigEntry + version: str | None = None + + def __init__( + self, + hass: HomeAssistant, + entry: KarakeepConfigEntry, + client: KarakeepClient, + ) -> None: + """Initialize the coordinator.""" + self.client = client + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + + @override + async def _async_setup(self) -> None: + """Fetch the server version once during setup.""" + try: + self.version = await self.client.async_get_version() + except (KarakeepApiError, KarakeepConnectionError) as err: + raise UpdateFailed(f"Error communicating with Karakeep: {err}") from err + + @override + async def _async_update_data(self) -> KarakeepStats: + """Fetch data from Karakeep API.""" + try: + return await self.client.async_get_stats() + except KarakeepAuthError as err: + raise UpdateFailed("Invalid Karakeep API token") from err + except KarakeepConnectionError as err: + raise UpdateFailed(f"Error communicating with Karakeep: {err}") from err + except (KarakeepApiError, KarakeepInvalidResponseError) as err: + raise UpdateFailed(f"Invalid response from Karakeep: {err}") from err diff --git a/homeassistant/components/karakeep/entity.py b/homeassistant/components/karakeep/entity.py new file mode 100644 index 000000000000..347468f460be --- /dev/null +++ b/homeassistant/components/karakeep/entity.py @@ -0,0 +1,26 @@ +"""Base entity for the Karakeep integration.""" + +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import KarakeepDataUpdateCoordinator + + +class KarakeepEntity(CoordinatorEntity[KarakeepDataUpdateCoordinator]): + """Base class for Karakeep entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: KarakeepDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + url = coordinator.config_entry.data[CONF_URL] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer="Karakeep", + entry_type=DeviceEntryType.SERVICE, + configuration_url=url, + sw_version=coordinator.version, + ) diff --git a/homeassistant/components/karakeep/icons.json b/homeassistant/components/karakeep/icons.json new file mode 100644 index 000000000000..85ffe948b51f --- /dev/null +++ b/homeassistant/components/karakeep/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "archived": { + "default": "mdi:archive" + }, + "bookmarks": { + "default": "mdi:bookmark" + }, + "favorites": { + "default": "mdi:star" + }, + "highlights": { + "default": "mdi:marker" + }, + "lists": { + "default": "mdi:format-list-bulleted" + }, + "tags": { + "default": "mdi:tag" + } + } + } +} diff --git a/homeassistant/components/karakeep/manifest.json b/homeassistant/components/karakeep/manifest.json new file mode 100644 index 000000000000..7561436b01b6 --- /dev/null +++ b/homeassistant/components/karakeep/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "karakeep", + "name": "Karakeep", + "codeowners": ["@sli-cka"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/karakeep", + "integration_type": "service", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["aiokarakeep==0.3.0"] +} diff --git a/homeassistant/components/karakeep/quality_scale.yaml b/homeassistant/components/karakeep/quality_scale.yaml new file mode 100644 index 000000000000..4df22ff58d89 --- /dev/null +++ b/homeassistant/components/karakeep/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not have 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: This integration does not have custom service actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: Entities do not subscribe to external 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 have custom service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration does not use discovery. + 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: + status: exempt + comment: Karakeep exposes one service device per config entry. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: + status: exempt + comment: This integration does not raise translatable Home Assistant exceptions. + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: Karakeep exposes one service device per config entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/karakeep/sensor.py b/homeassistant/components/karakeep/sensor.py new file mode 100644 index 000000000000..88013d5f36a3 --- /dev/null +++ b/homeassistant/components/karakeep/sensor.py @@ -0,0 +1,100 @@ +"""Sensor platform for Karakeep.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from aiokarakeep import KarakeepStats + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import KarakeepConfigEntry +from .entity import KarakeepEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class KarakeepSensorEntityDescription(SensorEntityDescription): + """Describes a Karakeep sensor.""" + + value_fn: Callable[[KarakeepStats], int] + + +SENSOR_DESCRIPTIONS: tuple[KarakeepSensorEntityDescription, ...] = ( + KarakeepSensorEntityDescription( + key="bookmarks", + translation_key="bookmarks", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_bookmarks, + ), + KarakeepSensorEntityDescription( + key="favorites", + translation_key="favorites", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_favorites, + ), + KarakeepSensorEntityDescription( + key="archived", + translation_key="archived", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_archived, + ), + KarakeepSensorEntityDescription( + key="highlights", + translation_key="highlights", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_highlights, + ), + KarakeepSensorEntityDescription( + key="lists", + translation_key="lists", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_lists, + ), + KarakeepSensorEntityDescription( + key="tags", + translation_key="tags", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda stats: stats.num_tags, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KarakeepConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Karakeep sensors based on a config entry.""" + async_add_entities( + KarakeepStatSensor(entry, description) for description in SENSOR_DESCRIPTIONS + ) + + +class KarakeepStatSensor(KarakeepEntity, SensorEntity): + """Representation of a Karakeep statistic as a sensor entity.""" + + entity_description: KarakeepSensorEntityDescription + + def __init__( + self, + entry: KarakeepConfigEntry, + entity_description: KarakeepSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(entry.runtime_data) + self.entity_description = entity_description + self._attr_unique_id = f"{entry.entry_id}_{entity_description.key}" + + @property + @override + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/karakeep/strings.json b/homeassistant/components/karakeep/strings.json new file mode 100644 index 000000000000..1aceb6c4e076 --- /dev/null +++ b/homeassistant/components/karakeep/strings.json @@ -0,0 +1,57 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "api_error": "The Karakeep API returned an unexpected response.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url_format": "Enter a valid URL including http:// or https://.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "token": "API token", + "url": "URL", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "token": "The API token used to connect to your Karakeep instance.", + "url": "The URL of your Karakeep instance, including http:// or https://.", + "verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates." + }, + "description": "Connect to your Karakeep instance." + } + } + }, + "entity": { + "sensor": { + "archived": { + "name": "Archived", + "unit_of_measurement": "items" + }, + "bookmarks": { + "name": "Bookmarks", + "unit_of_measurement": "bookmarks" + }, + "favorites": { + "name": "Favorites", + "unit_of_measurement": "favorites" + }, + "highlights": { + "name": "Highlights", + "unit_of_measurement": "highlights" + }, + "lists": { + "name": "Lists", + "unit_of_measurement": "lists" + }, + "tags": { + "name": "Tags", + "unit_of_measurement": "tags" + } + } + } +} diff --git a/homeassistant/components/kiosker/config_flow.py b/homeassistant/components/kiosker/config_flow.py index 85e43f280e03..9e2d51ab6d8c 100644 --- a/homeassistant/components/kiosker/config_flow.py +++ b/homeassistant/components/kiosker/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the Kiosker integration.""" +from collections.abc import Mapping import logging from typing import Any, override @@ -37,6 +38,11 @@ STEP_ZEROCONF_CONFIRM_DATA_SCHEMA = vol.Schema( vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_SSL_VERIFY): bool, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } +) async def validate_input( @@ -121,6 +127,43 @@ class KioskerConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + config_data = { + **reauth_entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + } + validation_errors, device_id = await validate_input(self.hass, config_data) + if validation_errors: + errors.update(validation_errors) + else: + assert device_id is not None + await self.async_set_unique_id(device_id, raise_on_progress=False) + self._abort_if_unique_id_mismatch(reason="wrong_device") + 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=STEP_REAUTH_DATA_SCHEMA, + description_placeholders={"name": reauth_entry.title}, + errors=errors, + ) + @override async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo diff --git a/homeassistant/components/kiosker/manifest.json b/homeassistant/components/kiosker/manifest.json index fc8c2ed911fa..7256cda13b22 100644 --- a/homeassistant/components/kiosker/manifest.json +++ b/homeassistant/components/kiosker/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/kiosker", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["kiosker-python-api==1.2.9"], "zeroconf": ["_kiosker._tcp.local."] } diff --git a/homeassistant/components/kiosker/quality_scale.yaml b/homeassistant/components/kiosker/quality_scale.yaml index 6b4e2e01cefb..cd3cf4e2bb63 100644 --- a/homeassistant/components/kiosker/quality_scale.yaml +++ b/homeassistant/components/kiosker/quality_scale.yaml @@ -34,11 +34,12 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done + diagnostics: todo discovery-update-info: todo discovery: done docs-data-update: done diff --git a/homeassistant/components/kiosker/strings.json b/homeassistant/components/kiosker/strings.json index 2e700a3a43eb..ea117b290b8a 100644 --- a/homeassistant/components/kiosker/strings.json +++ b/homeassistant/components/kiosker/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_device": "The device does not match the configured device." }, "error": { "bad_request": "Invalid request. Check your configuration.", @@ -13,6 +15,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The API token for the Kiosker App. This can be generated in the app API settings." + }, + "description": "Re-authenticate {name} with Home Assistant. Generate a new API token in the Kiosker app settings.", + "title": "Re-authenticate Kiosker" + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index c264f05f9916..4cd03a8e9653 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -13,10 +13,9 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ( ATTR_ID, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_PASSWORD, CONF_USERNAME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -68,12 +67,8 @@ class KiwiLock(LockEntity): self._state = LockState.LOCKED address = kiwi_lock.get("address") - address.update( - { - ATTR_LATITUDE: address.pop("lat", None), - ATTR_LONGITUDE: address.pop("lng", None), - } - ) + latitude = address.pop("lat", None) + longitude = address.pop("lng", None) self._device_attrs = { ATTR_ID: self.lock_id, @@ -81,6 +76,8 @@ class KiwiLock(LockEntity): ATTR_PERMISSION: kiwi_lock.get("highest_permission"), ATTR_CAN_INVITE: kiwi_lock.get("can_invite"), **address, + EntityStateAttribute.LATITUDE: latitude, + EntityStateAttribute.LONGITUDE: longitude, } @property diff --git a/homeassistant/components/klik_aan_klik_uit/strings.json b/homeassistant/components/klik_aan_klik_uit/strings.json index 14c367c6cfce..e9648dba6381 100644 --- a/homeassistant/components/klik_aan_klik_uit/strings.json +++ b/homeassistant/components/klik_aan_klik_uit/strings.json @@ -8,7 +8,7 @@ "error": {}, "step": { "pairing_mode": { - "description": "Bring device into learn mode by pushing it's button for more than 2 seconds, then press Ok.", + "description": "Bring the device into learn mode by pushing its button for more than 2 seconds, then press OK.", "title": "Pair device" }, "pairing_result": { diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index a1ec1e84547f..6dff1f12f512 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -24,11 +24,13 @@ from .const import ( CONF_KNX_KNXKEY_FILENAME, CONF_KNX_RATE_LIMIT, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DATA_HASS_CONFIG, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_PATH_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, @@ -165,10 +167,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", entry.version) - if entry.version > 2: - # Don't migrate from future version - return False - if entry.version == 1: new_data = {**entry.data} new_options = {**entry.options} @@ -192,11 +190,23 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options.setdefault(CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER) new_options.setdefault(CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT) + new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE + hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, version=2 + entry, data=new_data, options=new_options, version=2, minor_version=2 ) _LOGGER.info("Migration to version 2 successful") + if entry.version == 2 and entry.minor_version < 2: + # version 2.2 introduced in 2026.8 + new_options = {**entry.options} + if CONF_KNX_TELEGRAM_DB_BACKEND not in new_options: + new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE + hass.config_entries.async_update_entry( + entry, options=new_options, minor_version=2 + ) + _LOGGER.info("Migration to version 2.2 successful") + return True diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 718c76aca29e..f652725d5152 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -1,19 +1,24 @@ """Support for KNX button entities.""" -from typing import override +from typing import Any, override -from xknx.devices import RawValue as XknxRawValue +from xknx.devices import ExposeSensor as XknxExposeSensor, RawValue as XknxRawValue from homeassistant import config_entries from homeassistant.components.button import ButtonEntity from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType -from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import CONF_PAYLOAD_LENGTH, CONF_VALUE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule +from .storage.const import CONF_DATA, CONF_ENTITY, CONF_GA_SEND +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -21,27 +26,60 @@ async def async_setup_entry( config_entry: config_entries.ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the KNX binary sensor platform.""" + """Set up button(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.BUTTON] + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.BUTTON, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiButton, + ), + ) - async_add_entities(KNXButton(knx_module, entity_config) for entity_config in config) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.BUTTON): + entities.extend( + KnxYamlButton(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.BUTTON): + entities.extend( + KnxUiButton(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXButton(KnxYamlEntity, ButtonEntity): +class _KnxButton(ButtonEntity): """Representation of a KNX button.""" + _device: XknxRawValue | XknxExposeSensor + _payload: Any + + @override + async def async_press(self) -> None: + """Press the button.""" + await self._device.set(self._payload) + + +class KnxYamlButton(_KnxButton, KnxYamlEntity): + """Representation of a KNX button configured via YAML.""" + _device: XknxRawValue def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: """Initialize a KNX button.""" + # dpt-value to payload conversion is done in schema validation for yaml config + self._payload = config[CONF_PAYLOAD] self._device = XknxRawValue( xknx=knx_module.xknx, name=config[CONF_NAME], payload_length=config[CONF_PAYLOAD_LENGTH], group_address=config[KNX_ADDRESS], ) - self._payload = config[CONF_PAYLOAD] super().__init__( knx_module=knx_module, unique_id=f"{self._device.remote_value.group_address}_{self._payload}", @@ -49,7 +87,39 @@ class KNXButton(KnxYamlEntity, ButtonEntity): entity_category=config.get(CONF_ENTITY_CATEGORY), ) - @override - async def async_press(self) -> None: - """Press the button.""" - await self._device.set(self._payload) + +class KnxUiButton(_KnxButton, KnxUiEntity): + """Representation of a KNX button configured via the UI.""" + + _device: XknxRawValue | XknxExposeSensor + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize a KNX button.""" + knx_conf = ConfigExtractor(config[DOMAIN]) + button_data = knx_conf.get(CONF_DATA) + if CONF_PAYLOAD in button_data and CONF_PAYLOAD_LENGTH in button_data: + self._payload = int(button_data[CONF_PAYLOAD], 16) + self._device = XknxRawValue( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + payload_length=button_data[CONF_PAYLOAD_LENGTH], + group_address=knx_conf.get_write(CONF_GA_SEND), + ) + else: + dpt_string = knx_conf.get_dpt(CONF_GA_SEND) + self._payload = button_data[CONF_VALUE] + self._device = XknxExposeSensor( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + value_type=dpt_string, + group_address=knx_conf.get_write(CONF_GA_SEND), + respond_to_read=False, + ) + + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 50a2c7206b44..c612f26714d4 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -1,8 +1,12 @@ """Config flow for KNX.""" +import asyncio from collections.abc import AsyncGenerator from typing import Any, Final, Literal, override +from urllib.parse import quote, unquote, urlparse, urlunparse +from knx_telegram_store import ConnectionErrorKind +from knx_telegram_store.backends.postgres import PostgresStore import voluptuous as vol from xknx import XKNX from xknx.exceptions.exception import ( @@ -49,8 +53,16 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_DATABASE, + CONF_KNX_TELEGRAM_DB_HOST, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, + CONF_KNX_TELEGRAM_DB_PASSWORD, + CONF_KNX_TELEGRAM_DB_PORT, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, + CONF_KNX_TELEGRAM_DB_TLS, + CONF_KNX_TELEGRAM_DB_USER, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, @@ -58,6 +70,8 @@ from .const import ( DEFAULT_ROUTING_IA, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, KNXConfigEntryData, @@ -82,12 +96,17 @@ DEFAULT_ENTRY_OPTIONS = KNXConfigEntryOptions( state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT, telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + telegram_db_backend=KNX_TELEGRAM_BACKEND_SQLITE, ) CONF_KEYRING_FILE: Final = "knxkeys_file" CONF_KNX_TELEGRAM_STORE_SECTION: Final = "telegram_store_section" +# Timeout for the PostgreSQL connection check, so an unreachable host cannot +# block the options flow until the driver/OS connection timeout expires. +DSN_CHECK_TIMEOUT = 10 + CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { CONF_KNX_TUNNELING: "UDP (Tunneling v1)", @@ -113,6 +132,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize KNX config flow.""" @@ -951,6 +971,7 @@ class KNXOptionsFlow(OptionsFlowWithReload): """Manage KNX communication settings.""" if user_input is not None: telegram_store_section = user_input[CONF_KNX_TELEGRAM_STORE_SECTION] + backend = telegram_store_section[CONF_KNX_TELEGRAM_DB_BACKEND] self.new_entry_options |= KNXConfigEntryOptions( state_updater=user_input[CONF_KNX_STATE_UPDATER], rate_limit=user_input[CONF_KNX_RATE_LIMIT], @@ -960,7 +981,10 @@ class KNXOptionsFlow(OptionsFlowWithReload): telegram_db_retention_days=telegram_store_section[ CONF_KNX_TELEGRAM_DB_RETENTION_DAYS ], + telegram_db_backend=backend, ) + if backend == KNX_TELEGRAM_BACKEND_POSTGRES: + return await self.async_step_telegram_store_postgres() return self.finish_flow() data_schema = { @@ -1020,6 +1044,22 @@ class KNXOptionsFlow(OptionsFlowWithReload): ), vol.Coerce(int), ), + vol.Required( + CONF_KNX_TELEGRAM_DB_BACKEND, + default=self.initial_options.get( + CONF_KNX_TELEGRAM_DB_BACKEND, + KNX_TELEGRAM_BACKEND_SQLITE, + ), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + KNX_TELEGRAM_BACKEND_SQLITE, + KNX_TELEGRAM_BACKEND_POSTGRES, + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="telegram_backend", + ) + ), } ), ), @@ -1027,5 +1067,136 @@ class KNXOptionsFlow(OptionsFlowWithReload): return self.async_show_form( step_id="communication_settings", data_schema=vol.Schema(data_schema), + last_step=False, + ) + + async def async_step_telegram_store_postgres( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect and validate the PostgreSQL telegram store connection.""" + current_dsn = self.initial_options.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "") + parsed = _parse_dsn(current_dsn) + errors: dict[str, str] = {} + + if user_input is not None: + # Reuse the stored password when the field is left blank. + params = { + **user_input, + CONF_KNX_TELEGRAM_DB_PASSWORD: ( + user_input.get(CONF_KNX_TELEGRAM_DB_PASSWORD) + or parsed.get(CONF_KNX_TELEGRAM_DB_PASSWORD, "") + ), + } + dsn = _build_dsn(params) + errors = await _async_check_postgres_dsn(dsn) + if not errors: + self.new_entry_options |= KNXConfigEntryOptions( + telegram_db_postgres_dsn=dsn + ) + return self.finish_flow() + + data_schema = vol.Schema( + { + vol.Required( + CONF_KNX_TELEGRAM_DB_HOST, + default=parsed.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost"), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_PORT, + default=parsed.get(CONF_KNX_TELEGRAM_DB_PORT, 5432), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=65535, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ), + vol.Required( + CONF_KNX_TELEGRAM_DB_USER, + default=parsed.get(CONF_KNX_TELEGRAM_DB_USER, ""), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_PASSWORD, default="" + ): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required( + CONF_KNX_TELEGRAM_DB_DATABASE, + default=parsed.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_TLS, + default=parsed.get(CONF_KNX_TELEGRAM_DB_TLS, False), + ): selector.BooleanSelector(), + } + ) + if user_input is not None: + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + return self.async_show_form( + step_id="telegram_store_postgres", + data_schema=data_schema, + errors=errors, last_step=True, ) + + +async def _async_check_postgres_dsn(dsn: str) -> dict[str, str]: + """Validate a PostgreSQL DSN, returning form errors on failure.""" + connection_errors = { + ConnectionErrorKind.AUTH: "invalid_auth", + ConnectionErrorKind.HOST_UNREACHABLE: "host_unreachable", + ConnectionErrorKind.DATABASE_MISSING: "database_missing", + ConnectionErrorKind.PERMISSION: "permission", + ConnectionErrorKind.TIMEOUT: "timeout", + ConnectionErrorKind.MISSING_DEPENDENCY: "missing_dependency", + } + try: + async with asyncio.timeout(DSN_CHECK_TIMEOUT): + check_result = await PostgresStore.check_config(dsn) + except TimeoutError: + return {"base": "timeout"} + except ValueError: + return {"base": "cannot_connect"} + if not check_result.ok: + return {"base": connection_errors.get(check_result.kind, "cannot_connect")} + return {} + + +def _build_dsn(params: dict[str, Any]) -> str: + """Build a PostgreSQL DSN from form params.""" + quoted_user = quote(params.get(CONF_KNX_TELEGRAM_DB_USER, ""), safe="") + quoted_password = quote(params.get(CONF_KNX_TELEGRAM_DB_PASSWORD, ""), safe="") + host = params.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost") + if ":" in host and not host.startswith("["): + # IPv6 literals must be bracketed in the URL netloc + host = f"[{host}]" + port = int(params.get(CONF_KNX_TELEGRAM_DB_PORT, 5432)) + quoted_database = quote( + params.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), safe="" + ) + tls = params.get(CONF_KNX_TELEGRAM_DB_TLS, False) + + netloc = f"{quoted_user}:{quoted_password}@{host}:{port}" + query = "sslmode=require" if tls else "" + return urlunparse(("postgresql", netloc, f"/{quoted_database}", "", query, "")) + + +def _parse_dsn(dsn: str) -> dict[str, Any]: + """Parse a PostgreSQL DSN into form params.""" + if not dsn: + return {} + try: + url = urlparse(dsn) + return { + CONF_KNX_TELEGRAM_DB_USER: unquote(url.username or ""), + CONF_KNX_TELEGRAM_DB_PASSWORD: unquote(url.password or ""), + CONF_KNX_TELEGRAM_DB_HOST: url.hostname or "localhost", + CONF_KNX_TELEGRAM_DB_PORT: url.port or 5432, + CONF_KNX_TELEGRAM_DB_DATABASE: unquote(url.path.lstrip("/")), + CONF_KNX_TELEGRAM_DB_TLS: "sslmode=require" in url.query, + } + except ValueError, AttributeError: + return {} diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index b12d6241bb50..f1c203d18a33 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -26,6 +26,7 @@ KNX_ADDRESS: Final = "address" CONF_INVERT: Final = "invert" CONF_KNX_EXPOSE: Final = "expose" CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address" +CONF_VALUE: Final = "value" ## # Connection constants @@ -52,8 +53,20 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0 DEFAULT_ROUTING_IA: Final = "0.0.240" +CONF_KNX_TELEGRAM_DB_BACKEND: Final = "telegram_db_backend" CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: Final = "telegram_db_retention_days" CONF_KNX_TELEGRAM_DB_LOAD_HOURS: Final = "telegram_db_load_hours" +CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: Final = "telegram_db_postgres_dsn" + +CONF_KNX_TELEGRAM_DB_HOST: Final = "host" +CONF_KNX_TELEGRAM_DB_PORT: Final = "port" +CONF_KNX_TELEGRAM_DB_USER: Final = "user" +CONF_KNX_TELEGRAM_DB_PASSWORD: Final = "password" +CONF_KNX_TELEGRAM_DB_DATABASE: Final = "database" +CONF_KNX_TELEGRAM_DB_TLS: Final = "tls" + +KNX_TELEGRAM_BACKEND_SQLITE: Final = "sqlite" +KNX_TELEGRAM_BACKEND_POSTGRES: Final = "postgres" KNX_TELEGRAM_DB_RETENTION_DEFAULT: Final = 10 # days KNX_TELEGRAM_LOAD_HOURS_DEFAULT: Final = 24 # 1 day @@ -138,6 +151,8 @@ class KNXConfigEntryOptions(TypedDict, total=False): # Integration only (not forwarded to xknx) telegram_db_retention_days: int telegram_db_load_hours: int + telegram_db_backend: str # sqlite | postgres + telegram_db_postgres_dsn: str class ColorTempModes(Enum): @@ -178,6 +193,7 @@ SUPPORTED_PLATFORMS_YAML: Final = { SUPPORTED_PLATFORMS_UI: Final = { Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.DATE, diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index e84c9f2c7941..ec7c7cb2c220 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -79,10 +79,8 @@ class _KNXDate(DateEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XKNXDate.from_date( dt_date.fromisoformat(last_state.state) ) diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 91c81eba8f15..04674fa4cd28 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -80,10 +80,8 @@ class _KNXDateTime(DateTimeEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XKNXDateTime.from_datetime( datetime.fromisoformat(last_state.state).astimezone( dt_util.get_default_time_zone() diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index c685a5123b0c..d637eb551888 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -15,6 +15,7 @@ from .const import ( CONF_KNX_ROUTING_BACKBONE_KEY, CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, DOMAIN, KNX_MODULE_KEY, ) @@ -24,6 +25,7 @@ TO_REDACT = { CONF_KNX_KNXKEY_PASSWORD, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_SECURE_DEVICE_AUTHENTICATION, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, } diff --git a/homeassistant/components/knx/dpt.py b/homeassistant/components/knx/dpt.py index bb5792c00616..2ab86f0626a3 100644 --- a/homeassistant/components/knx/dpt.py +++ b/homeassistant/components/knx/dpt.py @@ -2,9 +2,9 @@ from collections.abc import Mapping from functools import cache -from typing import Literal, TypedDict +from typing import Literal, NotRequired, TypedDict, cast -from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric +from xknx.dpt import DPTBase, DPTComplex, DPTComplexFieldSchema, DPTEnum, DPTNumeric from xknx.dpt.dpt_16 import DPTString from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass @@ -24,15 +24,28 @@ class DPTInfo(TypedDict): sensor_device_class: SensorDeviceClass | None sensor_state_class: SensorStateClass | None + payload_length: int + + # numeric specific + min: NotRequired[float] + max: NotRequired[float] + step: NotRequired[float] + + # enum specific + options: NotRequired[list[str]] + + # complex specific + schema: NotRequired[list[DPTComplexFieldSchema]] + @cache def get_supported_dpts() -> Mapping[str, DPTInfo]: """Return a mapping of supported DPTs with HA specific attributes.""" - dpts = {} + dpts: dict[str, DPTInfo] = {} for dpt_class in DPTBase.dpt_class_tree(): dpt_number_str = dpt_class.dpt_number_str() ha_dpt_class = _ha_dpt_class(dpt_class) - dpts[dpt_number_str] = DPTInfo( + info = DPTInfo( dpt_class=ha_dpt_class, main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests sub=dpt_class.dpt_sub_number, @@ -40,7 +53,15 @@ def get_supported_dpts() -> Mapping[str, DPTInfo]: unit=_sensor_unit_overrides.get(dpt_number_str, dpt_class.unit), sensor_device_class=_sensor_device_classes.get(dpt_number_str), sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str), + payload_length=dpt_class.payload_length, ) + if ha_dpt_class == "numeric": + _add_numeric_details(info, cast(type[DPTNumeric], dpt_class)) + elif ha_dpt_class == "enum": + _add_enum_details(info, cast(type[DPTEnum], dpt_class)) + elif ha_dpt_class == "complex": + _add_complex_details(info, cast(type[DPTComplex], dpt_class)) + dpts[dpt_number_str] = info return dpts @@ -57,6 +78,23 @@ def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass: raise ValueError("Unsupported DPT class") +def _add_numeric_details(dpt_info: DPTInfo, dpt_cls: type[DPTNumeric]) -> None: + """Add numeric specific details to the DPTInfo.""" + dpt_info["min"] = dpt_cls.value_min + dpt_info["max"] = dpt_cls.value_max + dpt_info["step"] = dpt_cls.resolution + + +def _add_enum_details(dpt_info: DPTInfo, dpt_cls: type[DPTEnum]) -> None: + """Add enum specific details to the DPTInfo.""" + dpt_info["options"] = [o.name.lower() for o in dpt_cls.get_valid_values()] + + +def _add_complex_details(dpt_info: DPTInfo, dpt_cls: type[DPTComplex]) -> None: + """Add complex specific details to the DPTInfo.""" + dpt_info["schema"] = dpt_cls.get_dict_schema() + + _sensor_device_classes: Mapping[str, SensorDeviceClass] = { "7.011": SensorDeviceClass.DISTANCE, "7.012": SensorDeviceClass.CURRENT, diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a67d99cc3c6a..4c372bb876e7 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -14,7 +14,7 @@ "xknx==3.16.0", "xknxproject==3.9.0", "knx-frontend==2026.6.23.203726", - "knx-telegram-store[sqlite]==0.3.2" + "knx-telegram-store[sqlite,postgres]==0.10.1" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index db59b9527eb5..b6102c805e86 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -82,10 +82,8 @@ class _KnxNumber(RestoreNumber): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if ( - not self._device.sensor_value.readable - and (last_state := await self.async_get_last_state()) - and (last_number_data := await self.async_get_last_number_data()) + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() ): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.sensor_value.value = last_number_data.native_value diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 0d416c7b7a1b..7c200a77bc37 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -57,6 +57,7 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, + CONF_VALUE, KNX_ADDRESS, ClimateConf, ColorTempModes, @@ -98,9 +99,12 @@ def _max_payload_value(payload_length: int) -> int: def button_payload_sub_validator(entity_config: OrderedDict) -> OrderedDict: - """Validate a button entity payload configuration.""" + """Validate a button entity payload configuration. + + Returns raw payload and length from value and type (DPT), if given. + """ if _type := entity_config.get(CONF_TYPE): - _payload = entity_config[ButtonSchema.CONF_VALUE] + _payload = entity_config[CONF_VALUE] if (transcoder := DPTBase.parse_transcoder(_type)) is None: raise vol.Invalid(f"'type: {_type}' is not a valid sensor type.") entity_config[CONF_PAYLOAD_LENGTH] = transcoder.payload_length @@ -234,8 +238,6 @@ class ButtonSchema(KNXPlatformSchema): PLATFORM = Platform.BUTTON - CONF_VALUE = "value" - payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`" length_or_type_msg = ( f"Please use only one of `{CONF_PAYLOAD_LENGTH}` or `{CONF_TYPE}`" diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index f67465291dc5..b9079ac9ee30 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -83,9 +83,7 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.remote_value.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if ( last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) and (option := self._option_payloads.get(last_state.state)) is not None diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index d9729684eb2e..530af816b435 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -19,6 +19,9 @@ CONF_GA_TIME: Final = "ga_time" CONF_GA_STEP: Final = "ga_step" +# Button +CONF_GA_SEND: Final = "ga_send" + # Climate CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current" CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 7fffd6baaa0e..26d4c0286908 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -3,7 +3,8 @@ from enum import StrEnum, unique import voluptuous as vol -from xknx.dpt import DPTNumeric +from xknx.dpt import DPTBase, DPTBinary, DPTNumeric +from xknx.exceptions import ConversionError from homeassistant.components.climate import HVACMode from homeassistant.components.number import ( @@ -36,9 +37,11 @@ from ..const import ( CONF_CONTEXT_TIMEOUT, CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, + CONF_PAYLOAD_LENGTH, CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, + CONF_VALUE, DOMAIN, SUPPORTED_PLATFORMS_UI, ClimateConf, @@ -92,6 +95,7 @@ from .const import ( CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SCENE, + CONF_GA_SEND, CONF_GA_SENSOR, CONF_GA_SETPOINT_SHIFT, CONF_GA_SPEED, @@ -115,6 +119,7 @@ from .knx_selector import ( GASelector, GroupSelect, GroupSelectOption, + KnxPayloadSelector, KNXSectionFlat, SyncStateSelector, ) @@ -169,6 +174,55 @@ BINARY_SENSOR_KNX_SCHEMA = vol.Schema( }, ) + +def _button_data_sub_validator(config: dict) -> dict: + """Validate data matching configured DPT.""" + dpt = config[CONF_GA_SEND].get(CONF_DPT) + transcoder = None + if dpt: + transcoder = DPTBase.parse_transcoder(dpt) + assert transcoder is not None # already checked by GASelector + + if CONF_VALUE in config[CONF_DATA]: + try: + transcoder.to_knx(config[CONF_DATA][CONF_VALUE]) + except ConversionError as ex: + raise vol.Invalid( + f"Value invalid for DPT {transcoder.dpt_number_str()}", + path=([CONF_DATA]), + ) from ex + elif CONF_PAYLOAD_LENGTH in config[CONF_DATA]: + length = config[CONF_DATA][CONF_PAYLOAD_LENGTH] + if length != transcoder.payload_length or ( + length != 0 and transcoder.payload_type is DPTBinary + ): + raise vol.Invalid( + f"Payload length invalid for DPT {transcoder.dpt_number_str()}", + path=([CONF_DATA]), + ) + return config + # without DPT only raw allowed -> payload + payload_length (checked by KnxPayloadSelector) + if CONF_PAYLOAD_LENGTH in config[CONF_DATA]: + return config + raise vol.Invalid("Invalid configuration for button entity") + + +BUTTON_KNX_SCHEMA = AllSerializeFirst( + vol.Schema( + { + vol.Required(CONF_GA_SEND): GASelector( + state=False, + write_required=True, + passive=False, + dpt=["numeric", "enum", "complex", "string"], + dpt_required=False, # for raw payload support + ), + vol.Required(CONF_DATA): KnxPayloadSelector(ga_path=CONF_GA_SEND), + }, + ), + _button_data_sub_validator, +) + COVER_KNX_SCHEMA = AllSerializeFirst( vol.Schema( { @@ -741,6 +795,7 @@ SENSOR_KNX_SCHEMA = AllSerializeFirst( KNX_SCHEMA_FOR_PLATFORM = { Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA, + Platform.BUTTON: BUTTON_KNX_SCHEMA, Platform.CLIMATE: CLIMATE_KNX_SCHEMA, Platform.COVER: COVER_KNX_SCHEMA, Platform.DATE: DATE_KNX_SCHEMA, diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index 9bc0a1cd382c..b216752a58ba 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -6,6 +6,9 @@ from typing import Any, override import voluptuous as vol +from homeassistant.const import CONF_PAYLOAD + +from ..const import CONF_PAYLOAD_LENGTH, CONF_VALUE from ..dpt import HaDptClass, get_supported_dpts from ..validation import ga_validator, maybe_ga_validator, sync_state_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE @@ -159,7 +162,11 @@ class GroupSelect(KNXSelectorBase): class GASelector(KNXSelectorBase): - """Selector for a KNX group address structure.""" + """Selector for a KNX group address structure. + + `dpt_required` optional dpt only apply to dpt-class lists, enums are always required. + `valid_dpt` is used in frontend to filter dropdown menu - no validation is done. + """ selector_type = "knx_group_address" @@ -171,6 +178,7 @@ class GASelector(KNXSelectorBase): write_required: bool = False, state_required: bool = False, dpt: type[Enum] | list[HaDptClass] | None = None, + dpt_required: bool = True, valid_dpt: str | Iterable[str] | None = None, ) -> None: """Initialize the group address selector.""" @@ -180,7 +188,7 @@ class GASelector(KNXSelectorBase): self.write_required = write_required self.state_required = state_required self.dpt = dpt - # valid_dpt is used in frontend to filter dropdown menu - no validation is done + self.dpt_required = dpt_required self.valid_dpt = (valid_dpt,) if isinstance(valid_dpt, str) else valid_dpt self.schema = self.build_schema() @@ -196,6 +204,7 @@ class GASelector(KNXSelectorBase): } if self.dpt is not None: if isinstance(self.dpt, list): + # optional / required is not passed to FE - only validated in BE options["dptClasses"] = self.dpt else: options["dptSelect"] = [ @@ -267,7 +276,8 @@ class GASelector(KNXSelectorBase): """Add DPT validator to the schema.""" if self.dpt is not None: if isinstance(self.dpt, list): - schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts()) + marker = vol.Required if self.dpt_required else vol.Optional + schema[marker(CONF_DPT)] = vol.In(get_supported_dpts()) else: schema[vol.Required(CONF_DPT)] = vol.In( {item.value for item in self.dpt} @@ -300,3 +310,64 @@ class SyncStateSelector(KNXSelectorBase): if not self.allow_false and not data: raise vol.Invalid(f"Sync state cannot be {data}") return self.schema(data) + + +class KnxPayloadSelector(KNXSelectorBase): + """Selector for KNX payload configuration. + + Raw payloads are stored as hex strings. + """ + + schema = vol.Any( + { + vol.Required(CONF_VALUE): object, + }, + { + vol.Required(CONF_PAYLOAD): str, + vol.Required(CONF_PAYLOAD_LENGTH): vol.All(int, vol.Range(min=0, max=14)), + }, + ) + selector_type = "knx_payload" + + def __init__(self, ga_path: str) -> None: + """Initialize the KNX payload selector.""" + self.ga_path = ga_path + + @override + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + return { + "type": self.selector_type, + "ga_path": self.ga_path, + } + + @override + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + validated = self.schema(data) + if CONF_PAYLOAD in validated and CONF_PAYLOAD_LENGTH in validated: + payload = validated[CONF_PAYLOAD] + payload_length = validated[CONF_PAYLOAD_LENGTH] + try: + int_payload = int(payload, 16) + except ValueError as ex: + raise vol.Invalid(f"Invalid payload format: {payload}") from ex + validated[CONF_PAYLOAD] = hex(int_payload) # prepends "0x" if not present + + if int_payload < 0: + raise vol.Invalid(f"Payload cannot be negative: {payload}") + if payload_length == 0: + # DPT 1,2,3 is marked length 0, has 6 bit size + if int_payload > 63: + raise vol.Invalid( + f"Payload exceeds DPT 1,2,3 limit of 0x3f (63): {payload}" + ) + else: + max_payload = (1 << (payload_length * 8)) - 1 + if int_payload > max_payload: + raise vol.Invalid( + f"Payload {payload} exceeds possible maximum for " + f"length {payload_length}: {hex(max_payload)}" + ) + # CONF_VALUE branch needs subvalidator as we don't have the DPT available here + return validated diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index b5af5bee9836..ddc1f06465c7 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -453,6 +453,19 @@ } } }, + "button": { + "description": "Entity for sending predefined values.", + "knx": { + "data": { + "description": "The value sent when the button is pressed. The format of the value depends on the DPT of the configured address.", + "label": "Data" + }, + "ga_send": { + "description": "Group address the value is sent to.", + "label": "Address" + } + } + }, "climate": { "description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.", "knx": { @@ -1012,8 +1025,32 @@ "title": "Information" }, "project": { - "description": "Inspect imported group addresses", + "description": "Inspect imported project", + "devices": { + "channels": "Channels", + "group_objects": "Group objects", + "lines": "Lines", + "locations": "Locations", + "not_found": "No devices found in project data.", + "title": "Devices" + }, + "group_addresses": { + "title": "[%key:component::knx::config_panel::common::group_addresses%]" + }, "title": "Project" + }, + "selectors": { + "knx-payload-selector": { + "dpt_missing": "No DPT selected – Typed mode not available", + "mode": { + "label": "Payload format", + "raw": "Raw payload", + "typed": "Typed value" + }, + "raw_length": "Payload length", + "raw_length_description": "Length of the raw payload in bytes. For DPT 1, 2 and 3 use `0`.", + "raw_payload": "Raw payload" + } } }, "device_automation": { @@ -1136,6 +1173,15 @@ } }, "options": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "database_missing": "The specified database does not exist.", + "host_unreachable": "Could not reach the database host.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_dependency": "Required database driver is not installed.", + "permission": "Insufficient privileges to access the database.", + "timeout": "Connection timed out." + }, "step": { "communication_settings": { "data": { @@ -1149,10 +1195,12 @@ "sections": { "telegram_store_section": { "data": { + "telegram_db_backend": "Telegram storage backend", "telegram_db_load_hours": "Group monitor history", "telegram_db_retention_days": "Retention period" }, "data_description": { + "telegram_db_backend": "Select where to store KNX telegram history.", "telegram_db_load_hours": "Number of hours of telegram history to load when the group monitor is opened.", "telegram_db_retention_days": "Number of days to keep telegram history. Older telegrams are automatically deleted nightly at 3 AM. Set to `0` to delete all telegram history on every nightly run." }, @@ -1160,6 +1208,25 @@ } }, "title": "Communication settings" + }, + "telegram_store_postgres": { + "data": { + "database": "Database name", + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "tls": "Use TLS", + "user": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "database": "Name of the database to store telegrams in.", + "host": "Hostname or IP address of the PostgreSQL server.", + "password": "Password for the PostgreSQL user. Leave blank to keep the current password.", + "port": "Port the PostgreSQL server is listening on.", + "tls": "Encrypt the connection to the PostgreSQL server (`sslmode=require`). Note that the server certificate is not verified.", + "user": "Username to authenticate with the PostgreSQL server." + }, + "title": "PostgreSQL connection" } } }, @@ -1234,6 +1301,12 @@ "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" } + }, + "telegram_backend": { + "options": { + "postgres": "PostgreSQL (External)", + "sqlite": "Internal storage (Default)" + } } }, "services": { diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index c7db8212af17..dc9e19b8f341 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -79,17 +79,15 @@ class _KnxSwitch(SwitchEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.switch.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.switch.value = last_state.state == STATE_ON @property @override - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if device is on.""" - return bool(self._device.state) + return self._device.state @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 3d48589d2451..0e7acb36dfe8 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -8,6 +8,7 @@ import os from typing import Any, TypedDict from knx_telegram_store import ( + BufferedPostgresStore, BufferedSqliteStore, KnxTelegramStoreException, StoredTelegram, @@ -26,7 +27,10 @@ from homeassistant.helpers.storage import STORAGE_DIR, Store from homeassistant.util import dt as dt_util from .const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, + KNX_TELEGRAM_BACKEND_POSTGRES, KNX_TELEGRAM_DB_PATH_SQLITE, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, SIGNAL_KNX_TELEGRAM, @@ -48,6 +52,15 @@ EVICT_EXPIRED_HOUR = 3 # at risk from a longer interval are those buffered during an ungraceful shutdown. FLUSH_INTERVAL_SECONDS = 600 +# The buffer drops the oldest telegrams when full. Size it to cover a full +# flush interval at ~50 telegrams/s, the maximum rate of a KNX TP line, so +# nothing is dropped while the database is healthy. +MAX_BUFFER_TELEGRAMS = FLUSH_INTERVAL_SECONDS * 50 + +# Timeout for the migration probe and store initialization, so an unreachable +# database cannot block KNX setup until the driver/OS connection timeout expires. +STORE_INIT_TIMEOUT = 10 + class DecodedTelegramPayload(TypedDict): """Decoded payload value and metadata.""" @@ -89,19 +102,32 @@ class Telegrams: self.project = project self.config = config + self.backend: str = config[CONF_KNX_TELEGRAM_DB_BACKEND] + self.dsn: str = str(config.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "")) self.retention_days: int = config[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] - self.store: BufferedSqliteStore | None = None - self._uninitialized_store: BufferedSqliteStore | None = None + self.store: BufferedSqliteStore | BufferedPostgresStore | None = None + self._uninitialized_store: ( + BufferedSqliteStore | BufferedPostgresStore | None + ) = None self._evict_expired_unsub: CALLBACK_TYPE | None = None - full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) - os.makedirs(os.path.dirname(full_path), exist_ok=True) - self._uninitialized_store = BufferedSqliteStore( - full_path, - retention_days=self.retention_days, - flush_interval=FLUSH_INTERVAL_SECONDS, - ) + if self.backend == KNX_TELEGRAM_BACKEND_POSTGRES: + self._uninitialized_store = BufferedPostgresStore( + self.dsn, + retention_days=self.retention_days, + flush_interval=FLUSH_INTERVAL_SECONDS, + max_buffer_size=MAX_BUFFER_TELEGRAMS, + ) + else: + full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + self._uninitialized_store = BufferedSqliteStore( + full_path, + retention_days=self.retention_days, + flush_interval=FLUSH_INTERVAL_SECONDS, + max_buffer_size=MAX_BUFFER_TELEGRAMS, + ) self._xknx_telegram_cb_handle = ( xknx.telegram_queue.register_telegram_received_cb( @@ -121,7 +147,8 @@ class Telegrams: if self._uninitialized_store is None: return try: - needs_migration = await self._uninitialized_store.needs_migration() + async with asyncio.timeout(STORE_INIT_TIMEOUT): + needs_migration = await self._uninitialized_store.needs_migration() if needs_migration: _LOGGER.warning( "KNX telegram history database schema upgrade/migration is required. " @@ -129,24 +156,35 @@ class Telegrams: ) await self._uninitialized_store.initialize() else: - _LOGGER.debug("Initializing KNX telegram storage") - async with asyncio.timeout(10): + _LOGGER.debug( + "Initializing KNX telegram storage backend '%s'", + self.backend, + ) + async with asyncio.timeout(STORE_INIT_TIMEOUT): await self._uninitialized_store.initialize() - _LOGGER.info("Successfully initialized KNX telegram storage") + _LOGGER.info( + "Successfully initialized KNX telegram storage backend '%s'", + self.backend, + ) except TimeoutError: - _LOGGER.error("Timeout initializing KNX telegram storage") + _LOGGER.error( + "Timeout initializing KNX telegram storage backend '%s'", + self.backend, + ) await self._abort_store_init() return except KnxTelegramStoreException as err: _LOGGER.error( - "Database error initializing KNX telegram storage: %s", + "Database error initializing KNX telegram storage backend '%s': %s", + self.backend, err, ) await self._abort_store_init() return except Exception as err: # noqa: BLE001 _LOGGER.error( - "Error initializing KNX telegram storage: %s", + "Error initializing KNX telegram storage backend '%s': %s", + self.backend, err, ) await self._abort_store_init() diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index d96c41dc45ac..c42e1863e174 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -81,9 +81,7 @@ class _KnxText(TextEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.remote_value.readable and ( - last_state := await self.async_get_last_state() - ): + if last_state := await self.async_get_last_state(): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = last_state.state diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 99e16b0a2beb..dd42a23cf397 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -79,10 +79,8 @@ class _KNXTime(TimeEntity, RestoreEntity): """Restore last state.""" await super().async_added_to_hass() if ( - not self._device.remote_value.readable - and (last_state := await self.async_get_last_state()) is not None - and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) - ): + last_state := await self.async_get_last_state() + ) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): self._device.remote_value.value = XknxTime.from_time( dt_time.fromisoformat(last_state.state) ) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 4a79f7cdd9b0..568de4fe8220 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -8,7 +8,12 @@ import inspect from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel -from knx_telegram_store import KnxTelegramStoreException, TelegramQuery +from knx_telegram_store import ( + BufferedPostgresStore, + BufferedSqliteStore, + KnxTelegramStoreException, + TelegramQuery, +) import voluptuous as vol from xknx.telegram import Telegram from xknxproject.exceptions import XknxProjectException @@ -200,7 +205,11 @@ def ws_get_base_data( "connected": knx.xknx.connection_manager.connected.is_set(), "current_address": str(knx.xknx.current_address), "telegram_backend": ( - "sqlite" if knx.telegrams.store is not None else "unknown" + "sqlite" + if isinstance(knx.telegrams.store, BufferedSqliteStore) + else "postgres" + if isinstance(knx.telegrams.store, BufferedPostgresStore) + else "unknown" ), "telegram_retention": knx.telegrams.store.retention_days if knx.telegrams.store is not None diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 3a85a01c3fc8..a072b6edeff3 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -7,6 +7,7 @@ import uuid from aiohttp import ClientSession from pylamarzocco import LaMarzoccoCloudClient +from pylamarzocco.const import DeviceType from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from pylamarzocco.models import Thing from pylamarzocco.util import InstallationKey, generate_installation_key @@ -105,7 +106,11 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error connecting to server: %s", exc) errors["base"] = "cannot_connect" else: - self._things = {thing.serial_number: thing for thing in things} + self._things = { + thing.serial_number: thing + for thing in things + if thing.type is DeviceType.MACHINE + } if not self._things: errors["base"] = "no_machines" diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 15f5f7b76a9a..0abd73db29a1 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.4.1"] + "requirements": ["pylamarzocco==2.4.2"] } diff --git a/homeassistant/components/lawn_mower/llm.py b/homeassistant/components/lawn_mower/llm.py new file mode 100644 index 000000000000..c51d5ecdcbde --- /dev/null +++ b/homeassistant/components/lawn_mower/llm.py @@ -0,0 +1,38 @@ +"""LLM tools for the lawn_mower integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_LANW_MOWER_DOCK, INTENT_LANW_MOWER_START_MOWING) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/lcn/device_trigger.py b/homeassistant/components/lcn/device_trigger.py index 2421fa0b9f5e..799103e71c06 100644 --- a/homeassistant/components/lcn/device_trigger.py +++ b/homeassistant/components/lcn/device_trigger.py @@ -1,5 +1,7 @@ """Provides device triggers for LCN.""" +from typing import Any + import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -75,7 +77,7 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - event_data = { + event_data: dict[str, Any] = { CONF_DEVICE_ID: config[CONF_DEVICE_ID], **{ key: config[key] diff --git a/homeassistant/components/led_infrared/__init__.py b/homeassistant/components/led_infrared/__init__.py new file mode 100644 index 000000000000..12d4096ea426 --- /dev/null +++ b/homeassistant/components/led_infrared/__init__.py @@ -0,0 +1,18 @@ +"""The LED Infrared integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up LED Infrared from a config entry.""" + 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.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/led_infrared/config_flow.py b/homeassistant/components/led_infrared/config_flow.py new file mode 100644 index 000000000000..dc5998297723 --- /dev/null +++ b/homeassistant/components/led_infrared/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow for the LED Infrared integration.""" + +from typing import TYPE_CHECKING, Any, override + +import voluptuous as vol + +from homeassistant.components.infrared import ( + DOMAIN as INFRARED_DOMAIN, + async_get_emitters, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType + +DEVICE_NAMES = { + LEDIrDeviceType.GENERIC_24_KEY: "24-key remote", + LEDIrDeviceType.GENERIC_13_KEY: "13-key remote", +} + + +class LEDIrConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LED Infrared.""" + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID) + if emitter_id: + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: user_input[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: emitter_id, + } + ) + + title_entity_id = emitter_id + if TYPE_CHECKING: + assert title_entity_id is not None + ent_reg = er.async_get(self.hass) + entry = ent_reg.async_get(title_entity_id) + title_entity_name = ( + entry.name or entry.original_name or title_entity_id + if entry + else title_entity_id + ) + return self.async_create_entry( + title=f"LED light with {DEVICE_NAMES[LEDIrDeviceType(user_input[CONF_DEVICE_TYPE])]} via {title_entity_name}", + data=user_input, + ) + + errors["base"] = "missing_infrared_entity" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE_TYPE): SelectSelector( + SelectSelectorConfig( + options=[ + device_type.value for device_type in LEDIrDeviceType + ], + translation_key=CONF_DEVICE_TYPE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ), + } + ), + errors=errors, + description_placeholders={ + "docs_url": "https://www.home-assistant.io/integrations/led_infrared" + }, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID) + if emitter_id: + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: entry.data[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: emitter_id, + } + ) + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + + errors["base"] = "missing_infrared_entity" + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ) + } + ), + entry.data, + ), + errors=errors, + ) diff --git a/homeassistant/components/led_infrared/const.py b/homeassistant/components/led_infrared/const.py new file mode 100644 index 000000000000..7c5295f2b586 --- /dev/null +++ b/homeassistant/components/led_infrared/const.py @@ -0,0 +1,14 @@ +"""Constants for the LED Infrared integration.""" + +from enum import StrEnum + +DOMAIN = "led_infrared" +CONF_INFRARED_ENTITY_ID = "infrared_entity_id" +CONF_DEVICE_TYPE = "device_type" + + +class LEDIrDeviceType(StrEnum): + """LED Infrared device types.""" + + GENERIC_24_KEY = "generic_24_key" + GENERIC_13_KEY = "generic_13_key" diff --git a/homeassistant/components/led_infrared/diagnostics.py b/homeassistant/components/led_infrared/diagnostics.py new file mode 100644 index 000000000000..cd74ce1614ee --- /dev/null +++ b/homeassistant/components/led_infrared/diagnostics.py @@ -0,0 +1,14 @@ +"""Diagnostics platform for the LED Infrared integration.""" + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return dict(config_entry.data) diff --git a/homeassistant/components/led_infrared/icons.json b/homeassistant/components/led_infrared/icons.json new file mode 100644 index 000000000000..d1e1784e95ce --- /dev/null +++ b/homeassistant/components/led_infrared/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "blue": "mdi:palette", + "cyan": "mdi:palette", + "dark_cyan": "mdi:palette", + "fade": "mdi:gradient-horizontal", + "flash": "mdi:flash", + "green": "mdi:palette", + "light_green": "mdi:palette", + "mode_1": "mdi:numeric-1-box", + "mode_2": "mdi:numeric-2-box", + "mode_3": "mdi:numeric-3-box", + "mode_4": "mdi:numeric-4-box", + "mode_5": "mdi:numeric-5-box", + "mode_6": "mdi:numeric-6-box", + "mode_7": "mdi:numeric-7-box", + "mode_8": "mdi:numeric-8-box", + "orange": "mdi:palette", + "orange_red": "mdi:palette", + "plum": "mdi:palette", + "purple": "mdi:palette", + "rebecca_purple": "mdi:palette", + "red": "mdi:palette", + "sky_blue": "mdi:palette", + "smooth": "mdi:looks", + "strobe": "mdi:light-flood-down", + "tomato": "mdi:palette", + "turquoise": "mdi:palette", + "white": "mdi:palette", + "yellow": "mdi:palette" + } + } + } + } + } + } +} diff --git a/homeassistant/components/led_infrared/light.py b/homeassistant/components/led_infrared/light.py new file mode 100644 index 000000000000..7f0d385218a1 --- /dev/null +++ b/homeassistant/components/led_infrared/light.py @@ -0,0 +1,129 @@ +"""Light platform for LED Infrared integration.""" + +from typing import Any, override + +from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.light import ( + ATTR_EFFECT, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LEDIrDeviceType + +PARALLEL_UPDATES = 1 + +CODES = { + LEDIrDeviceType.GENERIC_24_KEY: Generic24KeyCode, + LEDIrDeviceType.GENERIC_13_KEY: Generic13KeyCode, +} + + +SUPPORTED_EFFECTS = { + LEDIrDeviceType.GENERIC_24_KEY: ["flash", "strobe", "fade", "smooth"], + LEDIrDeviceType.GENERIC_13_KEY: [ + "mode_1", + "mode_2", + "mode_3", + "mode_4", + "mode_5", + "mode_6", + "mode_7", + "mode_8", + ], +} + + +SUPPORTED_COLORS = { + LEDIrDeviceType.GENERIC_24_KEY: [ + "red", + "green", + "blue", + "white", + "tomato", + "light_green", + "sky_blue", + "orange_red", + "cyan", + "rebecca_purple", + "orange", + "turquoise", + "purple", + "yellow", + "dark_cyan", + "plum", + ], +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up platform from config entry.""" + if not (infrared_entity_id := entry.data.get(CONF_INFRARED_ENTITY_ID)): + return + + async_add_entities( + [LEDIrLightEntity(entry, entry.data[CONF_DEVICE_TYPE], infrared_entity_id)] + ) + + +class LEDIrLightEntity(InfraredEmitterConsumerEntity, LightEntity): + """Represents a LED Infrared light entity.""" + + _attr_assumed_state = True + _attr_color_mode = ColorMode.ONOFF + _attr_effect_list: list[str] + _attr_has_entity_name = True + _attr_name = None + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_supported_features = LightEntityFeature.EFFECT + _attr_translation_key = "light" + + def __init__( + self, + entry: ConfigEntry, + device_type: LEDIrDeviceType, + infrared_entity_id: str, + ) -> None: + """Initialize the entity.""" + self._attr_unique_id = entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + ) + + self._infrared_emitter_entity_id = infrared_entity_id + + self._codes = CODES[device_type] + self._attr_effect_list = SUPPORTED_EFFECTS.get( + device_type, [] + ) + SUPPORTED_COLORS.get(device_type, []) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + await self._send_command(self._codes.ON.to_command()) + self._attr_is_on = True + effect: str | None = kwargs.get(ATTR_EFFECT) + if effect and effect in self._attr_effect_list: + await self._send_command(self._codes[effect.upper()].to_command()) + self._attr_effect = effect + + self.async_write_ha_state() + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._send_command(self._codes.OFF.to_command()) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/led_infrared/manifest.json b/homeassistant/components/led_infrared/manifest.json new file mode 100644 index 000000000000..501f79a54b73 --- /dev/null +++ b/homeassistant/components/led_infrared/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "led_infrared", + "name": "LED Infrared", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "dependencies": ["infrared"], + "documentation": "https://www.home-assistant.io/integrations/led_infrared", + "integration_type": "device", + "iot_class": "assumed_state", + "quality_scale": "bronze" +} diff --git a/homeassistant/components/led_infrared/quality_scale.yaml b/homeassistant/components/led_infrared/quality_scale.yaml new file mode 100644 index 000000000000..5119557d203f --- /dev/null +++ b/homeassistant/components/led_infrared/quality_scale.yaml @@ -0,0 +1,120 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: + status: exempt + comment: This integration has only one platform + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: | + This integration does not store runtime data. + test-before-configure: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during config flow. + test-before-setup: + status: exempt + comment: | + This integration only proxies commands through an existing infrared + entity, so there is no separate connection to validate during setup. + unique-config-entry: done + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not register custom 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: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration does not support discovery. + discovery: + status: exempt + comment: | + This integration is configured manually via config flow. + 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: | + Each config entry creates a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No entities should be disabled by default + entity-translations: done + exception-translations: + status: exempt + comment: | + This integration does not raise exceptions. + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + This integration has no repairs. + stale-devices: + status: exempt + comment: | + Each config entry manages exactly one device. + + # Platinum + async-dependency: + status: exempt + comment: | + This integration depends on infrared_protocols which provides only code + definitions with no I/O, so async dependency does not apply. + inject-websession: + status: exempt + comment: | + This integration does not do HTTP requests. + strict-typing: done diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json new file mode 100644 index 000000000000..a7735543a8b6 --- /dev/null +++ b/homeassistant/components/led_infrared/strings.json @@ -0,0 +1,83 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured with this infrared entity.", + "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "missing_infrared_entity": "Select an infrared emitter." + }, + "step": { + "reconfigure": { + "data": { + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]" + }, + "data_description": { + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]" + }, + "title": "Reconfigure LED Infrared device" + }, + "user": { + "data": { + "device_type": "[%key:common::generic::device_type%]", + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]" + }, + "data_description": { + "device_type": "The type of remote control used for the LED light bulb, lamp, or controller.", + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]" + }, + "description": "Select the device type and an infrared emitter. You can identify the correct device based on the remote control used. Please refer to the [documentation]({docs_url}).", + "title": "Set up LED Infrared device" + } + } + }, + "entity": { + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "blue": "Color: Blue", + "cyan": "Color: Cyan", + "dark_cyan": "Color: Dark cyan", + "fade": "Fade", + "flash": "Flash", + "green": "Color: Green", + "light_green": "Color: Light green", + "mode_1": "Mode 1", + "mode_2": "Mode 2", + "mode_3": "Mode 3", + "mode_4": "Mode 4", + "mode_5": "Mode 5", + "mode_6": "Mode 6", + "mode_7": "Mode 7", + "mode_8": "Mode 8", + "orange": "Color: Orange", + "orange_red": "Color: Orange red", + "plum": "Color: Plum", + "purple": "Color: Purple", + "rebecca_purple": "Color: Rebecca purple", + "red": "Color: Red", + "sky_blue": "Color: Sky blue", + "smooth": "Smooth", + "strobe": "Strobe", + "tomato": "Color: Tomato", + "turquoise": "Color: Turquoise", + "white": "Color: White", + "yellow": "Color: Yellow" + } + } + } + } + } + }, + "selector": { + "device_type": { + "options": { + "generic_13_key": "13-key remote control", + "generic_24_key": "24-key remote control" + } + } + } +} diff --git a/homeassistant/components/lg_soundbar/__init__.py b/homeassistant/components/lg_soundbar/__init__.py index f32473c52811..6df848cfa15b 100644 --- a/homeassistant/components/lg_soundbar/__init__.py +++ b/homeassistant/components/lg_soundbar/__init__.py @@ -7,7 +7,6 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.exceptions import ConfigEntryNotReady from .config_flow import test_connect -from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -18,7 +17,6 @@ async def async_setup_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Set up platform from a ConfigEntry.""" - hass.data.setdefault(DOMAIN, {}) # Verify the device is reachable with the given # config before setting up the platform try: diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index b26364695429..b0254a11e27b 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + FAN_AUTO, FAN_MEDIUM, PRESET_NONE, SWING_OFF, @@ -66,6 +67,7 @@ SWING_TO_STR = {v: k for k, v in STR_TO_SWING.items()} STR_TO_HA_FAN: dict[str, str] = { "mid": FAN_MEDIUM, + "nature": FAN_AUTO, } HA_FAN_TO_STR = {v: k for k, v in STR_TO_HA_FAN.items()} @@ -276,16 +278,20 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): @override async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" + # Handle both reported fan mode variants ("auto" and "nature") safely. + thinq_fan_mode = HA_FAN_TO_STR.get(fan_mode, fan_mode) + if thinq_fan_mode not in self.data.fan_modes: + thinq_fan_mode = fan_mode _LOGGER.debug( "[%s:%s] async_set_fan_mode: %s", self.coordinator.device_name, self.property_id, - fan_mode, + thinq_fan_mode, ) await self.async_call_api( self.coordinator.api.async_set_fan_mode( self.property_id, - HA_FAN_TO_STR.get(fan_mode, fan_mode), + thinq_fan_mode, ) ) diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 0e614a8b363b..5cce4f3857a3 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -4,12 +4,13 @@ from collections.abc import Callable, Coroutine import logging from typing import Any, override +from aiohttp import ClientError from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState from homeassistant.core import callback -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,3 +113,10 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): if on_fail_method: on_fail_method() raise ServiceValidationError(exc) from exc + except (TimeoutError, ClientError) as exc: + if on_fail_method: + on_fail_method() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from exc diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index abd8d4d50d07..dd739a8ca21e 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -72,11 +72,15 @@ "fan_mode": { "state": { "auto": "[%key:common::state::auto%]", + "breeze": "Light breeze", "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", + "low_mid": "Low medium", "medium": "[%key:common::state::medium%]", + "mid_high": "Medium high", "power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]", - "slow": "Slow" + "slow": "Slow", + "slow_low": "Super low" } }, "preset_mode": { @@ -1159,6 +1163,9 @@ } }, "exceptions": { + "connection_error": { + "message": "Failed to connect to the LG ThinQ cloud. Please try again later." + }, "failed_to_connect_mqtt": { "message": "Failed to connect MQTT: {error}" } diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py index cab183044d38..3f997167dd36 100644 --- a/homeassistant/components/lg_thinq/vacuum.py +++ b/homeassistant/components/lg_thinq/vacuum.py @@ -65,15 +65,6 @@ ROBOT_STATUS_TO_HA = { "clean_select_gozone": VacuumActivity.CLEANING, "error": VacuumActivity.ERROR, } -ROBOT_BATT_TO_HA = { - "moveless": 5, - "dock_level": 5, - "low": 30, - "mid": 50, - "high": 90, - "full": 100, - "over_charge": 100, -} _LOGGER = logging.getLogger(__name__) @@ -106,7 +97,6 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): _attr_supported_features = ( VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.START | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME @@ -120,19 +110,12 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): # Update state. self._attr_activity = ROBOT_STATUS_TO_HA.get(self.data.current_state) - # Update battery. - if (level := self.data.battery) is not None: - self._attr_battery_level = ( - level if isinstance(level, int) else ROBOT_BATT_TO_HA.get(level, 0) - ) - _LOGGER.debug( - "[%s:%s] update status: %s -> %s (battery_level=%s)", + "[%s:%s] update status: %s -> %s", self.coordinator.device_name, self.property_id, self.data.current_state, self.state, - self.battery_level, ) @override diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 1fa5231a8db3..577cd1d737ba 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -24,6 +24,7 @@ from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ + Platform.COVER, Platform.LIGHT, Platform.NUMBER, Platform.SELECT, @@ -44,11 +45,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> try: devices = await client.get_devices() except LiebherrAuthenticationError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryAuthFailed("Invalid API key") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err except LiebherrConnectionError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err # Create a coordinator for each device (may be empty if no devices) data = LiebherrData(client=client) @@ -101,10 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> for device_id in device_ids: if coordinator := data.coordinators.pop(device_id, None): await coordinator.async_shutdown() - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) # Add new devices new_coordinators: list[LiebherrCoordinator] = [] diff --git a/homeassistant/components/liebherr/coordinator.py b/homeassistant/components/liebherr/coordinator.py index 6d9ae1004640..32887b08022a 100644 --- a/homeassistant/components/liebherr/coordinator.py +++ b/homeassistant/components/liebherr/coordinator.py @@ -60,12 +60,15 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]): try: await self.client.get_device(self.device_id) except LiebherrAuthenticationError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryAuthFailed("Invalid API key") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_api_key", + ) from err except LiebherrConnectionError as err: - # pylint: disable-next=home-assistant-exception-not-translated raise ConfigEntryNotReady( - f"Failed to connect to device {self.device_id}: {err}" + translation_domain=DOMAIN, + translation_key="device_connection_error", + translation_placeholders={"device_id": self.device_id}, ) from err @override @@ -74,15 +77,19 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]): try: return await self.client.get_device_state(self.device_id) except LiebherrAuthenticationError as err: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryAuthFailed("API key is no longer valid") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_expired", + ) from err except LiebherrTimeoutError as err: - # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed( - f"Timeout communicating with device {self.device_id}" + translation_domain=DOMAIN, + translation_key="device_timeout_error", + translation_placeholders={"device_id": self.device_id}, ) from err except LiebherrConnectionError as err: - # pylint: disable-next=home-assistant-exception-not-translated raise UpdateFailed( - f"Error communicating with device {self.device_id}" + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device_id": self.device_id}, ) from err diff --git a/homeassistant/components/liebherr/cover.py b/homeassistant/components/liebherr/cover.py new file mode 100644 index 000000000000..e6b8addf4c4c --- /dev/null +++ b/homeassistant/components/liebherr/cover.py @@ -0,0 +1,171 @@ +"""Cover platform for Liebherr integration.""" + +from typing import Any, override + +from pyliebherrhomeapi import AutoDoorControl, DoorState, ZonePosition + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +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 .const import DOMAIN +from .coordinator import LiebherrConfigEntry, LiebherrCoordinator +from .entity import ZONE_POSITION_MAP, LiebherrEntity + +PARALLEL_UPDATES = 1 + + +def _create_cover_entities( + coordinators: list[LiebherrCoordinator], +) -> list[LiebherrAutoDoor]: + """Create cover entities for the given coordinators.""" + entities: list[LiebherrAutoDoor] = [] + + for coordinator in coordinators: + has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1 + + entities.extend( + LiebherrAutoDoor( + coordinator=coordinator, + zone_id=zone_id, + has_multiple_zones=has_multiple_zones, + ) + for zone_id in coordinator.data.get_auto_door_controls() + ) + + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LiebherrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Liebherr cover entities.""" + async_add_entities( + _create_cover_entities(list(entry.runtime_data.coordinators.values())) + ) + + @callback + def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None: + """Add cover entities for new devices.""" + async_add_entities(_create_cover_entities(coordinators)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device + ) + ) + + +class LiebherrAutoDoor(LiebherrEntity, CoverEntity): + """Representation of a Liebherr auto door.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + _attr_translation_key = "auto_door" + _optimistic_state: bool | None = None + + def __init__( + self, + coordinator: LiebherrCoordinator, + zone_id: int, + has_multiple_zones: bool, + ) -> None: + """Initialize the auto door entity.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{coordinator.device_id}_auto_door_{zone_id}" + + # Add zone suffix only for multi-zone devices + if has_multiple_zones: + temp_controls = coordinator.data.get_temperature_controls() + if ( + (tc := temp_controls.get(zone_id)) + and isinstance(tc.zone_position, ZonePosition) + and (zone_key := ZONE_POSITION_MAP.get(tc.zone_position)) + ): + self._attr_translation_key = f"auto_door_{zone_key}" + + @property + def _auto_door_control(self) -> AutoDoorControl | None: + """Get the auto door control for this zone.""" + return self.coordinator.data.get_auto_door_controls().get(self._zone_id) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._auto_door_control is not None + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._optimistic_state = None + super()._handle_coordinator_update() + + @property + @override + def is_closed(self) -> bool | None: + """Return if the door is closed.""" + if self._optimistic_state is not None: + return False + control = self._auto_door_control + if control is None or control.value is None: + return None + return control.value == DoorState.CLOSED + + @property + @override + def is_opening(self) -> bool | None: + """Return if the door is opening.""" + if self._optimistic_state is None: + return False + return self._optimistic_state + + @property + @override + def is_closing(self) -> bool | None: + """Return if the door is closing.""" + if self._optimistic_state is None: + return False + return not self._optimistic_state + + async def _async_set_door(self, value: bool) -> None: + """Open or close the door.""" + self._optimistic_state = value + self.async_write_ha_state() + try: + await self._async_send_command( + self.coordinator.client.trigger_auto_door( + device_id=self.coordinator.device_id, + zone_id=self._zone_id, + value=value, + ) + ) + except HomeAssistantError as err: + self._optimistic_state = None + self.async_write_ha_state() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="open_auto_door_error" + if value + else "close_auto_door_error", + ) from err + + @override + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the door.""" + await self._async_set_door(True) + + @override + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the door.""" + await self._async_set_door(False) diff --git a/homeassistant/components/liebherr/strings.json b/homeassistant/components/liebherr/strings.json index 06d557f1eacf..ea44defe44e1 100644 --- a/homeassistant/components/liebherr/strings.json +++ b/homeassistant/components/liebherr/strings.json @@ -33,6 +33,20 @@ } }, "entity": { + "cover": { + "auto_door": { + "name": "AutoDoor" + }, + "auto_door_bottom_zone": { + "name": "Bottom zone AutoDoor" + }, + "auto_door_middle_zone": { + "name": "Middle zone AutoDoor" + }, + "auto_door_top_zone": { + "name": "Top zone AutoDoor" + } + }, "light": { "presentation_light": { "name": "Presentation light" @@ -203,8 +217,32 @@ } }, "exceptions": { + "auth_expired": { + "message": "API key is no longer valid" + }, + "cannot_connect": { + "message": "Failed to connect to the Liebherr API" + }, + "close_auto_door_error": { + "message": "An error occurred while closing the door" + }, "communication_error": { "message": "An error occurred while communicating with the device" + }, + "device_communication_error": { + "message": "Error communicating with device {device_id}" + }, + "device_connection_error": { + "message": "Failed to connect to device {device_id}" + }, + "device_timeout_error": { + "message": "Timeout communicating with device {device_id}" + }, + "invalid_api_key": { + "message": "Invalid API key" + }, + "open_auto_door_error": { + "message": "An error occurred while opening the door" } } } diff --git a/homeassistant/components/lifx/migration.py b/homeassistant/components/lifx/migration.py index 60aded43fb90..e84b2b465d3c 100644 --- a/homeassistant/components/lifx/migration.py +++ b/homeassistant/components/lifx/migration.py @@ -61,8 +61,7 @@ def async_migrate_entities_devices( migrated_devices.append(dev_entry.id) device_registry.async_update_device( dev_entry.id, - add_config_entry_id=new_entry.entry_id, - remove_config_entry_id=legacy_entry_id, + new_config_entry_id=new_entry.entry_id, ) entity_registry = er.async_get(hass) diff --git a/homeassistant/components/light/llm.py b/homeassistant/components/light/llm.py new file mode 100644 index 000000000000..5570245444f1 --- /dev/null +++ b/homeassistant/components/light/llm.py @@ -0,0 +1,38 @@ +"""LLM tools for the light integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import INTENT_SET + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_SET,) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 14b7df583341..bd44820fde92 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -29,6 +29,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + LightEntityStateAttribute, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_ON from homeassistant.core import HomeAssistant @@ -259,11 +260,15 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): await super().async_added_to_hass() if last_state := await self.async_get_last_state(): self._attr_is_on = last_state.state == STATE_ON - self._attr_brightness = last_state.attributes.get("brightness") + self._attr_brightness = last_state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ) self._attr_color_temp_kelvin = last_state.attributes.get( - "color_temp_kelvin" + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ) + self._attr_hs_color = last_state.attributes.get( + LightEntityStateAttribute.HS_COLOR ) - self._attr_hs_color = last_state.attributes.get("hs_color") @property @override diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py index fc2a9157e65e..acef5fec0d46 100644 --- a/homeassistant/components/linkplay/config_flow.py +++ b/homeassistant/components/linkplay/config_flow.py @@ -36,6 +36,17 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): # Do not probe the device if the host is already configured self._async_abort_entries_match({CONF_HOST: discovery_info.host}) + # Do not probe the device if the UUID advertised over mDNS matches + # an existing (or ignored) entry + if uuid := discovery_info.properties.get("uuid"): + # The advertised UUID is prefixed and dashed + # (uuid:FF31F09E-5001-...), while the device API (and therefore + # the stored unique id) uses the dashless form + await self.async_set_unique_id(uuid.removeprefix("uuid:").replace("-", "")) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info.host} + ) + session: ClientSession = await async_get_client_session(self.hass) bridge: LinkPlayBridge | None = None diff --git a/homeassistant/components/litellm/__init__.py b/homeassistant/components/litellm/__init__.py new file mode 100644 index 000000000000..5447eeb01454 --- /dev/null +++ b/homeassistant/components/litellm/__init__.py @@ -0,0 +1,33 @@ +"""The LiteLLM integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator + +PLATFORMS = [Platform.CONVERSATION] + + +async def async_setup_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool: + """Set up LiteLLM from a config entry.""" + coordinator = LiteLLMDataUpdateCoordinator(hass, entry) + 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(_async_update_listener)) + + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: LiteLLMConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: LiteLLMConfigEntry) -> bool: + """Unload LiteLLM.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/litellm/config_flow.py b/homeassistant/components/litellm/config_flow.py new file mode 100644 index 000000000000..0b8df8be1d44 --- /dev/null +++ b/homeassistant/components/litellm/config_flow.py @@ -0,0 +1,253 @@ +"""Config flow for LiteLLM integration.""" + +import logging +from typing import Any, override + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .const import ( + CONF_PROMPT, + DOMAIN, + PLACEHOLDER_API_KEY, + RECOMMENDED_CONVERSATION_OPTIONS, +) + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect to the proxy.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate the API key is invalid.""" + + +def _normalize_url(url: str) -> str: + """Normalize the proxy URL, ensuring it ends with the OpenAI `/v1` path.""" + parsed = URL(url.strip()) + path = parsed.path.rstrip("/") + if not path.endswith("/v1"): + path = f"{path}/v1" + return str(parsed.with_path(path)) + + +async def _get_models(hass: HomeAssistant, url: str, api_key: str | None) -> list[str]: + """Fetch the available model names from the LiteLLM proxy. + + Uses the OpenAI-compatible `/v1/models` endpoint, which a LiteLLM proxy + serves with the configured model names. + """ + client = AsyncOpenAI( + base_url=url, + api_key=api_key or PLACEHOLDER_API_KEY, + http_client=get_async_client(hass), + ) + try: + return [ + model.id async for model in client.with_options(timeout=10.0).models.list() + ] + except (AuthenticationError, PermissionDeniedError) as err: + raise InvalidAuth from err + except OpenAIError as err: + raise CannotConnect from err + + +class LiteLLMConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LiteLLM.""" + + VERSION = 1 + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + @override + 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: + url = _normalize_url(user_input[CONF_URL]) + api_key = user_input.get(CONF_API_KEY) + self._async_abort_entries_match({CONF_URL: url}) + try: + await _get_models(self.hass, url, api_key) + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + data = {CONF_URL: url} + if api_key: + data[CONF_API_KEY] = api_key + return self.async_create_entry( + title=URL(url).host or url, + data=data, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Optional(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class LiteLLMSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for LiteLLM.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.models: list[str] = [] + + async def _fetch_models(self) -> None: + """Fetch models from the LiteLLM proxy.""" + entry = self._get_entry() + self.models = await _get_models( + self.hass, entry.data[CONF_URL], entry.data.get(CONF_API_KEY) + ) + + +class ConversationFlowHandler(LiteLLMSubentryFlowHandler): + """Handle conversation subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self.options: dict[str, Any] = {} + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == SOURCE_USER + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a conversation agent.""" + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() + return await self.async_step_init(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a conversation agent.""" + self.options = self._get_reconfigure_subentry().data.copy() + return await self.async_step_init(user_input) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage conversation agent configuration.""" + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is not None: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if self._is_new: + return self.async_create_entry( + title=user_input[CONF_MODEL], data=user_input + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + title=user_input[CONF_MODEL], + data=user_input, + ) + + try: + await self._fetch_models() + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + options = [SelectOptionDict(value=model, label=model) for model in self.models] + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + + if suggested_llm_apis := self.options.get(CONF_LLM_HASS_API): + valid_api_ids = {api["value"] for api in hass_apis} + self.options[CONF_LLM_HASS_API] = [ + api for api in suggested_llm_apis if api in valid_api_ids + ] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_MODEL, default=self.options.get(CONF_MODEL) + ): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": self.options.get( + CONF_PROMPT, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_PROMPT], + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=self.options.get( + CONF_LLM_HASS_API, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ), + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ), + ) diff --git a/homeassistant/components/litellm/const.py b/homeassistant/components/litellm/const.py new file mode 100644 index 000000000000..8f645e234519 --- /dev/null +++ b/homeassistant/components/litellm/const.py @@ -0,0 +1,18 @@ +"""Constants for the LiteLLM integration.""" + +import logging + +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.helpers import llm + +DOMAIN = "litellm" +LOGGER = logging.getLogger(__package__) + +# LiteLLM proxies may run without authentication. The OpenAI client requires a +# non-empty API key, so we send a placeholder when the user did not provide one. +PLACEHOLDER_API_KEY = "sk-no-key-required" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/litellm/conversation.py b/homeassistant/components/litellm/conversation.py new file mode 100644 index 000000000000..c6d979aba8dd --- /dev/null +++ b/homeassistant/components/litellm/conversation.py @@ -0,0 +1,69 @@ +"""Conversation support for LiteLLM.""" + +from typing import Literal, override + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LiteLLMConfigEntry +from .const import DOMAIN +from .entity import LiteLLMEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LiteLLMConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry in config_entry.get_subentries_of_type("conversation"): + async_add_entities( + [LiteLLMConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class LiteLLMConversationEntity(LiteLLMEntity, conversation.ConversationEntity): + """LiteLLM conversation agent.""" + + _attr_name = None + + def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + @override + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + @override + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process the user input and call the API.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/litellm/coordinator.py b/homeassistant/components/litellm/coordinator.py new file mode 100644 index 000000000000..ecd856bf6fd8 --- /dev/null +++ b/homeassistant/components/litellm/coordinator.py @@ -0,0 +1,74 @@ +"""Coordinator for the LiteLLM integration.""" + +from datetime import timedelta +from typing import override + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant, callback +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 LOGGER, PLACEHOLDER_API_KEY + +# Ping the proxy hourly while it is reachable, and back off to once a minute +# while it is down so entities recover quickly once it returns. +UPDATE_INTERVAL_CONNECTED = timedelta(hours=1) +UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1) + +type LiteLLMConfigEntry = ConfigEntry[LiteLLMDataUpdateCoordinator] + + +class LiteLLMDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Own the OpenAI client and track LiteLLM proxy availability.""" + + config_entry: LiteLLMConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: LiteLLMConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL_CONNECTED, + always_update=False, + ) + self.client = AsyncOpenAI( + base_url=config_entry.data[CONF_URL], + api_key=config_entry.data.get(CONF_API_KEY) or PLACEHOLDER_API_KEY, + http_client=get_async_client(hass), + ) + + @override + async def _async_update_data(self) -> None: + """Ping the proxy to confirm it is reachable and authenticated.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + try: + async for _ in self.client.with_options(timeout=10.0).models.list(): + break + except (AuthenticationError, PermissionDeniedError) as err: + raise ConfigEntryAuthFailed from err + except OpenAIError as err: + raise UpdateFailed(err) from err + self.update_interval = UPDATE_INTERVAL_CONNECTED + + @callback + @override + def async_set_updated_data(self, data: None) -> None: + """Manually update data and reset to the connected interval.""" + self.update_interval = UPDATE_INTERVAL_CONNECTED + super().async_set_updated_data(data) + + @callback + def mark_connection_error(self) -> None: + """Flag the proxy as unreachable and schedule a quick recheck.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + if self.last_update_success: + self.last_update_success = False + self.async_update_listeners() + if self._listeners and not self.hass.is_stopping: + self._schedule_refresh() diff --git a/homeassistant/components/litellm/entity.py b/homeassistant/components/litellm/entity.py new file mode 100644 index 000000000000..dfd37a0e0f76 --- /dev/null +++ b/homeassistant/components/litellm/entity.py @@ -0,0 +1,211 @@ +"""Base entity for LiteLLM.""" + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCallParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_MODEL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LOGGER +from .coordinator import LiteLLMConfigEntry, LiteLLMDataUpdateCoordinator + +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + unsupported_keys = {"oneOf", "anyOf", "allOf"} + schema = convert(tool.parameters, custom_serializer=custom_serializer) + schema = {k: v for k, v in schema.items() if k not in unsupported_keys} + + tool_spec = FunctionDefinition( + name=tool.name, + parameters=schema, + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json_dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageFunctionToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json_dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError(f"Unexpected tool argument response: {err}") from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the LiteLLM message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if tool_call.type == "function" + ] + yield data + + +class LiteLLMEntity(CoordinatorEntity[LiteLLMDataUpdateCoordinator]): + """Base entity for LiteLLM.""" + + _attr_has_entity_name = True + + def __init__(self, entry: LiteLLMConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + super().__init__(entry.runtime_data) + self.entry = entry + self.subentry = subentry + self.model = subentry.data[CONF_MODEL] + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + model_args = { + "model": self.model, + "user": chat_log.conversation_id, + } + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if tools: + model_args["tools"] = tools + + model_args["messages"] = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + coordinator = self.entry.runtime_data + client = coordinator.client + + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create(**model_args) + except (openai.AuthenticationError, openai.PermissionDeniedError) as err: + # Re-check so the proxy is marked unavailable for the auth failure. + await coordinator.async_request_refresh() + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + except openai.APIConnectionError as err: + coordinator.mark_connection_error() + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + except openai.OpenAIError as err: + # Reachable but the request failed; keep the entity available. + coordinator.async_set_updated_data(None) + LOGGER.error("Error talking to API: %s", err) + raise HomeAssistantError("Error talking to API") from err + + if not result.choices: + LOGGER.error("API returned empty choices") + raise HomeAssistantError("API returned empty response") + + result_message = result.choices[0].message + + model_args["messages"].extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_response(result_message) + ) + if (msg := _convert_content_to_chat_message(content)) + ] + ) + if not chat_log.unresponded_tool_results: + coordinator.async_set_updated_data(None) + break + else: + LOGGER.warning( + "Stopped after %s tool iterations with unresolved tool calls", + MAX_TOOL_ITERATIONS, + ) diff --git a/homeassistant/components/litellm/manifest.json b/homeassistant/components/litellm/manifest.json new file mode 100644 index 000000000000..595ec0710b38 --- /dev/null +++ b/homeassistant/components/litellm/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "litellm", + "name": "LiteLLM", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@luismalves"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/litellm", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==2.45.0"] +} diff --git a/homeassistant/components/litellm/quality_scale.yaml b/homeassistant/components/litellm/quality_scale.yaml new file mode 100644 index 000000000000..448664369678 --- /dev/null +++ b/homeassistant/components/litellm/quality_scale.yaml @@ -0,0 +1,98 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: done + comment: >- + the coordinator polls the proxy hourly for an availability check, backing + off to once a minute while it is unreachable + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + 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: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: done + comment: >- + the conversation entity follows the coordinator and is marked unavailable + when the proxy cannot be reached + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be 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: devices are created via subentries, not discovered dynamically + entity-category: + status: exempt + comment: the conversation entity does not use entity categories + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/litellm/strings.json b/homeassistant/components/litellm/strings.json new file mode 100644 index 000000000000..c13cf5122080 --- /dev/null +++ b/homeassistant/components/litellm/strings.json @@ -0,0 +1,55 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "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%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "api_key": "An optional LiteLLM API key or virtual key. Leave empty if your proxy does not require authentication.", + "url": "The base URL of your LiteLLM proxy, including the host and port" + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "reconfigure": "Reconfigure conversation agent", + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "model": "[%key:common::generic::model%]", + "prompt": "[%key:common::config_flow::data::prompt%]" + }, + "data_description": { + "llm_hass_api": "Select which tools the model can use to interact with your devices and entities.", + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." + }, + "description": "Configure the conversation agent" + } + } + } + } +} diff --git a/homeassistant/components/llama_cpp/__init__.py b/homeassistant/components/llama_cpp/__init__.py new file mode 100644 index 000000000000..0153fc32c2d0 --- /dev/null +++ b/homeassistant/components/llama_cpp/__init__.py @@ -0,0 +1,60 @@ +"""The llama.cpp integration.""" + +import logging + +import openai + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) + +from .api import async_create_client, async_list_models + +_LOGGER = logging.getLogger(__name__) +PLATFORMS = (Platform.CONVERSATION,) + +type LlamaCppConfigEntry = ConfigEntry[openai.AsyncOpenAI] + + +async def async_setup_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool: + """Set up llama.cpp from a config entry.""" + client = await async_create_client(hass, entry.data) + + # Validate the connection by listing models + try: + await async_list_models(client) + except HomeAssistantError as err: + if err.translation_key == "invalid_auth": + raise ConfigEntryAuthFailed( + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) from err + raise ConfigEntryNotReady( + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) from err + + entry.runtime_data = client + + 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_unload_entry(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> bool: + """Unload llama.cpp.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_options(hass: HomeAssistant, entry: LlamaCppConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/llama_cpp/api.py b/homeassistant/components/llama_cpp/api.py new file mode 100644 index 000000000000..bf2c7bfa3959 --- /dev/null +++ b/homeassistant/components/llama_cpp/api.py @@ -0,0 +1,192 @@ +"""API client helper for llama.cpp integration. + +This module contains thin wrappers around the OpenAI completions APIs used +to simplify Home Assistant integration and configuration. It handles client +setup, model validation, and API error handling. +""" + +from collections.abc import Generator, Mapping +from contextlib import contextmanager +import logging +from typing import Any, cast + +import openai +from openai._streaming import AsyncStream +from openai.types.chat import ( + ChatCompletionChunk, + ChatCompletionMessageParam, + ChatCompletionToolParam, +) + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.httpx_client import get_async_client + +from .const import ( + CONF_BASE_URL, + DEFAULT_API_KEY, + DEFAULT_MODEL, + DOMAIN, + RECOMMENDED_CHAT_MODELS, +) + +_LOGGER = logging.getLogger(__name__) + + +# Simple prompt to test model basic chat completion capability. We send tools +# to ensure the model and server correctly supports tool calling. We set a +# minimal max_tokens to consume few resources. +_TEST_MESSAGES: list[ChatCompletionMessageParam] = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the capital of France?"}, +] +_TEST_TOOLS: list[ChatCompletionToolParam] = [ + { + "type": "function", + "function": { + "name": "test_function", + "description": "Test function.", + "parameters": {"type": "object", "properties": {}}, + }, + } +] +_TEST_MAX_TOKENS = 3 + + +async def async_create_client( + hass: HomeAssistant, config_entry_data: Mapping[str, Any] +) -> openai.AsyncOpenAI: + """Create a new OpenAI client.""" + api_key = config_entry_data.get(CONF_API_KEY) or DEFAULT_API_KEY + client = openai.AsyncOpenAI( + api_key=api_key, + base_url=config_entry_data[CONF_BASE_URL], + http_client=get_async_client(hass), + ) + # Cache current platform data which gets added to each request + # (caching done by library) + _ = await hass.async_add_executor_job(client.platform_headers) + return client + + +async def async_list_models(client: openai.AsyncOpenAI) -> list[str]: + """Return a list of models supported by the client.""" + with api_error_handler(): + page = await client.with_options(timeout=10.0).models.list() + return [model.id async for model in page] + + +async def async_validate_completions( + client: openai.AsyncOpenAI, + model: str, + stream: bool = False, +) -> None: + """Validate that we can speak to the model over the completions API.""" + with api_error_handler(): + result = await client.chat.completions.create( + model=model, + messages=_TEST_MESSAGES, + tools=_TEST_TOOLS, + max_tokens=_TEST_MAX_TOKENS, + stream=stream, + ) + + if stream: + stream_result = cast(AsyncStream[ChatCompletionChunk], result) + async for event in stream_result: + if not event.choices: + continue + if event.choices[0].finish_reason is not None: + continue + + +def recommended_model(models: list[str] | None) -> str: + """Return the selected model from user input.""" + if not models: + return DEFAULT_MODEL + for model in RECOMMENDED_CHAT_MODELS: + if model in models: + return model + return models[0] + + +def model_name_to_title(model_id: str) -> str: + """Convert a model ID into a human-readable title (inverse slugification). + + Examples: + - "deepseek-v4-flash" -> "Deepseek V4 Flash" + - "gpt-4" -> "Gpt 4" + - "llama-3.2-3b-instruct" -> "Llama 3.2 3b Instruct" + - "anthropic/claude-fable-5" -> "Anthropic Claude Fable 5" + """ + words = model_id.replace("-", " ").replace("_", " ").replace("/", " ").split() + return " ".join(word.capitalize() for word in words) + + +def _extract_error_message(err: openai.APIStatusError) -> str: + """Extract a clean error message from an APIStatusError response or message.""" + error_message = "" + if err.response is not None: + try: + json_data = err.response.json() + if isinstance(json_data, dict) and "error" in json_data: + error_message = json_data["error"].get("message") or "" + except ValueError: + pass + return error_message or err.message or str(err) + + +@contextmanager +def api_error_handler() -> Generator[None]: + """Context manager to handle API errors and translate them to HomeAssistantErrors.""" + try: + yield + except openai.APITimeoutError as err: + _LOGGER.error("Timeout talking to API: %s", err) + error_message = err.message or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={"message": error_message}, + ) from err + except openai.APIConnectionError as err: + _LOGGER.error("Connection error talking to API: %s", err) + error_message = err.message or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"message": error_message}, + ) from err + except openai.AuthenticationError as err: + _LOGGER.error("Authentication error talking to API: %s", err) + error_message = _extract_error_message(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"message": error_message}, + ) from err + except openai.APIStatusError as err: + _LOGGER.error("Status error talking to API: %s", err) + error_message = _extract_error_message(err) + + if err.status_code == 402: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="quota_exceeded", + translation_placeholders={"message": error_message}, + ) from err + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": error_message}, + ) from err + except openai.OpenAIError as err: + _LOGGER.error("Generic error talking to API: %s", err) + error_message = getattr(err, "message", None) or str(err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": error_message}, + ) from err diff --git a/homeassistant/components/llama_cpp/config_flow.py b/homeassistant/components/llama_cpp/config_flow.py new file mode 100644 index 000000000000..8c7e42c29ed5 --- /dev/null +++ b/homeassistant/components/llama_cpp/config_flow.py @@ -0,0 +1,383 @@ +"""Config flow for llama.cpp integration.""" + +import logging +from typing import Any, cast, override + +import openai +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) + +from .api import ( + async_create_client, + async_list_models, + async_validate_completions, + model_name_to_title, + recommended_model, +) +from .const import ( + CONF_BASE_URL, + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_RECOMMENDED, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_BASE_URL, + DOMAIN, + LOGGER, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str, + vol.Optional(CONF_API_KEY): str, + } +) + + +class LlamaCppConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for llama.cpp.""" + + VERSION = 1 + + data: dict[str, Any] | None = None + client: openai.AsyncOpenAI | None = None + models: list[str] | None = None + + @override + 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) + try: + self.client = await async_create_client(self.hass, user_input) + self.models = await async_list_models(self.client) + except HomeAssistantError as err: + LOGGER.error("Connection validation failed: %s", err) + errors["base"] = err.translation_key or "unknown" + except Exception: # pylint: disable=broad-except # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = user_input + return await self.async_step_model() + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_model( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle selecting a model.""" + assert self.client is not None + assert self.models is not None + assert self.data is not None + errors = {} + if user_input is not None: + model = user_input[CONF_CHAT_MODEL] + try: + await async_validate_completions( + self.client, + model=model, + stream=False, + ) + except HomeAssistantError as err: + LOGGER.error("Model completion validation failed: %s", err) + errors["base"] = err.translation_key or "unknown" + else: + stream_support = True + try: + await async_validate_completions( + self.client, + model=model, + stream=True, + ) + except HomeAssistantError: + stream_support = False + + base_options = { + **user_input, + } + return self.async_create_entry( + title=self.data[CONF_BASE_URL], + data={ + **self.data, + CONF_STREAMING: stream_support, + }, + subentries=[ + { + "subentry_type": "conversation", + "data": { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + **base_options, + }, + "title": model_name_to_title(model), + "unique_id": None, + }, + ], + ) + + return self.async_show_form( + step_id="model", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional( + CONF_CHAT_MODEL, + ): SelectSelector( + SelectSelectorConfig( + options=self.models, + translation_key=CONF_CHAT_MODEL, + mode=SelectSelectorMode.DROPDOWN, + custom_value=True, + ), + ), + } + ), + { + CONF_CHAT_MODEL: (user_input or {}).get( + CONF_CHAT_MODEL, recommended_model(self.models) + ), + }, + ), + errors=errors, + ) + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return { + "conversation": ConversationSubentryFlowHandler, + } + + +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" + + last_rendered_recommended = False + options: dict[str, Any] | None = None + models: list[str] | None = None + + @property + def _openai_client(self) -> openai.AsyncOpenAI: + """Return the OpenAI client.""" + return cast(openai.AsyncOpenAI, self._get_entry().runtime_data) + + async def _get_models(self) -> list[str] | None: + """Return the list of models.""" + if self.models is None: + self.models = await async_list_models(self._openai_client) + return self.models + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a subentry.""" + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + try: + models = await self._get_models() + except HomeAssistantError: + return self.async_abort(reason="cannot_connect") + self.options = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_CHAT_MODEL: recommended_model(models), + } + self.last_rendered_recommended = cast( + bool, self.options.get(CONF_RECOMMENDED, False) + ) + return await self.async_step_init() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a subentry.""" + return await self.async_step_init() + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Manage initial options.""" + # abort if entry is not loaded + if self._get_entry().state is not ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if self.options is None: + self.options = self._get_reconfigure_subentry().data.copy() + self.last_rendered_recommended = cast( + bool, self.options.get(CONF_RECOMMENDED, False) + ) + + try: + models = await self._get_models() + except HomeAssistantError: + return self.async_abort(reason="cannot_connect") + + options = self.options + + if user_input is not None: + model = user_input[CONF_CHAT_MODEL] + try: + await async_validate_completions( + self._openai_client, + model=model, + stream=self._get_entry().data.get(CONF_STREAMING, False), + ) + except HomeAssistantError as err: + LOGGER.error("Model completion validation failed: %s", err) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + llama_cpp_config_option_schema(self.hass, options, models) + ), + user_input, + ), + errors={"base": err.translation_key or "unknown"}, + ) + + if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if self.source == "user": + return self.async_create_entry( + title=model_name_to_title(user_input[CONF_CHAT_MODEL]), + data=user_input, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + title=model_name_to_title(user_input[CONF_CHAT_MODEL]), + ) + + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API, []), + } + + schema = llama_cpp_config_option_schema(self.hass, options, models) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema), options + ), + ) + + +def llama_cpp_config_option_schema( + hass: HomeAssistant, + options: dict[str, Any], + models: list[str] | None = None, +) -> dict: + """Return a schema for llama.cpp completion options.""" + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ] + LOGGER.debug("Available LLM APIs: %s", hass_apis) + + schema: dict[vol.Required | vol.Optional, Any] = {} + + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + } + ) + schema.update( + { + vol.Optional( + CONF_CHAT_MODEL, + description={"suggested_value": options.get(CONF_CHAT_MODEL)}, + default=options.get(CONF_CHAT_MODEL, recommended_model(models)), + ): SelectSelector( + SelectSelectorConfig( + options=models or [], + translation_key=CONF_CHAT_MODEL, + mode=SelectSelectorMode.DROPDOWN, + custom_value=True, + ), + ), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) + + if options.get(CONF_RECOMMENDED): + return schema + + schema.update( + { + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options.get(CONF_MAX_TOKENS)}, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options.get(CONF_TOP_P)}, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options.get(CONF_TEMPERATURE)}, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + } + ) + return schema diff --git a/homeassistant/components/llama_cpp/const.py b/homeassistant/components/llama_cpp/const.py new file mode 100644 index 000000000000..401d2a99abc3 --- /dev/null +++ b/homeassistant/components/llama_cpp/const.py @@ -0,0 +1,30 @@ +"""Constants for the llama.cpp integration.""" + +import logging + +DOMAIN = "llama_cpp" +LOGGER = logging.getLogger(__package__) + +DEFAULT_CONVERSATION_NAME = "llama.cpp Conversation" + +CONF_CHAT_MODEL = "chat_model" +CONF_MAX_TOKENS = "max_tokens" +CONF_TEMPERATURE = "temperature" +CONF_TOP_P = "top_p" +CONF_BASE_URL = "base_url" +CONF_RECOMMENDED = "recommended" +CONF_STREAMING = "streaming" + +# Some servers set placeholder model names which we can use as a default +DEFAULT_MODEL = "gpt-3.5-turbo" +RECOMMENDED_CHAT_MODELS = [ + DEFAULT_MODEL, + "gpt-4", + "local-model", +] +RECOMMENDED_MAX_TOKENS = 3000 +RECOMMENDED_TEMPERATURE = 0.7 +RECOMMENDED_TOP_P = 1.0 + +DEFAULT_BASE_URL = "http://localhost:8080/v1" +DEFAULT_API_KEY = "sk-0000000000000000000" diff --git a/homeassistant/components/llama_cpp/conversation.py b/homeassistant/components/llama_cpp/conversation.py new file mode 100644 index 000000000000..44ff4c07d7fe --- /dev/null +++ b/homeassistant/components/llama_cpp/conversation.py @@ -0,0 +1,83 @@ +"""Conversation support for llama.cpp.""" + +from typing import Literal, override + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LlamaCppConfigEntry +from .const import DOMAIN +from .entity import LlamaCppBaseLLMEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LlamaCppConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry in config_entry.subentries.values(): + async_add_entities( + [LlamaCppConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class LlamaCppConversationEntity( + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + LlamaCppBaseLLMEntity, +): + """llama.cpp conversation agent.""" + + def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the agent.""" + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) + + @property + @override + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + @override + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + conversation.async_set_agent(self.hass, self.entry, self) + + @override + async def async_will_remove_from_hass(self) -> None: + """When entity will be removed from Home Assistant.""" + conversation.async_unset_agent(self.hass, self.entry) + await super().async_will_remove_from_hass() + + @override + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process a sentence.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + await self._async_handle_chat_log(chat_log) + + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/llama_cpp/entity.py b/homeassistant/components/llama_cpp/entity.py new file mode 100644 index 000000000000..605c9ffb9793 --- /dev/null +++ b/homeassistant/components/llama_cpp/entity.py @@ -0,0 +1,457 @@ +"""Base entity for llama.cpp Conversation.""" + +import base64 +from collections.abc import AsyncGenerator, Callable +import json +import logging +import mimetypes +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, cast + +from openai import AsyncOpenAI +from openai._streaming import AsyncStream +from openai._types import Omit +from openai.types.chat import ( + ChatCompletion, + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionContentPartParam, + ChatCompletionContentPartTextParam, + ChatCompletionFunctionToolParam, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition, ResponseFormatJSONSchema +import voluptuous as vol +from voluptuous_openapi import convert + +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.entity import Entity + +from .api import api_error_handler +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_MODEL, + DOMAIN, + LOGGER, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, +) + +if TYPE_CHECKING: + from . import LlamaCppConfigEntry + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +_LOGGER = logging.getLogger(__name__) + + +def _format_structured_output( + name: str, structure: vol.Schema, llm_api: llm.APIInstance | None +) -> ResponseFormatJSONSchema: + """Format structured output specification.""" + schema = convert( + structure, custom_serializer=llm_api.custom_serializer if llm_api else None + ) + return ResponseFormatJSONSchema( + type="json_schema", + json_schema={ + "name": name, + "strict": True, + "schema": cast(dict[str, object], schema), + }, + ) + + +def _format_tool( + tool: llm.Tool, + custom_serializer: Callable[[Any], Any] | None, +) -> ChatCompletionFunctionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition( + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + ) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionFunctionToolParam(type="function", function=tool_spec) + + +def _convert_content_to_chat_message( + content: conversation.Content, +) -> ChatCompletionMessageParam | None: + """Convert any native chat message for this agent to the native format.""" + _LOGGER.debug("_convert_content_to_chat_message=%s", content) + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + + role: Literal["user", "assistant", "system"] = content.role + if role == "system" and content.content: + return ChatCompletionSystemMessageParam(role="system", content=content.content) + + if role == "user" and content.content: + return ChatCompletionUserMessageParam(role="user", content=content.content) + + if role == "assistant": + param = ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + ) + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + param["tool_calls"] = [ + ChatCompletionMessageToolCallParam( + type="function", + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + ) + for tool_call in content.tool_calls + ] + return param + LOGGER.warning("Could not convert message to OpenAI API: %s", content) + return None + + +def _decode_tool_arguments(arguments: str) -> Any: + """Decode tool call arguments.""" + try: + return json.loads(arguments) + except json.JSONDecodeError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="json_parse_error", + translation_placeholders={"message": str(err)}, + ) from err + + +async def _transform_response( + message: ChatCompletionMessage, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the OpenAI API message to a ChatLog format.""" + data: conversation.AssistantContentDeltaDict = { + "role": message.role, + "content": message.content, + } + if message.tool_calls: + data["tool_calls"] = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=_decode_tool_arguments(tool_call.function.arguments), + ) + for tool_call in message.tool_calls + if isinstance(tool_call, ChatCompletionMessageFunctionToolCall) + ] + yield data + + +def _convert_content_to_param( + content: conversation.Content, +) -> ChatCompletionMessageParam: + """Convert any native chat message for this agent to the native format.""" + if isinstance(content, conversation.ToolResultContent): + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if not isinstance(content, conversation.AssistantContent) or not content.tool_calls: + if isinstance(content, conversation.SystemContent): + return ChatCompletionSystemMessageParam( + role="system", + content=content.content or "", + ) + return cast( + ChatCompletionMessageParam, + {"role": content.role, "content": content.content or ""}, + ) + + return ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + tool_calls=[ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + type="function", + ) + for tool_call in content.tool_calls + ], + ) + + +async def _transform_stream( + result: AsyncStream[ChatCompletionChunk], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform an OpenAI delta stream into HA format.""" + current_tool_call: dict[str, Any] | None = None + yielded_role = False + + async for chunk in result: + LOGGER.debug("Received chunk: %s", chunk) + if not chunk.choices: + continue + choice = chunk.choices[0] + + if choice.finish_reason: + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=_decode_tool_arguments( + current_tool_call["tool_args"] + ) + if current_tool_call["tool_args"] + else {}, + ) + ] + } + break + + delta = choice.delta + + if current_tool_call is None and not delta.tool_calls: + yield_dict: conversation.AssistantContentDeltaDict = {} + if not yielded_role and delta.role == "assistant": + yield_dict["role"] = "assistant" + yielded_role = True + if delta.content is not None: + yield_dict["content"] = delta.content + if yield_dict: + yield yield_dict + continue + + if ( + not delta.tool_calls + or not (delta_tool_call := delta.tool_calls[0]) + or not delta_tool_call.function + ): + continue + + if current_tool_call and delta_tool_call.index == current_tool_call["index"]: + current_tool_call["tool_args"] += delta_tool_call.function.arguments or "" + continue + + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=_decode_tool_arguments( + current_tool_call["tool_args"] + ), + ) + ] + } + + current_tool_call = { + "index": delta_tool_call.index, + "id": delta_tool_call.id, + "tool_name": delta_tool_call.function.name, + "tool_args": delta_tool_call.function.arguments or "", + } + + +class LlamaCppBaseLLMEntity(Entity): + """llama.cpp base LLM entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: LlamaCppConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="llama.cpp", + model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT_MODEL), + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + tools: list[ChatCompletionFunctionToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + model: str = options.get(CONF_CHAT_MODEL, DEFAULT_MODEL) + messages = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + response_format: ResponseFormatJSONSchema | Omit = Omit() + if structure and structure_name: + response_format = _format_structured_output( + structure_name, structure, chat_log.llm_api + ) + + last_content = chat_log.content[-1] + if ( + isinstance(last_content, conversation.UserContent) + and last_content.attachments + ): + files = await async_prepare_files_for_prompt( + self.hass, + [a.path for a in last_content.attachments], + ) + for i in range(len(messages) - 1, -1, -1): + if messages[i]["role"] == "user": + user_msg = cast(ChatCompletionUserMessageParam, messages[i]) + current_content = user_msg.get("content") + if isinstance(current_content, str): + user_msg["content"] = [ + ChatCompletionContentPartTextParam( + type="text", text=current_content + ), + *files, + ] + break + + client: AsyncOpenAI = self.entry.runtime_data + streaming = bool( + self.entry.data.get(CONF_STREAMING, options.get(CONF_STREAMING, False)) + ) + + for _iteration in range(MAX_TOOL_ITERATIONS): + with api_error_handler(): + result = await client.chat.completions.create( + messages=messages, + model=model, + tools=tools or Omit(), + response_format=response_format, + max_tokens=cast( + int, options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS) + ), + top_p=cast(float, options.get(CONF_TOP_P, RECOMMENDED_TOP_P)), + temperature=cast( + float, options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE) + ), + user=chat_log.conversation_id, + stream=cast(Any, streaming), + ) + + convert_message: Callable[[Any], Any] + async_generator: AsyncGenerator[conversation.AssistantContentDeltaDict] + if streaming: + convert_message = _convert_content_to_param + async_generator = _transform_stream( + cast(AsyncStream[ChatCompletionChunk], result) + ) + else: + convert_message = _convert_content_to_chat_message + async_generator = _transform_response( + cast(ChatCompletion, result).choices[0].message + ) + + messages.extend( + [ + msg + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, async_generator + ) + if (msg := convert_message(content)) + ] + ) + + if not chat_log.unresponded_tool_results: + break + + +async def async_prepare_files_for_prompt( + hass: HomeAssistant, files: list[Path] +) -> list[ChatCompletionContentPartParam]: + """Prepare files for OpenAI-compatible API. + + Caller needs to ensure that the files are allowed. + """ + + def guess_file_type(file_path: Path) -> tuple[str | None, str | None]: + """Guess the file type based on the file extension.""" + return mimetypes.guess_type(str(file_path)) + + def append_files_to_content() -> list[ChatCompletionContentPartParam]: + content: list[ChatCompletionContentPartParam] = [] + + for file_path in files: + if not file_path.exists(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_not_found", + translation_placeholders={"file_path": str(file_path)}, + ) + + mime_type, _ = guess_file_type(file_path) + + if not mime_type or not mime_type.startswith(("image/", "application/pdf")): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_file_type", + translation_placeholders={"file_path": str(file_path)}, + ) + + base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8") + + if mime_type.startswith("image/"): + content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:{mime_type};base64,{base64_file}", + "detail": "auto", + }, + } + ) + elif mime_type.startswith("application/pdf"): + content.append( + { + "type": "text", + "text": f"[File: {file_path.name}]\nContent: {base64_file}", + } + ) + + return content + + return await hass.async_add_executor_job(append_files_to_content) diff --git a/homeassistant/components/llama_cpp/manifest.json b/homeassistant/components/llama_cpp/manifest.json new file mode 100644 index 000000000000..a610d9027208 --- /dev/null +++ b/homeassistant/components/llama_cpp/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "llama_cpp", + "name": "llama.cpp", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/llama_cpp", + "integration_type": "service", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["openai==2.45.0"] +} diff --git a/homeassistant/components/llama_cpp/quality_scale.yaml b/homeassistant/components/llama_cpp/quality_scale.yaml new file mode 100644 index 000000000000..672cef1fdef1 --- /dev/null +++ b/homeassistant/components/llama_cpp/quality_scale.yaml @@ -0,0 +1,104 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions are registered by this integration. + appropriate-polling: + status: exempt + comment: The integration does not poll and is push-based. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions are registered by this integration. + docs-conditions: + status: exempt + comment: No custom conditions are supported by this integration. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: No custom triggers are supported by this integration. + entity-event-setup: + status: exempt + comment: No event entities or helper events are supported by this integration. + 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 actions are registered by this integration. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Conversation entities do not have an unavailable state. + integration-owner: done + log-when-unavailable: + status: exempt + comment: Conversation entities do not have an unavailable state. + parallel-updates: + status: exempt + comment: No periodic updates are performed by this integration. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: 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: + status: exempt + comment: No periodic data updates are performed by this integration. + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: The integration does not support physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No physical devices are supported. + entity-category: + status: exempt + comment: Conversation entity does not require an entity category. + entity-device-class: + status: exempt + comment: Conversation entity does not require a device class. + entity-disabled-by-default: + status: exempt + comment: Conversation entity should be enabled by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: No icons are defined for this integration. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair issues are defined for this integration. + stale-devices: + status: exempt + comment: No physical devices are supported. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/llama_cpp/strings.json b/homeassistant/components/llama_cpp/strings.json new file mode 100644 index 000000000000..d298773a914e --- /dev/null +++ b/homeassistant/components/llama_cpp/strings.json @@ -0,0 +1,98 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "api_error": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "quota_exceeded": "Your account or API key has insufficient credits.", + "timeout": "Connection timed out.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "model": { + "data": { + "chat_model": "[%key:common::generic::model%]" + }, + "data_description": { + "chat_model": "Select the model to use." + } + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "base_url": "URL" + }, + "data_description": { + "api_key": "API key for the server (optional).", + "base_url": "Base URL of your running OpenAI-compatible server (e.g. http://localhost:8080/v1)." + } + } + } + }, + "config_subentries": { + "conversation": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "entry_type": "Conversation agent", + "initiate_flow": { + "user": "Add conversation agent" + }, + "step": { + "init": { + "data": { + "chat_model": "[%key:common::generic::model%]", + "llm_hass_api": "Control Home Assistant", + "max_tokens": "Maximum tokens to return in response", + "name": "[%key:common::config_flow::data::name%]", + "prompt": "Instructions", + "recommended": "Recommended model settings", + "temperature": "Temperature", + "top_p": "Top P" + }, + "data_description": { + "chat_model": "Select the model to use.", + "llm_hass_api": "Select the level of control over Home Assistant.", + "max_tokens": "Select the maximum number of tokens to return.", + "prompt": "Instruct how the LLM should respond. This can be a template.", + "recommended": "Select whether to use recommended model settings.", + "temperature": "Select the temperature for response variability.", + "top_p": "Select the top P value for response diversity." + } + } + } + } + }, + "exceptions": { + "api_error": { + "message": "API error: {message}." + }, + "cannot_connect": { + "message": "Cannot connect to the server: {message}." + }, + "file_not_found": { + "message": "File does not exist: {file_path}." + }, + "invalid_auth": { + "message": "Invalid authentication: {message}." + }, + "json_parse_error": { + "message": "Unexpected tool argument response: {message}." + }, + "quota_exceeded": { + "message": "Your account or API key has insufficient credits: {message}." + }, + "timeout": { + "message": "Connection timed out: {message}." + }, + "unsupported_file_type": { + "message": "Only images and PDF are supported by the OpenAI API, {file_path} is not an image file or PDF." + } + } +} diff --git a/homeassistant/components/llm/__init__.py b/homeassistant/components/llm/__init__.py index 0fdd1783e0e2..7b907762f89c 100644 --- a/homeassistant/components/llm/__init__.py +++ b/homeassistant/components/llm/__init__.py @@ -1,19 +1,21 @@ -"""The LLM integration. - -Owns the LLM tools platform: integrations contribute tools to the LLM APIs -through an ``/llm.py`` platform with an ``async_get_tools`` hook. -The platforms are loaded lazily and queried per request. The framework -(``Tool``, the APIs) lives in ``homeassistant.helpers.llm``. -""" +"""The LLM integration.""" from dataclasses import dataclass import logging -from typing import Protocol +from typing import Protocol, override from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms -from homeassistant.helpers.llm import LLMContext, Tool +from homeassistant.helpers.llm import ( + API, + LLM_API_ASSIST, + APIInstance, + LLMContext, + Tool, + async_register_api, + selector_serializer, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -40,8 +42,13 @@ class LLMToolsPlatformProtocol(Protocol): """Define the format that LLM tools platforms can have.""" @callback - def async_get_tools(self, hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: - """Return the integration's LLM tools for the given context.""" + def async_get_tools( + self, hass: HomeAssistant, llm_context: LLMContext, api_id: str + ) -> LLMTools | None: + """Return the integration's LLM tools for the given context and API. + + Return None when the integration has nothing for the given API. + """ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -49,6 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_PLATFORMS] = LazyIntegrationPlatforms( hass, DOMAIN, _process_llm_tools_platform ) + async_register_api(hass, AssistAPI(hass)) return True @@ -60,7 +68,9 @@ def _process_llm_tools_platform( return platform -async def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: +async def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools: """Return the tools and merged prompt from all integration platforms.""" platforms = await hass.data[DATA_PLATFORMS].async_get_platforms() @@ -69,11 +79,38 @@ async def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTo # Sort by domain so the tool and prompt order is independent of load order. for domain, platform in sorted(platforms.items()): try: - result = platform.async_get_tools(hass, llm_context) + result = platform.async_get_tools(hass, llm_context, api_id) except Exception: _LOGGER.exception("Error getting tools from LLM platform %s", domain) continue + if result is None: + continue tools.extend(result.tools) if result.prompt: prompts.append(result.prompt) return LLMTools(tools=tools, prompt="\n".join(prompts) if prompts else None) + + +class AssistAPI(API): + """API exposing Assist API to LLMs.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the class.""" + super().__init__( + hass=hass, + id=LLM_API_ASSIST, + name="Assist", + ) + + @override + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: + """Return the instance of the API.""" + llm_tools = await async_get_tools(self.hass, llm_context, self.id) + + return APIInstance( + api=self, + api_prompt=llm_tools.prompt or "", + llm_context=llm_context, + tools=llm_tools.tools, + custom_serializer=selector_serializer, + ) diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py new file mode 100644 index 000000000000..c63f4098c245 --- /dev/null +++ b/homeassistant/components/llm/llm.py @@ -0,0 +1,45 @@ +"""LLM tools provided by the llm integration.""" + +from typing import override + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.llm import LLMContext, Tool, ToolInput +from homeassistant.util import dt as dt_util +from homeassistant.util.json import JsonObjectType + +from . import LLMTools + + +class GetDateTimeTool(Tool): + """Tool for getting the current date and time.""" + + name = "GetDateTime" + description = "Provides the current date and time." + + @override + async def async_call( + self, + hass: HomeAssistant, + tool_input: ToolInput, + llm_context: LLMContext, + ) -> JsonObjectType: + """Get the current date and time.""" + now = dt_util.now() + + return { + "success": True, + "result": { + "date": now.strftime("%Y-%m-%d"), + "time": now.strftime("%H:%M:%S"), + "timezone": now.strftime("%Z"), + "weekday": now.strftime("%A"), + }, + } + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools: + """Return the always-available LLM tools.""" + return LLMTools(tools=[GetDateTimeTool()]) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index bd5950b46beb..73ecf37efd41 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -1,4 +1,5 @@ """The lookin integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Callable, Coroutine diff --git a/homeassistant/components/luci/__init__.py b/homeassistant/components/luci/__init__.py index b0efa61ae778..f39ef47578c6 100644 --- a/homeassistant/components/luci/__init__.py +++ b/homeassistant/components/luci/__init__.py @@ -1 +1,59 @@ """The luci component.""" + +from openwrt_luci_rpc import OpenWrtRpc +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, PLATFORMS +from .coordinator import LuciConfigEntry, LuciCoordinator + + +def _connect( + host: str, username: str, password: str, ssl: bool, verify_ssl: bool +) -> OpenWrtRpc: + """Connect to the router and verify login.""" + router = OpenWrtRpc(host, username, password, ssl, verify_ssl) + if not router.is_logged_in(): + raise ConfigEntryAuthFailed("Invalid credentials for router") + return router + + +async def async_setup_entry(hass: HomeAssistant, entry: LuciConfigEntry) -> bool: + """Set up OpenWrt (luci) from a config entry.""" + try: + router = await hass.async_add_executor_job( + _connect, + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data.get(CONF_SSL, DEFAULT_SSL), + entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ) + except (ConnectionError, RequestsConnectionError) as err: + raise ConfigEntryNotReady( + f"Cannot connect to router at {entry.data[CONF_HOST]}" + ) from err + + coordinator = LuciCoordinator(hass, entry, router) + + 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: LuciConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/luci/config_flow.py b/homeassistant/components/luci/config_flow.py new file mode 100644 index 000000000000..c5c8a191ab1f --- /dev/null +++ b/homeassistant/components/luci/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow for the OpenWrt (luci) integration.""" + +from collections.abc import Mapping +from typing import Any, override + +from openwrt_luci_rpc import OpenWrtRpc +from requests.exceptions import ConnectionError as RequestsConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +class InvalidAuth(Exception): + """Raised when authentication fails.""" + + +class LuciConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenWrt (luci).""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + try: + await self.hass.async_add_executor_job(_try_connect, user_input) + except ConnectionError, RequestsConnectionError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + _try_connect, reauth_entry.data | user_input + ) + except ConnectionError, RequestsConnectionError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Handle import from legacy YAML configuration.""" + self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) + + try: + await self.hass.async_add_executor_job(_try_connect, import_data) + except ConnectionError, RequestsConnectionError: + return self.async_abort(reason="cannot_connect") + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + + return self.async_create_entry( + title=import_data[CONF_HOST], + data=import_data, + ) + + +def _try_connect(user_input: dict[str, Any]) -> None: + """Try to connect and authenticate with the router.""" + router = OpenWrtRpc( + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input.get(CONF_SSL, DEFAULT_SSL), + user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + ) + if not router.is_logged_in(): + raise InvalidAuth diff --git a/homeassistant/components/luci/const.py b/homeassistant/components/luci/const.py new file mode 100644 index 000000000000..e8d34b6b3354 --- /dev/null +++ b/homeassistant/components/luci/const.py @@ -0,0 +1,10 @@ +"""Constants for the OpenWrt (luci) integration.""" + +from homeassistant.const import Platform + +DOMAIN = "luci" + +PLATFORMS = [Platform.DEVICE_TRACKER] + +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/luci/coordinator.py b/homeassistant/components/luci/coordinator.py new file mode 100644 index 000000000000..c57f7d0f3b59 --- /dev/null +++ b/homeassistant/components/luci/coordinator.py @@ -0,0 +1,55 @@ +"""DataUpdateCoordinator for the OpenWrt (luci) integration.""" + +from datetime import timedelta +import logging +from typing import Any, override + +from openwrt_luci_rpc import OpenWrtRpc +from openwrt_luci_rpc.exceptions import LuciRpcUnknownError +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + +type LuciConfigEntry = ConfigEntry[LuciCoordinator] + + +class LuciCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for fetching connected devices from an OpenWrt router.""" + + config_entry: LuciConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: LuciConfigEntry, + router: OpenWrtRpc, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="luci", + update_interval=SCAN_INTERVAL, + ) + self.router = router + + @override + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the router.""" + try: + result = await self.hass.async_add_executor_job( + lambda: self.router.get_all_connected_devices(only_reachable=True) + ) + except (ConnectionError, RequestsConnectionError, LuciRpcUnknownError) as err: + raise UpdateFailed(f"Error communicating with router: {err}") from err + + _LOGGER.debug("Luci get_all_connected_devices returned: %s", result) + + return {device.mac: device for device in result} diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index 172e6cd5422d..e4f87070c787 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -1,16 +1,16 @@ """Support for OpenWRT (luci) routers.""" import logging -from typing import override +from typing import Any, override -from openwrt_luci_rpc import OpenWrtRpc import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, - DeviceScanner, + AsyncSeeCallback, + ScannerEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -18,14 +18,20 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN +from .coordinator import LuciConfigEntry, LuciCoordinator _LOGGER = logging.getLogger(__name__) -DEFAULT_SSL = False -DEFAULT_VERIFY_SSL = True +PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { @@ -38,73 +44,111 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> LuciDeviceScanner | None: - """Validate the configuration and return a Luci scanner.""" - scanner = LuciDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) +async def async_setup_scanner( + hass: HomeAssistant, + config: ConfigType, + async_see: AsyncSeeCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> bool: + """Import legacy YAML configuration.""" - return scanner if scanner.success_init else None + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: config[CONF_HOST], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_SSL: config.get(CONF_SSL, DEFAULT_SSL), + CONF_VERIFY_SSL: config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + }, + ) - -class LuciDeviceScanner(DeviceScanner): - """Scanner for devices connected to an OpenWrt router.""" - - def __init__(self, config): - """Initialize the scanner.""" - - self.router = OpenWrtRpc( - config[CONF_HOST], - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_SSL], - config[CONF_VERIFY_SSL], - ) - - self.last_results = {} - self.success_init = self.router.is_logged_in() - - @override - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return [device.mac for device in self.last_results] - - @override - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - return next( - (result.hostname for result in self.last_results if result.mac == device), - None, - ) - - @override - def get_extra_attributes(self, device): - """Get extra attributes of a device. - - Some known extra attributes that may be returned in the device tuple - include MAC address (mac), network device (dev), IP address - (ip), reachable status (reachable), associated router - (host), hostname if known (hostname) among others. - """ - if not ( - device := next( - (result for result in self.last_results if result.mac == device), None + if result["type"] is FlowResultType.ABORT: + reason = result["reason"] + if reason in ("invalid_auth", "cannot_connect"): + ir.async_create_issue( + hass, + DOMAIN, + f"yaml_import_{reason}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.ERROR, + translation_key=f"yaml_import_{reason}", + translation_placeholders={"host": config[CONF_HOST]}, ) - ): - return {} - return device._asdict() + return True - def _update_info(self): - """Check the Luci router for devices.""" - result = self.router.get_all_connected_devices(only_reachable=True) + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "OpenWrt (luci)", + }, + ) - _LOGGER.debug("Luci get_all_connected_devices returned: %s", result) + return True - self.last_results = [ - device - for device in result - if not hasattr(self.router.router.owrt_version, "release") - or not self.router.router.owrt_version.release - or self.router.router.owrt_version.release[0] < 19 - or device.reachable - ] + +async def async_setup_entry( + hass: HomeAssistant, + entry: LuciConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up device tracker for OpenWrt (luci) component.""" + coordinator = entry.runtime_data + + async_add_entities( + LuciScannerEntity(coordinator, mac, device) + for mac, device in coordinator.data.items() + ) + + +class LuciScannerEntity(CoordinatorEntity[LuciCoordinator], ScannerEntity): + """Representation of a device connected to an OpenWrt router.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LuciCoordinator, + mac: str, + device: Any, + ) -> None: + """Initialize the scanner entity.""" + super().__init__(coordinator) + self._mac = mac + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{mac}" + self._attr_mac_address = mac + self._attr_hostname = device.hostname + self._attr_ip_address = device.ip + self._attr_name = device.hostname or mac + + @property + @override + def unique_id(self) -> str | None: + """Return the unique ID of the entity.""" + return self._attr_unique_id + + @property + @override + def is_connected(self) -> bool: + """Return true if the device is connected to the router.""" + return self._mac in self.coordinator.data + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._mac in self.coordinator.data: + device = self.coordinator.data[self._mac] + self._attr_hostname = device.hostname + self._attr_ip_address = device.ip + self._attr_name = device.hostname or self._mac + super()._handle_coordinator_update() diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index a8df2c63df4b..111bb6a6bd76 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -2,9 +2,11 @@ "domain": "luci", "name": "OpenWrt (luci)", "codeowners": ["@mzdrale"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/luci", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["openwrt_luci_rpc"], - "quality_scale": "legacy", + "quality_scale": "silver", "requirements": ["openwrt-luci-rpc==1.1.17"] } diff --git a/homeassistant/components/luci/quality_scale.yaml b/homeassistant/components/luci/quality_scale.yaml new file mode 100644 index 000000000000..37debf5684b9 --- /dev/null +++ b/homeassistant/components/luci/quality_scale.yaml @@ -0,0 +1,100 @@ +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-conditions: + status: exempt + comment: This integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: Entities do not subscribe to events; they are updated via the coordinator. + 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: + status: exempt + comment: This 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: done + + # Gold + devices: + status: todo + comment: Tracked clients are represented as scanner entities without a device registry entry. + diagnostics: todo + discovery: + status: todo + comment: The router could be discovered via DHCP. + discovery-update-info: 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: + status: todo + comment: Clients connecting after setup are not added until Home Assistant restarts. + entity-category: + status: exempt + comment: This integration only creates device tracker entities. + entity-device-class: + status: exempt + comment: This integration only creates device tracker entities. + entity-disabled-by-default: + status: exempt + comment: This integration only creates device tracker entities. + entity-translations: + status: exempt + comment: Device tracker entities are named after the tracked client's hostname. + exception-translations: todo + icon-translations: + status: exempt + comment: Device tracker entities use the icon of their source type. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: This integration has no repairs beyond the deprecated YAML import issue. + stale-devices: + status: todo + comment: Clients that stop connecting are not removed automatically. + + # Platinum + async-dependency: + status: todo + comment: The openwrt-luci-rpc library is synchronous and is run in the executor. + inject-websession: + status: exempt + comment: The openwrt-luci-rpc library uses requests and does not accept an injected websession. + strict-typing: todo diff --git a/homeassistant/components/luci/strings.json b/homeassistant/components/luci/strings.json new file mode 100644 index 000000000000..fe28ec962dcc --- /dev/null +++ b/homeassistant/components/luci/strings.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password to log in to the OpenWrt router.", + "username": "The username to log in to the OpenWrt router." + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your OpenWrt router.", + "password": "The password to log in to the OpenWrt router.", + "ssl": "Whether to connect to the router using SSL.", + "username": "The username to log in to the OpenWrt router.", + "verify_ssl": "Whether to verify the router's SSL certificate." + } + } + } + }, + "issues": { + "yaml_import_cannot_connect": { + "description": "The YAML configuration for OpenWrt (luci) at `{host}` could not be imported because the connection failed.\n\nPlease check that `{host}` is reachable, update your `configuration.yaml` if needed, and restart Home Assistant.", + "title": "YAML configuration import failed: cannot connect" + }, + "yaml_import_invalid_auth": { + "description": "The YAML configuration for OpenWrt (luci) at `{host}` could not be imported because the credentials are invalid.\n\nPlease update your `configuration.yaml` with valid credentials and restart Home Assistant.", + "title": "YAML configuration import failed: invalid authentication" + } + } +} diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 04752d5493d0..6317b7e0e934 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -9,9 +9,8 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_SHOW_ON_MAP, + EntityStateAttribute, UnitOfDensity, UnitOfPressure, UnitOfRatio, @@ -120,12 +119,12 @@ class SensorCommunitySensor(CoordinatorEntity, SensorEntity): ) if show_on_map: - self._attr_extra_state_attributes[ATTR_LONGITUDE] = coordinator.data[ - "longitude" - ] - self._attr_extra_state_attributes[ATTR_LATITUDE] = coordinator.data[ - "latitude" - ] + self._attr_extra_state_attributes[EntityStateAttribute.LONGITUDE] = ( + coordinator.data["longitude"] + ) + self._attr_extra_state_attributes[EntityStateAttribute.LATITUDE] = ( + coordinator.data["latitude"] + ) @property @override diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py index 52b48c8ca5fb..f5a591e5f296 100644 --- a/homeassistant/components/lunatone/coordinator.py +++ b/homeassistant/components/lunatone/coordinator.py @@ -139,6 +139,7 @@ class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Senso async def _async_update_data(self) -> dict[int, Sensor]: """Update sensor data.""" try: + await self.sensors_api.async_refresh() await self.sensors_api.async_update() except aiohttp.ClientConnectionError as ex: raise UpdateFailed( diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index b08676082cba..ae234713e72a 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.4.1"], + "requirements": ["pylutron==0.4.2"], "single_config_entry": true } diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index d53187425161..f6bba1ab2a34 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -10,7 +10,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.28.0"], + "requirements": ["pylutron-caseta==0.29.0"], "zeroconf": [ { "properties": { diff --git a/homeassistant/components/lyngdorf/__init__.py b/homeassistant/components/lyngdorf/__init__.py new file mode 100644 index 000000000000..56d8b3db398e --- /dev/null +++ b/homeassistant/components/lyngdorf/__init__.py @@ -0,0 +1,130 @@ +"""The Lyngdorf integration.""" + +import logging + +from lyngdorf.device import async_create_receiver, lookup_receiver_model + +from homeassistant.const import CONF_HOST, CONF_MODEL, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) + +from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS +from .models import LyngdorfConfigEntry, LyngdorfRuntimeData + +_LOGGER = logging.getLogger(__name__) + + +def _serial_as_mac(serial: str) -> str | None: + """Return a normalized MAC if the serial is one, otherwise None. + + Lyngdorf reports the device MAC in the UPnP serialNumber field, but this is + not formally guaranteed — fall back gracefully if the value is not a MAC. + """ + cleaned = serial.replace(":", "").replace("-", "").replace(".", "") + if len(cleaned) != 12 or not all(c in "0123456789abcdefABCDEF" for c in cleaned): + return None + return format_mac(cleaned) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: LyngdorfConfigEntry +) -> bool: + """Set up Lyngdorf from a config entry.""" + lyngdorf_model = lookup_receiver_model(config_entry.data[CONF_MODEL]) + assert lyngdorf_model is not None + + try: + receiver = await async_create_receiver( + config_entry.data[CONF_HOST], lyngdorf_model + ) + await receiver.async_connect() + except TimeoutError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_timeout", + translation_placeholders={"host": config_entry.data[CONF_HOST]}, + ) from err + except (ConnectionError, OSError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_connection_error", + translation_placeholders={"host": config_entry.data[CONF_HOST]}, + ) from err + + assert config_entry.unique_id + serial = config_entry.data[CONF_SERIAL_NUMBER] + mac = _serial_as_mac(serial) + connections = {(CONNECTION_NETWORK_MAC, mac)} if mac else set() + + device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.unique_id)}, + connections=connections, + manufacturer=lyngdorf_model.manufacturer, + serial_number=serial, + model=lyngdorf_model.model_name, + ) + + zone_b_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{config_entry.unique_id}_zone_b")}, + manufacturer=lyngdorf_model.manufacturer, + serial_number=serial, + model=lyngdorf_model.model_name, + translation_key="zone_b", + translation_placeholders={"device_name": config_entry.title}, + via_device=(DOMAIN, config_entry.unique_id), + ) + + config_entry.runtime_data = LyngdorfRuntimeData( + receiver=receiver, + device_info=device_info, + zone_b_device_info=zone_b_device_info, + ) + + host = config_entry.data[CONF_HOST] + last_connected = receiver.connected + + @callback + def _log_availability_change() -> None: + nonlocal last_connected + connected = receiver.connected + if connected == last_connected: + return + last_connected = connected + if connected: + _LOGGER.info("Lyngdorf %s is back online", host) + else: + _LOGGER.info("Lyngdorf %s is unavailable", host) + + receiver.register_notification_callback(_log_availability_change) + config_entry.async_on_unload( + lambda: receiver.un_register_notification_callback(_log_availability_change) + ) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + async def _async_disconnect(event: Event) -> None: + """Disconnect from receiver.""" + await receiver.async_disconnect() + + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: LyngdorfConfigEntry +) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + if unload_ok: + await config_entry.runtime_data.receiver.async_disconnect() + return unload_ok diff --git a/homeassistant/components/lyngdorf/config_flow.py b/homeassistant/components/lyngdorf/config_flow.py new file mode 100644 index 000000000000..2ebd56213690 --- /dev/null +++ b/homeassistant/components/lyngdorf/config_flow.py @@ -0,0 +1,175 @@ +"""Config flow for Lyngdorf integration.""" + +from typing import Any, override +from urllib.parse import urlparse + +from lyngdorf.device import ( + async_find_receiver_model, + async_get_device_serial, + lookup_receiver_model, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) + +from .const import CONF_SERIAL_NUMBER, DEFAULT_DEVICE_NAME, DOMAIN + + +class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Lyngdorf config flow.""" + + def __init__(self) -> None: + """Initialize flow.""" + self._location: str | None = None + self._device_model: str | None = None + self._device_serial_number: str | None = None + self._name: str | None = None + self._host: str | None = None + + @property + def _display_name(self) -> str: + """Return the name shown to the user during discovery confirmation.""" + if self._device_model and self._device_model != self._name: + return f"{self._device_model} ({self._name})" + return self._name or DEFAULT_DEVICE_NAME + + @override + 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._host = user_input[CONF_HOST] + + try: + model = await async_find_receiver_model(self._host) + except TimeoutError: + errors["base"] = "timeout_connect" + except OSError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + + if not errors and not model: + errors["base"] = "unsupported_model" + + if not errors and model: + self._device_model = model.model_name + self._name = model.model_name + + serial = await async_get_device_serial(self._host) + if not serial: + errors["base"] = "cannot_determine_id" + else: + self._device_serial_number = serial.lower() + await self.async_set_unique_id(self._device_serial_number) + self._abort_if_unique_id_configured() + return await self._create_entry() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + } + ), + errors=errors, + ) + + @override + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by SSDP discovery.""" + await self._async_set_info_from_discovery(discovery_info) + + assert self._host + try: + model = await async_find_receiver_model(self._host) + except TimeoutError, OSError: + return self.async_abort(reason="cannot_connect") + if not model: + return self.async_abort(reason="unsupported_model") + + self.context["title_placeholders"] = {"name": self._display_name} + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + return await self._create_entry() + + self._set_confirm_only() + return self.async_show_form( + step_id="confirm", + description_placeholders={"name": self._display_name}, + ) + + async def _create_entry(self) -> ConfigFlowResult: + """Create a config entry.""" + assert self._host + assert self._device_model + if self._location: + title = ( + self._name or urlparse(self._location).hostname or DEFAULT_DEVICE_NAME + ) + else: + title = self._name or DEFAULT_DEVICE_NAME + + data: dict[str, Any] = { + CONF_MODEL: self._device_model, + CONF_HOST: self._host, + } + if self._device_serial_number: + data[CONF_SERIAL_NUMBER] = self._device_serial_number + + return self.async_create_entry(title=title, data=data) + + async def _async_set_info_from_discovery( + self, discovery_info: SsdpServiceInfo + ) -> None: + """Set information required for a config entry from SSDP discovery.""" + if not self._location: + self._location = discovery_info.ssdp_location + if not isinstance(self._location, str): + raise AbortFlow("cannot_connect") + + if hostname := ( + discovery_info.ssdp_headers.get("_host") + or urlparse(self._location).hostname + ): + self._host = str(hostname) + else: + raise AbortFlow("cannot_connect") + + device_model_name = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or "" + if not (model := lookup_receiver_model(device_model_name)): + raise AbortFlow("unsupported_model") + self._device_model = model.model_name + self._device_serial_number = ( + discovery_info.upnp.get(ATTR_UPNP_SERIAL) or "" + ).lower() or None + self._name = ( + discovery_info.upnp.get(ATTR_UPNP_FRIENDLY_NAME) + or urlparse(self._location).hostname + or DEFAULT_DEVICE_NAME + ) + + if not self._device_serial_number: + raise AbortFlow("cannot_determine_id") + await self.async_set_unique_id(self._device_serial_number) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) diff --git a/homeassistant/components/lyngdorf/const.py b/homeassistant/components/lyngdorf/const.py new file mode 100644 index 000000000000..d34961be3d5f --- /dev/null +++ b/homeassistant/components/lyngdorf/const.py @@ -0,0 +1,11 @@ +"""Constants for the Lyngdorf integration.""" + +from homeassistant.const import Platform + +DOMAIN = "lyngdorf" +DEFAULT_DEVICE_NAME = "Lyngdorf" + +PLATFORMS: list[Platform] = [ + Platform.MEDIA_PLAYER, +] +CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/lyngdorf/entity.py b/homeassistant/components/lyngdorf/entity.py new file mode 100644 index 000000000000..4c95d27e86d1 --- /dev/null +++ b/homeassistant/components/lyngdorf/entity.py @@ -0,0 +1,46 @@ +"""Base entity for Lyngdorf integration.""" + +from typing import override + +from lyngdorf.device import Receiver + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + + +class LyngdorfEntity(Entity): + """Base Lyngdorf entity.""" + + _attr_has_entity_name = True + _attr_available = True + _attr_should_poll = False + + def __init__(self, receiver: Receiver, device_info: DeviceInfo) -> None: + """Initialize the entity.""" + self._receiver = receiver + self._attr_device_info = device_info + + @override + async def async_added_to_hass(self) -> None: + """Register notification callback when added to hass.""" + await super().async_added_to_hass() + self._receiver.register_notification_callback(self._handle_receiver_update) + self._update_availability() + + @override + async def async_will_remove_from_hass(self) -> None: + """Unregister notification callback when removed from hass.""" + await super().async_will_remove_from_hass() + self._receiver.un_register_notification_callback(self._handle_receiver_update) + + @callback + def _handle_receiver_update(self) -> None: + """Handle receiver updates.""" + self._update_availability() + self.async_write_ha_state() + + @callback + def _update_availability(self) -> None: + """Update availability from receiver connection status.""" + self._attr_available = self._receiver.connected diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json new file mode 100644 index 000000000000..c4144acf1011 --- /dev/null +++ b/homeassistant/components/lyngdorf/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "lyngdorf", + "name": "Lyngdorf", + "codeowners": ["@fishloa"], + "config_flow": true, + "dependencies": ["ssdp"], + "documentation": "https://www.home-assistant.io/integrations/lyngdorf", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["lyngdorf", "async_upnp_client"], + "quality_scale": "silver", + "requirements": ["lyngdorf==1.3.3"], + "ssdp": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Lyngdorf" + } + ] +} diff --git a/homeassistant/components/lyngdorf/media_player.py b/homeassistant/components/lyngdorf/media_player.py new file mode 100644 index 000000000000..dd764c9d0fec --- /dev/null +++ b/homeassistant/components/lyngdorf/media_player.py @@ -0,0 +1,288 @@ +"""Media player platform for Lyngdorf integration.""" + +from typing import override + +from lyngdorf.device import Receiver + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import LyngdorfEntity +from .models import LyngdorfConfigEntry + +PARALLEL_UPDATES = 1 + +MAX_VOLUME_DB = 18.0 +MIN_VOLUME_DB = -80.0 +VOLUME_RANGE = MAX_VOLUME_DB - MIN_VOLUME_DB + +FEATURES_ZONE_B = ( + MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE +) + +FEATURES_MAIN = ( + MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOUND_MODE + | MediaPlayerEntityFeature.SELECT_SOURCE +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LyngdorfConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the receiver from a config entry.""" + runtime_data = config_entry.runtime_data + + async_add_entities( + [ + LyngdorfMainDevice( + runtime_data.receiver, config_entry, runtime_data.device_info + ), + LyngdorfZoneBDevice( + runtime_data.receiver, config_entry, runtime_data.zone_b_device_info + ), + ] + ) + + +def _to_ha_volume(volume_db: float) -> float: + """Convert Lyngdorf dB volume to HA 0..1 scale, clamped to 0..1.""" + volume = (volume_db - MIN_VOLUME_DB) / VOLUME_RANGE + return max(0.0, min(volume, 1.0)) + + +def _to_lyngdorf_volume(volume: float) -> float: + """Convert HA 0..1 volume to Lyngdorf dB scale, clamped to min and max.""" + volume_db = volume * VOLUME_RANGE + MIN_VOLUME_DB + return max(MIN_VOLUME_DB, min(volume_db, MAX_VOLUME_DB)) + + +class LyngdorfDevice(LyngdorfEntity, MediaPlayerEntity): + """Base Lyngdorf media player entity.""" + + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + translation_key: str | None, + entity_id_suffix: str, + features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0), + ) -> None: + """Initialize the device.""" + super().__init__(receiver, device_info) + assert config_entry.unique_id + self._attr_unique_id = f"{config_entry.unique_id}_{entity_id_suffix}" + self._attr_translation_key = translation_key + self._attr_supported_features = features + + +class LyngdorfZoneBDevice(LyngdorfDevice): + """Lyngdorf Zone B device.""" + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + ) -> None: + """Create the device.""" + super().__init__( + receiver, + config_entry, + device_info, + None, + "zone_b", + FEATURES_ZONE_B, + ) + + @override + @property + def state(self) -> MediaPlayerState | None: + """Return the state of the device.""" + if self._receiver.zone_b_power_on: + return MediaPlayerState.ON + return MediaPlayerState.OFF + + @override + @property + def is_volume_muted(self) -> bool | None: + """Return boolean if volume is currently muted.""" + return self._receiver.zone_b_mute_enabled + + @override + @property + def volume_level(self) -> float | None: + """Volume level of the media player (0..1).""" + if not isinstance(self._receiver.zone_b_volume, float): + return None + return _to_ha_volume(self._receiver.zone_b_volume) + + @override + def turn_on(self) -> None: + """Turn on media player.""" + self._receiver.zone_b_power_on = True + + @override + def turn_off(self) -> None: + """Turn off media player.""" + self._receiver.zone_b_power_on = False + + def volume_up(self) -> None: + """Volume up the media player.""" + self._receiver.zone_b_volume_up() + + def volume_down(self) -> None: + """Volume down the media player.""" + self._receiver.zone_b_volume_down() + + @override + def set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self._receiver.zone_b_volume = _to_lyngdorf_volume(volume) + + @override + def mute_volume(self, mute: bool) -> None: + """Send mute command.""" + self._receiver.zone_b_mute_enabled = mute + + @override + @property + def source(self) -> str | None: + """Return the current input source.""" + return self._receiver.zone_b_source + + @override + @property + def source_list(self) -> list[str] | None: + """Return the list of available sources.""" + return self._receiver.zone_b_available_sources + + @override + def select_source(self, source: str) -> None: + """Select input source.""" + self._receiver.zone_b_source = source + + +class LyngdorfMainDevice(LyngdorfDevice): + """Lyngdorf main zone device.""" + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + ) -> None: + """Create the device.""" + super().__init__( + receiver, + config_entry, + device_info, + "main_zone", + "main_zone", + FEATURES_MAIN, + ) + + @override + @property + def state(self) -> MediaPlayerState | None: + """Return the state of the device.""" + if self._receiver.power_on: + return MediaPlayerState.ON + return MediaPlayerState.OFF + + @override + @property + def source_list(self) -> list[str] | None: + """Return a list of available input sources.""" + return self._receiver.available_sources + + @override + @property + def sound_mode_list(self) -> list[str] | None: + """Return a list of available sound modes.""" + return self._receiver.available_sound_modes + + @override + @property + def is_volume_muted(self) -> bool | None: + """Return boolean if volume is currently muted.""" + return self._receiver.mute_enabled + + @override + @property + def volume_level(self) -> float | None: + """Volume level of the media player (0..1).""" + if not isinstance(self._receiver.volume, float): + return None + return _to_ha_volume(self._receiver.volume) + + @override + @property + def source(self) -> str | None: + """Return the current input source.""" + return self._receiver.source + + @override + @property + def sound_mode(self) -> str | None: + """Return the current sound mode.""" + return self._receiver.sound_mode + + @override + def turn_on(self) -> None: + """Turn on media player.""" + self._receiver.power_on = True + + @override + def turn_off(self) -> None: + """Turn off media player.""" + self._receiver.power_on = False + + def volume_up(self) -> None: + """Volume up the media player.""" + self._receiver.volume_up() + + def volume_down(self) -> None: + """Volume down the media player.""" + self._receiver.volume_down() + + @override + def set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self._receiver.volume = _to_lyngdorf_volume(volume) + + @override + def mute_volume(self, mute: bool) -> None: + """Send mute command.""" + self._receiver.mute_enabled = mute + + @override + def select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + self._receiver.sound_mode = sound_mode + + @override + def select_source(self, source: str) -> None: + """Select input source.""" + self._receiver.source = source diff --git a/homeassistant/components/lyngdorf/models.py b/homeassistant/components/lyngdorf/models.py new file mode 100644 index 000000000000..26e2f84b027a --- /dev/null +++ b/homeassistant/components/lyngdorf/models.py @@ -0,0 +1,20 @@ +"""Models for Lyngdorf integration.""" + +from dataclasses import dataclass + +from lyngdorf.device import Receiver + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo + + +@dataclass +class LyngdorfRuntimeData: + """Runtime data for Lyngdorf integration.""" + + receiver: Receiver + device_info: DeviceInfo + zone_b_device_info: DeviceInfo + + +type LyngdorfConfigEntry = ConfigEntry[LyngdorfRuntimeData] diff --git a/homeassistant/components/lyngdorf/quality_scale.yaml b/homeassistant/components/lyngdorf/quality_scale.yaml new file mode 100644 index 000000000000..a9a72c80f4d9 --- /dev/null +++ b/homeassistant/components/lyngdorf/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: Integration is push-based; entities update via receiver callbacks. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + 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: Integration does not register custom 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: Integration does not use authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: done + discovery-update-info: 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: + status: exempt + comment: Single device per config entry. + entity-category: + status: exempt + comment: Media player entities do not need entity categories. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: All entities are useful by default. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Media player uses default platform icons. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair issues needed. + stale-devices: + status: exempt + comment: Single device per config entry. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: Integration uses local TCP, not HTTP. + strict-typing: todo diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json new file mode 100644 index 000000000000..c47d256d32b8 --- /dev/null +++ b/homeassistant/components/lyngdorf/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_determine_id": "[%key:component::lyngdorf::config::error::cannot_determine_id%]", + "unsupported_model": "This Lyngdorf model is not supported" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_determine_id": "Could not determine device identity. Ensure the device is powered on and reachable.", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unsupported_model": "This Lyngdorf model is not supported" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Do you want to set up **{name}**?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of the Lyngdorf device" + }, + "title": "Lyngdorf device connection" + } + } + }, + "device": { + "zone_b": { + "name": "{device_name} Zone B" + } + }, + "entity": { + "media_player": { + "main_zone": { + "name": "Main zone" + } + } + }, + "exceptions": { + "setup_connection_error": { + "message": "Failed to connect to {host}" + }, + "setup_timeout": { + "message": "Timeout connecting to {host}" + } + } +} diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 1ceb8721fae3..b46b8be84a34 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -14,6 +14,8 @@ from mastodon.Mastodon import ( MastodonNotFoundError, MastodonUnauthorizedError, MediaAttachment, + ScheduledStatus, + Status, ) import voluptuous as vol @@ -177,7 +179,11 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_UNMUTE_ACCOUNT_SCHEMA, ) hass.services.async_register( - DOMAIN, SERVICE_POST, _async_post, schema=SERVICE_POST_SCHEMA + DOMAIN, + SERVICE_POST, + _async_post, + schema=SERVICE_POST_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, @@ -326,7 +332,7 @@ async def _async_post(call: ServiceCall) -> ServiceResponse: translation_key="idempotency_key_too_short", ) - await call.hass.async_add_executor_job( + response = await call.hass.async_add_executor_job( partial( _post, hass=call.hass, @@ -344,11 +350,14 @@ async def _async_post(call: ServiceCall) -> ServiceResponse: quoted_status_id=quoted_status, ) ) - + if call.return_response: + return response return None -def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None: +def _post( + hass: HomeAssistant, client: Mastodon, **kwargs: Any +) -> Status | ScheduledStatus: """Post to Mastodon.""" media_data: MediaAttachment | None = None @@ -385,12 +394,15 @@ def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None: if media_data: media_ids = media_data.id try: - client.status_post(media_ids=media_ids, **kwargs) + response: Status | ScheduledStatus = client.status_post( + media_ids=media_ids, **kwargs + ) except MastodonAPIError as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="unable_to_send_message", ) from err + return response async def _async_update_profile(call: ServiceCall) -> ServiceResponse | None: diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 8755819e9505..591d58e9c5c3 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==12.2.0", "aiofiles==24.1.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==12.3.0", "aiofiles==24.1.0"] } diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index de54159ad3b4..304b65315bfd 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -394,9 +394,7 @@ def _remove_via_devices( devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) for device in devices: if device.via_device_id == device_entry.id: - device_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async def async_remove_config_entry_device( diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index ad4d9aa4d6ce..66286b40be78 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -148,7 +148,10 @@ DISCOVERY_SCHEMAS = [ clusters.Switch.Attributes.CurrentPosition, clusters.Switch.Attributes.FeatureMap, ), - device_type=(device_types.GenericSwitch,), + device_type=( + device_types.Doorbell, + device_types.GenericSwitch, + ), optional_attributes=( clusters.Switch.Attributes.NumberOfPositions, clusters.FixedLabel.Attributes.LabelList, diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 776d895186a8..26c12da16775 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -9,11 +9,11 @@ from matter_server.common.errors import UpdateCheckError, UpdateError from matter_server.common.models import MatterSoftwareVersion, UpdateSource from homeassistant.components.update import ( - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -204,7 +204,9 @@ class MatterUpdate(MatterEntity, UpdateEntity): await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._attr_latest_version = state.attributes.get(ATTR_LATEST_VERSION) + self._attr_latest_version = state.attributes.get( + UpdateEntityStateAttribute.LATEST_VERSION + ) if (extra_data := await self.async_get_last_extra_data()) and ( matter_extra_data := MatterUpdateExtraStoredData.from_dict( diff --git a/homeassistant/components/mcp/auth.py b/homeassistant/components/mcp/auth.py new file mode 100644 index 000000000000..6b0ee76e1990 --- /dev/null +++ b/homeassistant/components/mcp/auth.py @@ -0,0 +1,36 @@ +"""Authentication helper classes for the Model Context Protocol integration.""" + +from dataclasses import dataclass +import re + +import httpx +from yarl import URL + +# Headers and regex for WWW-Authenticate parsing for rfc9728 +WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" +RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"' +SCOPES_REGEXP = r'scope="([^"]+)"' + + +@dataclass +class AuthenticateHeader: + """Class to hold info from the WWW-Authenticate header for supporting rfc9728.""" + + resource_metadata_url: str + scopes: list[str] | None = None + + @classmethod + def from_header( + cls, url: str, error_response: httpx.Response + ) -> AuthenticateHeader | None: + """Create AuthenticateHeader from WWW-Authenticate header.""" + if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not ( + match := re.search(RESOURCE_METADATA_REGEXP, header) + ): + return None + resource_metadata_url = str(URL(url).join(URL(match.group(1)))) + scope_match = re.search(SCOPES_REGEXP, header) + return cls( + resource_metadata_url=resource_metadata_url, + scopes=scope_match.group(1).split(" ") if scope_match else None, + ) diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 1e77143359dc..982cc0a4884f 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Iterable, Mapping from dataclasses import dataclass import logging -import re from typing import Any, cast, override import httpx @@ -24,6 +23,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from . import async_get_config_entry_implementation from .application_credentials import authorization_server_context +from .auth import AuthenticateHeader from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN from .coordinator import TokenManager, mcp_client @@ -35,35 +35,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) -# Headers and regex for WWW-Authenticate parsing for rfc9728 -WWW_AUTHENTICATE_HEADER = "WWW-Authenticate" -RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"' OAUTH_PROTECTED_RESOURCE_ENDPOINT = "/.well-known/oauth-protected-resource" -SCOPES_REGEXP = r'scope="([^"]+)"' - - -@dataclass -class AuthenticateHeader: - """Class to hold info from the WWW-Authenticate header for supporting rfc9728.""" - - resource_metadata_url: str - scopes: list[str] | None = None - - @classmethod - def from_header( - cls, url: str, error_response: httpx.Response - ) -> AuthenticateHeader | None: - """Create AuthenticateHeader from WWW-Authenticate header.""" - if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not ( - match := re.search(RESOURCE_METADATA_REGEXP, header) - ): - return None - resource_metadata_url = str(URL(url).join(URL(match.group(1)))) - scope_match = re.search(SCOPES_REGEXP, header) - return cls( - resource_metadata_url=resource_metadata_url, - scopes=scope_match.group(1).split(" ") if scope_match else None, - ) @dataclass @@ -369,6 +341,8 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" + if entry_data and "auth_header" in entry_data: + self.auth_header = entry_data["auth_header"] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -379,6 +353,13 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm") config_entry = self._get_reauth_entry() self.data = {**config_entry.data} + if "auth_implementation" not in self.data: + # For entries configured without authentication (no-auth), any authentication + # failure (from a tool call or coordinator update) requires upgrading to OAuth. + # We bypass validate_input connection handshake (which might succeed if the server + # doesn't restrict the connection handshake itself) and proceed directly to OAuth discovery. + return await self.async_step_auth_discovery() + self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment] self.hass, config_entry ) diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index e9bffdc0c8f5..4257449c94da 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -18,12 +18,17 @@ from voluptuous_openapi import convert_to_voluptuous from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.json import JsonObjectType +from .auth import AuthenticateHeader from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -98,6 +103,7 @@ class ModelContextProtocolTool(llm.Tool): description: str | None, parameters: vol.Schema, server_url: str, + config_entry: ConfigEntry, token_manager: TokenManager | None = None, ) -> None: """Initialize the tool.""" @@ -105,6 +111,7 @@ class ModelContextProtocolTool(llm.Tool): self.description = description self.parameters = parameters self.server_url = server_url + self.config_entry = config_entry self.token_manager = token_manager @override @@ -126,9 +133,32 @@ class ModelContextProtocolTool(llm.Tool): except TimeoutError as error: _LOGGER.debug("Timeout when calling tool: %s", error) raise HomeAssistantError(f"Timeout when calling tool: {error}") from error + except OAuth2TokenRequestReauthError as error: + _LOGGER.debug("OAuth token request failed when calling tool: %s", error) + self.config_entry.async_start_reauth(hass) + raise ConfigEntryAuthFailed( + "OAuth token request failed when calling tool" + ) from error except httpx.HTTPStatusError as error: _LOGGER.debug("Error when calling tool: %s", error) + if error.response.status_code == 401: + auth_header = AuthenticateHeader.from_header( + self.server_url, error.response + ) + self.config_entry.async_start_reauth( + hass, data={"auth_header": auth_header} + ) + raise ConfigEntryAuthFailed( + "The MCP server requires authentication" + ) from error raise HomeAssistantError(f"Error when calling tool: {error}") from error + except httpx.HTTPError as error: + _LOGGER.debug( + "Error communicating with MCP server when calling tool: %s", error + ) + raise HomeAssistantError( + f"Error communicating with MCP server when calling tool: {error}" + ) from error return result.model_dump(exclude_unset=True, exclude_none=True) @@ -169,9 +199,18 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): except TimeoutError as error: _LOGGER.debug("Timeout when listing tools: %s", error) raise UpdateFailed(f"Timeout when listing tools: {error}") from error + except OAuth2TokenRequestReauthError as error: + _LOGGER.debug("OAuth token request failed: %s", error) + raise ConfigEntryAuthFailed("OAuth token request failed") from error except httpx.HTTPStatusError as error: _LOGGER.debug("Error communicating with API: %s", error) - if error.response.status_code == 401 and self.token_manager is not None: + if error.response.status_code == 401: + auth_header = AuthenticateHeader.from_header( + self.config_entry.data[CONF_URL], error.response + ) + self.config_entry.async_start_reauth( + self.hass, data={"auth_header": auth_header} + ) raise ConfigEntryAuthFailed( "The MCP server requires authentication" ) from error @@ -195,6 +234,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.description, parameters, self.config_entry.data[CONF_URL], + self.config_entry, self.token_manager, ) ) diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 3af6fb4806a3..6f2857f0d9ca 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -3,12 +3,16 @@ 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: +The Streamable HTTP protocol uses these HTTP endpoints: -- /api/mcp_server: The Streamable HTTP endpoint currently implements the +- /api/mcp: 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 client. This serves the configured LLM APIs and does not require + admin access. +- /api/mcp/: The same Streamable HTTP endpoint, but exposing a + specific LLM API selected by its ID. These endpoints require admin access, + except for the Assist API. The older SSE protocol has two HTTP endpoints: @@ -43,6 +47,7 @@ from homeassistant.components import conversation from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import Unauthorized from homeassistant.helpers import llm from .const import DOMAIN @@ -67,6 +72,7 @@ def async_register(hass: HomeAssistant) -> None: hass.http.register_view(ModelContextProtocolSSEView()) hass.http.register_view(ModelContextProtocolMessagesView()) hass.http.register_view(ModelContextProtocolStreamableView()) + hass.http.register_view(ModelContextProtocolStreamableApiView()) def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: @@ -112,6 +118,11 @@ class Streams: @asynccontextmanager async def create_streams() -> AsyncGenerator[Streams]: """Create a new pair of streams for MCP server communication.""" + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] + read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] + write_stream: MemoryObjectSendStream[SessionMessage] + write_stream_reader: MemoryObjectReceiveStream[SessionMessage] + read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) streams = Streams( @@ -127,7 +138,7 @@ async def create_streams() -> AsyncGenerator[Streams]: async def create_mcp_server( - hass: HomeAssistant, context: Context, entry: MCPServerConfigEntry + hass: HomeAssistant, context: Context, llm_api_id: str | list[str] ) -> tuple[Server, InitializationOptions]: """Initialize the MCP server to ensure it's ready to handle requests.""" llm_context = llm.LLMContext( @@ -137,7 +148,6 @@ async def create_mcp_server( 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 @@ -165,7 +175,9 @@ class ModelContextProtocolSSEView(HomeAssistantView): entry = async_get_config_entry(hass) session_manager = entry.runtime_data - server, options = await create_mcp_server(hass, self.context(request), entry) + server, options = await create_mcp_server( + hass, self.context(request), entry.data[CONF_LLM_HASS_API] + ) async with ( create_streams() as streams, @@ -231,65 +243,92 @@ class ModelContextProtocolMessagesView(HomeAssistantView): return web.Response(status=200) +async def _async_handle_streamable_message( + request: web.Request, context: Context, llm_api_id: str | list[str] +) -> web.StreamResponse: + """Process a single JSON-RPC message for the given LLM API.""" + hass = request.app[KEY_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, context, llm_api_id) + + async with create_streams() as 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), + ) + + class ModelContextProtocolStreamableView(HomeAssistantView): - """Model Context Protocol Streamable HTTP endpoint.""" + """Model Context Protocol Streamable HTTP endpoint. + + This serves the configured LLM APIs and does not require admin access. + """ 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.""" + """Process JSON-RPC messages for the configured LLM APIs.""" hass = request.app[KEY_HASS] entry = async_get_config_entry(hass) + return await _async_handle_streamable_message( + request, self.context(request), entry.data[CONF_LLM_HASS_API] + ) - # 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) +class ModelContextProtocolStreamableApiView(HomeAssistantView): + """Model Context Protocol Streamable HTTP endpoint for a specific LLM API. - # 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 LLM API is selected by its ID in the URL. These endpoints require + admin access, except for the Assist API. + """ - # 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) + name = f"{DOMAIN}:streamable_api" + url = f"{STREAMABLE_API}/{{api_id}}" - async with create_streams() as 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 - ), - ) + async def post(self, request: web.Request, api_id: str) -> web.StreamResponse: + """Process JSON-RPC messages for the LLM API identified by api_id.""" + hass = request.app[KEY_HASS] + if api_id != llm.LLM_API_ASSIST and not request["hass_user"].is_admin: + raise Unauthorized + if api_id not in {api.id for api in llm.async_get_apis(hass)}: + raise HTTPNotFound(text=f"Unknown LLM API '{api_id}'") + return await _async_handle_streamable_message( + request, self.context(request), api_id + ) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f505c3505017..881aa71d11a1 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -49,6 +49,7 @@ from homeassistant.const import ( # noqa: F401 STATE_OFF, STATE_PLAYING, STATE_STANDBY, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv @@ -543,7 +544,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _entity_component_unrecorded_attributes = frozenset( { MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL, - ATTR_ENTITY_PICTURE, + EntityStateAttribute.ENTITY_PICTURE, MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST, MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT, MediaPlayerEntityStateAttribute.MEDIA_POSITION, diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index 2b26f999907c..eaa289640e1f 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -174,7 +174,7 @@ class SearchMedia: """Represent search results.""" version: int = field(default=1) - result: list[BrowseMedia] + result: Sequence[BrowseMedia] def as_dict(self, *, parent: bool = True) -> dict[str, Any]: """Convert SearchMedia class to browse media dictionary.""" diff --git a/homeassistant/components/media_player/condition.py b/homeassistant/components/media_player/condition.py index d95fedf92cb2..639466a0dde9 100644 --- a/homeassistant/components/media_player/condition.py +++ b/homeassistant/components/media_player/condition.py @@ -12,11 +12,10 @@ from homeassistant.helpers.condition import ( make_entity_state_condition, ) -from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED -from .const import DOMAIN, MediaPlayerState +from .const import DOMAIN, MediaPlayerEntityStateAttribute, MediaPlayerState VOLUME_DOMAIN_SPECS: dict[str, DomainSpec] = { - DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL), + DOMAIN: DomainSpec(value_source=MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL), } @@ -38,8 +37,10 @@ class _MediaPlayerMutedConditionBase(EntityConditionBase): def _has_volume_attributes(self, state: State) -> bool: """Check if the state has volume muted or volume level attributes.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is not None + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) @override @@ -50,8 +51,10 @@ class _MediaPlayerMutedConditionBase(EntityConditionBase): def _is_muted(self, state: State) -> bool: """Check if the media player is muted.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0 + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is True + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + == 0 ) @override @@ -96,7 +99,8 @@ class MediaPlayerIsVolumeCondition(EntityNumericalConditionBase): """Skip media players that do not expose a volume_level attribute.""" return ( super()._should_include(state) - and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + and state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index 789c2c064f5f..fad4025e6f7f 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -113,7 +113,7 @@ "service": "mdi:shuffle" }, "toggle": { - "service": "mdi:play-pause" + "service": "mdi:power" }, "turn_off": { "service": "mdi:power" diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py new file mode 100644 index 000000000000..aa6778835a64 --- /dev/null +++ b/homeassistant/components/media_player/llm.py @@ -0,0 +1,58 @@ +"""LLM tools for the media_player integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import ( + DOMAIN, + INTENT_MEDIA_NEXT, + INTENT_MEDIA_PAUSE, + INTENT_MEDIA_PREVIOUS, + INTENT_MEDIA_SEARCH_AND_PLAY, + INTENT_MEDIA_UNPAUSE, + INTENT_PLAYER_MUTE, + INTENT_PLAYER_UNMUTE, + INTENT_SET_VOLUME, + INTENT_SET_VOLUME_RELATIVE, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = ( + INTENT_MEDIA_NEXT, + INTENT_MEDIA_PAUSE, + INTENT_PLAYER_MUTE, + INTENT_PLAYER_UNMUTE, + INTENT_MEDIA_PREVIOUS, + INTENT_MEDIA_SEARCH_AND_PLAY, + INTENT_MEDIA_UNPAUSE, + INTENT_SET_VOLUME, + INTENT_SET_VOLUME_RELATIVE, +) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index b4c2c4f821fb..a498966c2c9b 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -5,7 +5,6 @@ from collections.abc import Iterable from typing import Any from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, @@ -19,6 +18,7 @@ from homeassistant.const import ( STATE_ON, STATE_PAUSED, STATE_PLAYING, + EntityStateAttribute, ) from homeassistant.core import Context, HomeAssistant, State @@ -34,8 +34,19 @@ from .const import ( SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, ) +# Maps a state attribute to the service call argument used to restore it. +_STATE_ATTRIBUTE_TO_SERVICE_ARG: dict[MediaPlayerEntityStateAttribute, str] = { + MediaPlayerEntityStateAttribute.INPUT_SOURCE: ATTR_INPUT_SOURCE, + MediaPlayerEntityStateAttribute.SOUND_MODE: ATTR_SOUND_MODE, + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: ATTR_MEDIA_VOLUME_LEVEL, + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED: ATTR_MEDIA_VOLUME_MUTED, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE: ATTR_MEDIA_CONTENT_TYPE, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID: ATTR_MEDIA_CONTENT_ID, +} + async def _async_reproduce_states( hass: HomeAssistant, @@ -46,14 +57,22 @@ async def _async_reproduce_states( ) -> None: """Reproduce component states.""" cur_state = hass.states.get(state.entity_id) - features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0 + features = ( + cur_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES] + if cur_state + else 0 + ) - async def call_service(service: str, keys: Iterable[str]) -> None: - """Call service with set of attributes given.""" + async def call_service( + service: str, attributes: Iterable[MediaPlayerEntityStateAttribute] + ) -> None: + """Call service with the given state attributes.""" data = {"entity_id": state.entity_id} - for key in keys: - if key in state.attributes: - data[key] = state.attributes[key] + for attribute in attributes: + if attribute in state.attributes: + data[_STATE_ATTRIBUTE_TO_SERVICE_ARG[attribute]] = state.attributes[ + attribute + ] await hass.services.async_call( DOMAIN, service, data, blocking=True, context=context @@ -79,42 +98,57 @@ async def _async_reproduce_states( await call_service(SERVICE_TURN_ON, []) cur_state = hass.states.get(state.entity_id) - features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0 + features = ( + cur_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES] + if cur_state + else 0 + ) # First set source & sound mode to match the saved supported features if ( - ATTR_INPUT_SOURCE in state.attributes + MediaPlayerEntityStateAttribute.INPUT_SOURCE in state.attributes and features & MediaPlayerEntityFeature.SELECT_SOURCE ): - await call_service(SERVICE_SELECT_SOURCE, [ATTR_INPUT_SOURCE]) + await call_service( + SERVICE_SELECT_SOURCE, [MediaPlayerEntityStateAttribute.INPUT_SOURCE] + ) if ( - ATTR_SOUND_MODE in state.attributes + MediaPlayerEntityStateAttribute.SOUND_MODE in state.attributes and features & MediaPlayerEntityFeature.SELECT_SOUND_MODE ): - await call_service(SERVICE_SELECT_SOUND_MODE, [ATTR_SOUND_MODE]) + await call_service( + SERVICE_SELECT_SOUND_MODE, [MediaPlayerEntityStateAttribute.SOUND_MODE] + ) if ( - ATTR_MEDIA_VOLUME_LEVEL in state.attributes + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL in state.attributes and features & MediaPlayerEntityFeature.VOLUME_SET ): - await call_service(SERVICE_VOLUME_SET, [ATTR_MEDIA_VOLUME_LEVEL]) + await call_service( + SERVICE_VOLUME_SET, [MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL] + ) if ( - ATTR_MEDIA_VOLUME_MUTED in state.attributes + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED in state.attributes and features & MediaPlayerEntityFeature.VOLUME_MUTE ): - await call_service(SERVICE_VOLUME_MUTE, [ATTR_MEDIA_VOLUME_MUTED]) + await call_service( + SERVICE_VOLUME_MUTE, [MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED] + ) already_playing = False - if (ATTR_MEDIA_CONTENT_TYPE in state.attributes) and ( - ATTR_MEDIA_CONTENT_ID in state.attributes + if (MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE in state.attributes) and ( + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID in state.attributes ): if features & MediaPlayerEntityFeature.PLAY_MEDIA: await call_service( SERVICE_PLAY_MEDIA, - [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID], + [ + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE, + MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID, + ], ) already_playing = True diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py index f9d81b9135dc..c3320f3354ab 100644 --- a/homeassistant/components/media_player/significant_change.py +++ b/homeassistant/components/media_player/significant_change.py @@ -8,21 +8,16 @@ from homeassistant.helpers.significant_change import ( check_valid_float, ) -from . import ( - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_MEDIA_VOLUME_LEVEL, - PROP_TO_ATTR, -) +from . import PROP_TO_ATTR +from .const import MediaPlayerEntityStateAttribute -INSIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, +INSIGNIFICANT_ATTRIBUTES: set[MediaPlayerEntityStateAttribute] = { + MediaPlayerEntityStateAttribute.MEDIA_POSITION, + MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT, } -SIGNIFICANT_ATTRIBUTES: set[str] = { - ATTR_ENTITY_PICTURE_LOCAL, +SIGNIFICANT_ATTRIBUTES: set[MediaPlayerEntityStateAttribute] = { + MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL, *PROP_TO_ATTR.values(), } - INSIGNIFICANT_ATTRIBUTES @@ -49,7 +44,7 @@ def async_check_significant_change( changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} for attr_name in changed_attrs: - if attr_name != ATTR_MEDIA_VOLUME_LEVEL: + if attr_name != MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL: return True old_attr_value = old_attrs.get(attr_name) diff --git a/homeassistant/components/media_player/trigger.py b/homeassistant/components/media_player/trigger.py index 36c9e1c936ff..0629797de740 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -14,11 +14,11 @@ from homeassistant.helpers.trigger import ( make_entity_transition_trigger, ) -from . import ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState -from .const import DOMAIN +from . import MediaPlayerState +from .const import DOMAIN, MediaPlayerEntityStateAttribute VOLUME_DOMAIN_SPECS: dict[str, DomainSpec] = { - DOMAIN: DomainSpec(value_source=ATTR_MEDIA_VOLUME_LEVEL), + DOMAIN: DomainSpec(value_source=MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL), } @@ -31,8 +31,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase): def _has_volume_attributes(self, state: State) -> bool: """Check if the state has volume muted or volume level attributes.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is not None - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is not None + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) @override @@ -48,8 +50,10 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase): def is_muted(self, state: State) -> bool: """Check if the media player is muted.""" return ( - state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True - or state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0 + state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED) + is True + or state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + == 0 ) @override @@ -109,7 +113,8 @@ class VolumeTriggerMixin(EntityNumericalStateTriggerBase): """ return ( super()._should_include(state) - and state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is not None + and state.attributes.get(MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL) + is not None ) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index e2d30db21004..bc68335fcad3 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -5,22 +5,21 @@ from typing import Protocol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.integration_platform import ( - async_process_integration_platforms, -) +from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms from homeassistant.helpers.typing import ConfigType from . import http, local_source from .const import ( + DATA_LOCAL_SOURCE, + DATA_MEDIA_SOURCE_PLATFORMS, DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, - MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX, ) from .error import MediaSourceError, Unresolvable -from .helper import async_browse_media, async_resolve_media +from .helper import async_browse_media, async_resolve_media, async_search_media from .models import ( BrowseMediaSource, MediaSource, @@ -42,6 +41,7 @@ __all__ = [ "Unresolvable", "async_browse_media", "async_resolve_media", + "async_search_media", "generate_media_source_id", "is_media_source_id", ] @@ -72,17 +72,18 @@ 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] = {} + hass.data[DATA_MEDIA_SOURCE_PLATFORMS] = LazyIntegrationPlatforms[MediaSource]( + hass, DOMAIN, _process_media_source_platform + ) http.async_setup(hass) # Local sources support - await _process_media_source_platform(hass, DOMAIN, local_source) + source = await local_source.async_get_media_source(hass) + hass.data[DATA_LOCAL_SOURCE] = source + hass.http.register_view(local_source.LocalMediaView(hass, 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 - ) return True @@ -90,9 +91,6 @@ async def _process_media_source_platform( hass: HomeAssistant, domain: str, platform: MediaSourceProtocol, -) -> None: +) -> MediaSource: """Process a media source platform.""" - 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)) + return await platform.async_get_media_source(hass) diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py index 1e9a7cc1eaa2..e7cc4499b006 100644 --- a/homeassistant/components/media_source/const.py +++ b/homeassistant/components/media_source/const.py @@ -7,10 +7,15 @@ from homeassistant.components.media_player import MediaClass from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: + from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms + from .models import MediaSource DOMAIN = "media_source" -MEDIA_SOURCE_DATA: HassKey[dict[str, MediaSource]] = HassKey(DOMAIN) +DATA_LOCAL_SOURCE: HassKey[MediaSource] = HassKey("media_source_local_source") +DATA_MEDIA_SOURCE_PLATFORMS: HassKey[LazyIntegrationPlatforms[MediaSource]] = HassKey( + "media_source_platforms" +) MEDIA_MIME_TYPES = ("audio", "video", "image") MEDIA_CLASS_MAP = { "audio": MediaClass.MUSIC, diff --git a/homeassistant/components/media_source/helper.py b/homeassistant/components/media_source/helper.py index 0d7afc2b81c3..92eff623bc3d 100644 --- a/homeassistant/components/media_source/helper.py +++ b/homeassistant/components/media_source/helper.py @@ -2,18 +2,29 @@ from collections.abc import Callable -from homeassistant.components.media_player import BrowseError, BrowseMedia -from homeassistant.core import HomeAssistant, callback +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + SearchMedia, + SearchMediaQuery, +) +from homeassistant.core import HomeAssistant from homeassistant.helpers.frame import report_usage from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from .const import DOMAIN, MEDIA_SOURCE_DATA +from .const import DOMAIN from .error import UnknownMediaSource, Unresolvable -from .models import BrowseMediaSource, MediaSourceItem, PlayMedia, RootBrowseMediaSource +from .models import ( + BrowseMediaSource, + MediaSourceItem, + PlayMedia, + RootBrowseMediaSource, + _async_get_media_source, + _async_get_media_sources, +) -@callback -def _get_media_item( +async def _get_media_item( hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None ) -> MediaSourceItem: """Return media item.""" @@ -21,10 +32,14 @@ def _get_media_item( 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 + sources = await _async_get_media_sources(hass) + domain = None if len(sources) > 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]: + if ( + item.domain is not None + and await _async_get_media_source(hass, item.domain) is None + ): raise UnknownMediaSource( translation_domain=DOMAIN, translation_key="unknown_media_source", @@ -41,11 +56,12 @@ async def async_browse_media( content_filter: Callable[[BrowseMedia], bool] | None = None, ) -> BrowseMediaSource | RootBrowseMediaSource: """Return media player browse media results.""" - if DOMAIN not in hass.data: + if DOMAIN not in hass.config.top_level_components: raise BrowseError("Media Source not loaded") try: - item = await _get_media_item(hass, media_content_id, None).async_browse() + media_item = await _get_media_item(hass, media_content_id, None) + item = await media_item.async_browse() except ValueError as err: raise BrowseError( translation_domain=DOMAIN, @@ -67,13 +83,42 @@ async def async_browse_media( return item +async def async_search_media( + hass: HomeAssistant, + media_content_id: str | None, + query: SearchMediaQuery, +) -> SearchMedia: + """Return media searched in the media source.""" + if DOMAIN not in hass.config.top_level_components: + raise BrowseError("Media Source not loaded") + + try: + media_item = await _get_media_item(hass, media_content_id, None) + return await media_item.async_search(query) + except NotImplementedError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="search_not_supported", + translation_placeholders={"media_content_id": str(media_content_id)}, + ) from err + except ValueError as err: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="search_media_failed", + translation_placeholders={ + "media_content_id": str(media_content_id), + "error": str(err), + }, + ) from err + + 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: + if DOMAIN not in hass.config.top_level_components: raise Unresolvable("Media Source not loaded") if target_media_player is UNDEFINED: @@ -84,7 +129,7 @@ async def async_resolve_media( target_media_player = None try: - item = _get_media_item(hass, media_content_id, target_media_player) + item = await _get_media_item(hass, media_content_id, target_media_player) except ValueError as err: raise Unresolvable( translation_domain=DOMAIN, diff --git a/homeassistant/components/media_source/http.py b/homeassistant/components/media_source/http.py index c1c4882e7acf..0acec90fae88 100644 --- a/homeassistant/components/media_source/http.py +++ b/homeassistant/components/media_source/http.py @@ -7,20 +7,25 @@ import voluptuous as vol from homeassistant.components import frontend, websocket_api from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_FILTER_CLASSES, + ATTR_MEDIA_SEARCH_QUERY, CONTENT_AUTH_EXPIRY_TIME, BrowseError, + MediaClass, + SearchMediaQuery, 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 +from .helper import async_browse_media, async_resolve_media, async_search_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_search_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" @@ -48,6 +53,35 @@ async def websocket_browse_media( connection.send_error(msg["id"], "browse_media_failed", str(err)) +@websocket_api.websocket_command( + { + vol.Required("type"): "media_source/search_media", + vol.Optional(ATTR_MEDIA_CONTENT_ID, default=""): str, + vol.Required(ATTR_MEDIA_SEARCH_QUERY): str, + vol.Optional(ATTR_MEDIA_FILTER_CLASSES): [vol.Coerce(MediaClass)], + } +) +@websocket_api.async_response +async def websocket_search_media( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Search available media.""" + try: + result = await async_search_media( + hass, + msg["media_content_id"], + SearchMediaQuery( + search_query=msg[ATTR_MEDIA_SEARCH_QUERY], + media_filter_classes=msg.get(ATTR_MEDIA_FILTER_CLASSES), + ), + ) + except BrowseError as err: + connection.send_error(msg["id"], "search_media_failed", str(err)) + return + + connection.send_result(msg["id"], result.as_dict()) + + @websocket_api.websocket_command( { vol.Required("type"): "media_source/resolve_media", diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 3c4bd81c2aee..49fb54b225ae 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -13,16 +13,23 @@ import voluptuous as vol from homeassistant.components import http, websocket_api from homeassistant.components.http import require_admin -from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + SearchMedia, + SearchMediaQuery, +) 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 +from .const import DATA_LOCAL_SOURCE, DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES from .error import Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia MAX_UPLOAD_SIZE = 1024 * 1024 * 20 +MAX_SEARCH_RESULTS = 100 LOGGER = logging.getLogger(__name__) @@ -175,6 +182,72 @@ class LocalSource(MediaSource): self._browse_media, source_dir_id, location ) + @override + async def async_search_media( + self, item: MediaSourceItem, query: SearchMediaQuery + ) -> SearchMedia: + """Search media by file name within the local media directories.""" + if item.identifier: + try: + source_dir_id, location = self.async_parse_identifier(item) + except Unresolvable as err: + raise BrowseError(str(err)) from err + search_dirs = [(source_dir_id, location)] + else: + search_dirs = [(source_dir_id, "") for source_dir_id in self.media_dirs] + + return await self.hass.async_add_executor_job( + self._search_media, search_dirs, query + ) + + def _search_media( + self, search_dirs: list[tuple[str, str]], query: SearchMediaQuery + ) -> SearchMedia: + """Search media files by name (runs in the executor).""" + query_str = query.search_query.casefold() + filter_classes = set(query.media_filter_classes or ()) + results: list[BrowseMedia] = [] + + for source_dir_id, location in search_dirs: + if len(results) >= MAX_SEARCH_RESULTS: + break + base_path = Path(self.media_dirs[source_dir_id]) + search_path = base_path / location + if not search_path.is_dir(): + continue + + # Traverse lazily so MAX_SEARCH_RESULTS can short-circuit large libraries + for path in search_path.rglob("*"): + if len(results) >= MAX_SEARCH_RESULTS: + break + relative = path.relative_to(base_path) + if any(part.startswith(".") for part in relative.parts): + continue + if query_str not in path.name.casefold() or not path.is_file(): + continue + mime_type, _ = mimetypes.guess_type(str(path)) + if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: + continue + media_class = MEDIA_CLASS_MAP.get( + mime_type.split("/")[0], MediaClass.DIRECTORY + ) + if filter_classes and media_class not in filter_classes: + continue + results.append( + BrowseMediaSource( + domain=self.domain, + identifier=f"{source_dir_id}/{relative}", + media_class=media_class, + media_content_type=mime_type, + title=path.name, + can_play=True, + can_expand=False, + ) + ) + + results.sort(key=lambda item: item.title) + return SearchMedia(result=results) + def _browse_media( self, source_dir_id: str | None, location: str ) -> BrowseMediaSource: @@ -197,6 +270,7 @@ class LocalSource(MediaSource): title=self.name, can_play=False, can_expand=True, + can_search=True, children_media_class=MediaClass.DIRECTORY, ) @@ -255,6 +329,7 @@ class LocalSource(MediaSource): title=title, can_play=is_file, can_expand=is_dir, + can_search=is_dir, ) if is_file or is_child: @@ -371,7 +446,7 @@ class UploadMediaView(http.HomeAssistantView): if target_folder.domain != DOMAIN: raise web.HTTPBadRequest - source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][target_folder.domain]) + source = cast(LocalSource, hass.data[DATA_LOCAL_SOURCE]) try: uploaded_media_source_id = await source.async_upload_media( target_folder, data["file"] @@ -416,7 +491,7 @@ async def websocket_remove_media( ) return - source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][item.domain]) + source = cast(LocalSource, hass.data[DATA_LOCAL_SOURCE]) try: await source.async_delete_media(item) diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index c02cee7b9b95..2a341f3d0c3b 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -3,16 +3,44 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType -from homeassistant.core import HomeAssistant, callback +from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, + MediaType, + SearchMedia, + SearchMediaQuery, +) +from homeassistant.core import HomeAssistant from homeassistant.helpers.translation import async_get_cached_translations -from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX +from .const import ( + DATA_LOCAL_SOURCE, + DATA_MEDIA_SOURCE_PLATFORMS, + DOMAIN, + URI_SCHEME, + URI_SCHEME_REGEX, +) if TYPE_CHECKING: from pathlib import Path +async def _async_get_media_sources(hass: HomeAssistant) -> dict[str, MediaSource]: + """Return all media sources, loading integration platforms on demand.""" + sources: dict[str, MediaSource] = {DOMAIN: hass.data[DATA_LOCAL_SOURCE]} + sources.update(await hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platforms()) + return sources + + +async def _async_get_media_source( + hass: HomeAssistant, domain: str +) -> MediaSource | None: + """Return the media source for a domain, loading it on demand.""" + if domain == DOMAIN: + return hass.data[DATA_LOCAL_SOURCE] + return await hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platform(domain) + + @dataclass(slots=True) class PlayMedia: """Represents a playable media.""" @@ -81,6 +109,7 @@ class MediaSourceItem: can_expand=True, children_media_class=MediaClass.APP, ) + sources = await _async_get_media_sources(self.hass) base.children = sorted( ( BrowseMediaSource( @@ -93,24 +122,37 @@ class MediaSourceItem: can_play=False, can_expand=True, ) - for source in self.hass.data[MEDIA_SOURCE_DATA].values() + for source in sources.values() ), key=lambda item: item.title, ) return base - return await self.async_media_source().async_browse_media(self) + source = await self._async_media_source() + return await source.async_browse_media(self) + + async def async_search(self, query: SearchMediaQuery) -> SearchMedia: + """Search this item.""" + # Searching the aggregate root (no specific source) is currently not supported + # because it would possibly returns 100s of items + if self.domain is None: + raise NotImplementedError + + return await (await self._async_media_source()).async_search_media(self, query) async def async_resolve(self) -> PlayMedia: """Resolve to playable item.""" - return await self.async_media_source().async_resolve_media(self) + source = await self._async_media_source() + return await source.async_resolve_media(self) - @callback - def async_media_source(self) -> MediaSource: + async def _async_media_source(self) -> MediaSource: """Return media source that owns this item.""" if TYPE_CHECKING: assert self.domain is not None - return self.hass.data[MEDIA_SOURCE_DATA][self.domain] + # Existence is validated by _get_media_item before browse/resolve. + source = await _async_get_media_source(self.hass, self.domain) + assert source is not None + return source @classmethod def from_uri( @@ -144,3 +186,9 @@ class MediaSource: async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Browse media.""" raise NotImplementedError + + async def async_search_media( + self, item: MediaSourceItem, query: SearchMediaQuery + ) -> SearchMedia: + """Search media.""" + raise NotImplementedError diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json index 607f48f66523..9755af5f8a49 100644 --- a/homeassistant/components/media_source/strings.json +++ b/homeassistant/components/media_source/strings.json @@ -4,10 +4,16 @@ }, "exceptions": { "browse_media_failed": { - "message": "Failed to browse media with content id {media_content_id}: {error}" + "message": "Failed to browse media with content ID {media_content_id}: {error}" }, "resolve_media_failed": { - "message": "Failed to resolve media with content id {media_content_id}: {error}" + "message": "Failed to resolve media with content ID {media_content_id}: {error}" + }, + "search_media_failed": { + "message": "Failed to search media with content ID {media_content_id}: {error}" + }, + "search_not_supported": { + "message": "Search is not supported for media with content ID {media_content_id}" }, "unknown_media_source": { "message": "Unknown media source: {domain}" diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index d9e367be06d0..d0b576631bcc 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -12,9 +12,28 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import DOMAIN +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="username") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -69,9 +88,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), + data_schema=USER_SCHEMA, ) return await self._create_client( username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD] @@ -98,9 +115,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), + data_schema=USER_SCHEMA, errors=errors, ) @@ -180,7 +195,12 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=vol.Schema( { - vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ), errors=errors, diff --git a/homeassistant/components/melcloud_home/__init__.py b/homeassistant/components/melcloud_home/__init__.py index def9dc20f815..762b97846120 100644 --- a/homeassistant/components/melcloud_home/__init__.py +++ b/homeassistant/components/melcloud_home/__init__.py @@ -11,6 +11,7 @@ from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/melcloud_home/binary_sensor.py b/homeassistant/components/melcloud_home/binary_sensor.py index 2543227e106a..bc27a1488093 100644 --- a/homeassistant/components/melcloud_home/binary_sensor.py +++ b/homeassistant/components/melcloud_home/binary_sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .common import async_setup_unit_entities from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -22,83 +23,71 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class ATABinarySensorEntityDescription(BinarySensorEntityDescription): - """Class to hold MELCloud Home ATA binary sensor description.""" +class MelCloudHomeBinarySensorEntityDescription[_UnitT: ATAUnit | ATWUnit]( + BinarySensorEntityDescription +): + """Class to hold MELCloud Home binary sensor description.""" - state_fn: Callable[[ATAUnit], bool | None] + state_fn: Callable[[_UnitT], bool | None] -@dataclass(frozen=True, kw_only=True) -class ATWBinarySensorEntityDescription(BinarySensorEntityDescription): - """Class to hold MELCloud Home ATW binary sensor description.""" - - state_fn: Callable[[ATWUnit], bool | None] - - -ATA_SENSORS: tuple[ATABinarySensorEntityDescription, ...] = ( - ATABinarySensorEntityDescription( - key="error", - translation_key="error", - device_class=BinarySensorDeviceClass.PROBLEM, - state_fn=lambda unit: unit.is_in_error, - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATABinarySensorEntityDescription( - key="standby", - translation_key="standby", - state_fn=lambda unit: unit.in_standby_mode, - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATABinarySensorEntityDescription( - key="frost_protection", - translation_key="frost_protection", - state_fn=lambda unit: ( - unit.frost_protection.enabled if unit.frost_protection else None +def _common_sensor_descriptions[_UnitT: ATAUnit | ATWUnit]( + unit_type: type[_UnitT], +) -> tuple[MelCloudHomeBinarySensorEntityDescription[_UnitT], ...]: + """Return the binary sensor descriptions shared by ATA and ATW units.""" + return ( + MelCloudHomeBinarySensorEntityDescription( + key="error", + translation_key="error", + device_class=BinarySensorDeviceClass.PROBLEM, + state_fn=lambda unit: unit.is_in_error, + entity_category=EntityCategory.DIAGNOSTIC, ), - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATABinarySensorEntityDescription( - key="overheat_protection", - translation_key="overheat_protection", - state_fn=lambda unit: ( - unit.overheat_protection.enabled if unit.overheat_protection else None + MelCloudHomeBinarySensorEntityDescription( + key="standby", + translation_key="standby", + state_fn=lambda unit: unit.in_standby_mode, + entity_category=EntityCategory.DIAGNOSTIC, ), - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATABinarySensorEntityDescription( - key="holiday_mode", - translation_key="holiday_mode", - state_fn=lambda unit: unit.holiday_mode.enabled if unit.holiday_mode else None, - entity_category=EntityCategory.DIAGNOSTIC, - ), + MelCloudHomeBinarySensorEntityDescription( + key="holiday_mode", + translation_key="holiday_mode", + state_fn=lambda unit: ( + unit.holiday_mode.enabled if unit.holiday_mode else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + MelCloudHomeBinarySensorEntityDescription( + key="frost_protection", + translation_key="frost_protection", + state_fn=lambda unit: ( + unit.frost_protection.enabled if unit.frost_protection else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + MelCloudHomeBinarySensorEntityDescription( + key="overheat_protection", + translation_key="overheat_protection", + state_fn=lambda unit: ( + unit.overheat_protection.enabled if unit.overheat_protection else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + ) + + +ATA_SENSORS: tuple[MelCloudHomeBinarySensorEntityDescription[ATAUnit], ...] = ( + *_common_sensor_descriptions(ATAUnit), ) -ATW_SENSORS: tuple[ATWBinarySensorEntityDescription, ...] = ( - ATWBinarySensorEntityDescription( - key="error", - translation_key="error", - device_class=BinarySensorDeviceClass.PROBLEM, - state_fn=lambda unit: unit.is_in_error, - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATWBinarySensorEntityDescription( - key="standby", - translation_key="standby", - state_fn=lambda unit: unit.in_standby_mode, - entity_category=EntityCategory.DIAGNOSTIC, - ), - ATWBinarySensorEntityDescription( +ATW_SENSORS: tuple[MelCloudHomeBinarySensorEntityDescription[ATWUnit], ...] = ( + *_common_sensor_descriptions(ATWUnit), + MelCloudHomeBinarySensorEntityDescription( key="forced_hot_water", translation_key="forced_hot_water", state_fn=lambda unit: unit.forced_hot_water_mode, entity_category=EntityCategory.DIAGNOSTIC, ), - ATWBinarySensorEntityDescription( - key="holiday_mode", - translation_key="holiday_mode", - state_fn=lambda unit: unit.holiday_mode.enabled if unit.holiday_mode else None, - entity_category=EntityCategory.DIAGNOSTIC, - ), ) @@ -108,38 +97,32 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home binary sensors.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities( - ATABinarySensor(coordinator, entity_description, unit) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: ( + ATABinarySensor(entry.runtime_data, entity_description, unit) for entity_description in ATA_SENSORS for unit in units - ) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWBinarySensor(coordinator, entity_description, unit) + ), + lambda units: ( + ATWBinarySensor(entry.runtime_data, entity_description, unit) for entity_description in ATW_SENSORS for unit in units - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity): """Representation of a MELCloud Home ATA binary sensor.""" - entity_description: ATABinarySensorEntityDescription + entity_description: MelCloudHomeBinarySensorEntityDescription[ATAUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATABinarySensorEntityDescription, + entity_description: MelCloudHomeBinarySensorEntityDescription[ATAUnit], unit: ATAUnit, ) -> None: """Initialize the entity.""" @@ -157,12 +140,12 @@ class ATABinarySensor(MelCloudHomeATAUnitEntity, BinarySensorEntity): class ATWBinarySensor(MelCloudHomeATWUnitEntity, BinarySensorEntity): """Representation of a MELCloud Home ATW binary sensor.""" - entity_description: ATWBinarySensorEntityDescription + entity_description: MelCloudHomeBinarySensorEntityDescription[ATWUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATWBinarySensorEntityDescription, + entity_description: MelCloudHomeBinarySensorEntityDescription[ATWUnit], unit: ATWUnit, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py index 510881e6bdda..f7742e6b3bc6 100644 --- a/homeassistant/components/melcloud_home/climate.py +++ b/homeassistant/components/melcloud_home/climate.py @@ -8,7 +8,6 @@ from aiomelcloudhome import ( ATAUnit, ATAVaneHorizontal, ATAVaneVertical, - ATWUnit, ATWZoneMode, ) @@ -21,6 +20,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .common import async_setup_unit_entities from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity @@ -99,14 +99,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home climate entities from a config entry.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities(ATAClimateEntity(coordinator, unit) for unit in units) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWZoneClimateEntity(coordinator, unit, zone_number) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: (ATAClimateEntity(entry.runtime_data, unit) for unit in units), + lambda units: ( + ATWZoneClimateEntity(entry.runtime_data, unit, zone_number) for unit in units for zone_number in ( [1, 2] @@ -114,13 +113,8 @@ async def async_setup_entry( or (unit.capabilities is None and unit.has_zone2) else [1] ) - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity): @@ -198,6 +192,42 @@ class ATAClimateEntity(MelCloudHomeATAUnitEntity, ClimateEntity): """Return the target temperature.""" return self.unit.set_temperature + @property + @override + def min_temp(self) -> float: + """Return the minimum temperature based on the current HVAC mode.""" + capabilities = self.unit.capabilities + if capabilities is not None: + hvac_mode = self.hvac_mode + if hvac_mode in (HVACMode.COOL, HVACMode.DRY): + if capabilities.min_temp_cool is not None: + return capabilities.min_temp_cool + elif hvac_mode == HVACMode.AUTO: + if capabilities.min_temp_auto is not None: + return capabilities.min_temp_auto + elif hvac_mode == HVACMode.HEAT: + if capabilities.min_temp_heat is not None: + return capabilities.min_temp_heat + return super().min_temp + + @property + @override + def max_temp(self) -> float: + """Return the maximum temperature based on the current HVAC mode.""" + capabilities = self.unit.capabilities + if capabilities is not None: + hvac_mode = self.hvac_mode + if hvac_mode in (HVACMode.COOL, HVACMode.DRY): + if capabilities.max_temp_cool is not None: + return capabilities.max_temp_cool + elif hvac_mode == HVACMode.AUTO: + if capabilities.max_temp_auto is not None: + return capabilities.max_temp_auto + elif hvac_mode == HVACMode.HEAT: + if capabilities.max_temp_heat is not None: + return capabilities.max_temp_heat + return super().max_temp + @property @override def hvac_mode(self) -> HVACMode: @@ -338,6 +368,36 @@ class ATWZoneClimateEntity(MelCloudHomeATWZoneEntity, ClimateEntity): else self.unit.set_temperature_zone2 ) + @property + @override + def min_temp(self) -> float: + """Return the minimum zone temperature.""" + capabilities = self.unit.capabilities + if capabilities is not None: + value = ( + capabilities.min_set_temperature_zone1 + if self.zone_number == 1 + else capabilities.min_set_temperature_zone2 + ) + if value is not None: + return value + return super().min_temp + + @property + @override + def max_temp(self) -> float: + """Return the maximum zone temperature.""" + capabilities = self.unit.capabilities + if capabilities is not None: + value = ( + capabilities.max_set_temperature_zone1 + if self.zone_number == 1 + else capabilities.max_set_temperature_zone2 + ) + if value is not None: + return value + return super().max_temp + @property @override def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/melcloud_home/common.py b/homeassistant/components/melcloud_home/common.py new file mode 100644 index 000000000000..4a58bba960d8 --- /dev/null +++ b/homeassistant/components/melcloud_home/common.py @@ -0,0 +1,75 @@ +"""Commonly shared code for the MELCloud Home integration.""" + +from collections.abc import Callable, Coroutine, Iterable +from typing import Any + +from aiomelcloudhome import ( + ATAUnit, + ATWUnit, + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import MelCloudHomeCoordinator + + +@callback +def async_setup_unit_entities( + coordinator: MelCloudHomeCoordinator, + async_add_entities: AddConfigEntryEntitiesCallback, + ata_entities_fn: Callable[[list[ATAUnit]], Iterable[Entity]], + atw_entities_fn: Callable[[list[ATWUnit]], Iterable[Entity]], +) -> None: + """Add entities for the current units and register callbacks for new units.""" + + def _async_add_new_ata_units(units: list[ATAUnit]) -> None: + async_add_entities(ata_entities_fn(units)) + + def _async_add_new_atw_units(units: list[ATWUnit]) -> None: + async_add_entities(atw_entities_fn(units)) + + coordinator.new_ata_callbacks.append(_async_add_new_ata_units) + coordinator.new_atw_callbacks.append(_async_add_new_atw_units) + + _async_add_new_ata_units(list(coordinator.ata_units.values())) + _async_add_new_atw_units(list(coordinator.atw_units.values())) + + +async def perform_action( + coordinator: MelCloudHomeCoordinator, + coroutine: Coroutine[Any, Any, None], +) -> None: + """Perform a MELCloud Home action with error handling and coordinator refresh.""" + try: + await coroutine + except MelCloudHomeAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except MelCloudHomeConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except MelCloudHomeTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + ) from err + else: + await coordinator.async_request_refresh() + + +def unit_ids(unit: ATAUnit | ATWUnit) -> dict[str, list[str]]: + """Return the client keyword argument selecting this unit.""" + if isinstance(unit, ATAUnit): + return {"ata_unit_ids": [unit.id]} + return {"atw_unit_ids": [unit.id]} diff --git a/homeassistant/components/melcloud_home/config_flow.py b/homeassistant/components/melcloud_home/config_flow.py index 17d97ff86f7b..47edc6276519 100644 --- a/homeassistant/components/melcloud_home/config_flow.py +++ b/homeassistant/components/melcloud_home/config_flow.py @@ -128,6 +128,43 @@ class MelCloudHomeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", - data_schema=STEP_USER_DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + {CONF_EMAIL: reauth_entry.data[CONF_EMAIL]}, + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + + if user_input is not None: + errors, user_id = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if not errors: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconf_entry, + data_updates={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + { + CONF_EMAIL: reconf_entry.data[CONF_EMAIL], + CONF_PASSWORD: reconf_entry.data[CONF_PASSWORD], + }, + ), errors=errors, ) diff --git a/homeassistant/components/melcloud_home/const.py b/homeassistant/components/melcloud_home/const.py index 6ce91d654e5f..6adbcd76c4ea 100644 --- a/homeassistant/components/melcloud_home/const.py +++ b/homeassistant/components/melcloud_home/const.py @@ -1,3 +1,8 @@ """Constants for the MELCloud Home integration.""" DOMAIN = "melcloud_home" + +WEB_BASE_URL = "https://melcloudhome.com" + +DEVICE_ATA = "ata" +DEVICE_ATW = "atw" diff --git a/homeassistant/components/melcloud_home/coordinator.py b/homeassistant/components/melcloud_home/coordinator.py index 1f9a745ac30f..c24f246d8371 100644 --- a/homeassistant/components/melcloud_home/coordinator.py +++ b/homeassistant/components/melcloud_home/coordinator.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import utcnow from .const import DOMAIN @@ -50,6 +51,8 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): self.client = client self.ata_units: dict[str, ATAUnit] = {} self.atw_units: dict[str, ATWUnit] = {} + self.ata_energy: dict[str, float | None] = {} + self.atw_energy: dict[str, float | None] = {} self.known_ata: set[str] = set() self.known_atw: set[str] = set() self.new_ata_callbacks: list[Callable[[list[ATAUnit]], None]] = [] @@ -60,7 +63,7 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): current_ata = [ unit for building in data.buildings for unit in building.air_to_air_units ] - self.ata_units = {unit.id: unit for unit in current_ata} + current_ata_ids = {unit.id for unit in current_ata} self.known_ata &= current_ata_ids new_ata_ids = current_ata_ids - self.known_ata @@ -74,7 +77,7 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): current_atw_units = [ unit for building in data.buildings for unit in building.air_to_water_units ] - self.atw_units = {unit.id: unit for unit in current_atw_units} + current_atw_ids = {unit.id for unit in current_atw_units} self.known_atw &= current_atw_ids new_atw_ids = current_atw_ids - self.known_atw @@ -99,15 +102,46 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]): for identifier in device.identifiers ): _LOGGER.debug("Removing stale device: %s", device.identifiers) - registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + registry.async_remove_device(device.id) @override async def _async_update_data(self) -> UserContext: """Fetch data from the MELCloud Home API.""" try: data = await self.client.get_context() + + start_of_month = utcnow().replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + for building in data.buildings: + for ata_unit in building.air_to_air_units: + self.ata_units[ata_unit.id] = ata_unit + if ( + ata_unit.capabilities + and ata_unit.capabilities.has_energy_consumed_meter + ): + energy = await self.client.get_energy_telemetry( + ata_unit.id, + from_dt=start_of_month, + to_dt=utcnow(), + ) + self.ata_energy[ata_unit.id] = sum( + float(e.value) for e in energy + ) + for atw_unit in building.air_to_water_units: + self.atw_units[atw_unit.id] = atw_unit + if ( + atw_unit.capabilities + and atw_unit.capabilities.has_energy_consumed_meter + ): + energy = await self.client.get_energy_telemetry( + atw_unit.id, + from_dt=start_of_month, + to_dt=utcnow(), + ) + self.atw_energy[atw_unit.id] = sum( + float(e.value) for e in energy + ) except MelCloudHomeAuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/melcloud_home/entity.py b/homeassistant/components/melcloud_home/entity.py index 7bb2ac4e2c87..da84d8e9abbe 100644 --- a/homeassistant/components/melcloud_home/entity.py +++ b/homeassistant/components/melcloud_home/entity.py @@ -4,11 +4,12 @@ from abc import abstractmethod from typing import override from aiomelcloudhome import ATAUnit, ATWUnit +from yarl import URL from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DEVICE_ATA, DEVICE_ATW, DOMAIN, WEB_BASE_URL from .coordinator import MelCloudHomeCoordinator @@ -21,6 +22,8 @@ class MelCloudHomeEntity(CoordinatorEntity[MelCloudHomeCoordinator]): class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity): """Base entity for a MELCloud Home unit.""" + _unit_type_path: str + def __init__(self, coordinator: MelCloudHomeCoordinator, unit: _UnitT) -> None: """Initialize the entity.""" super().__init__(coordinator) @@ -30,6 +33,9 @@ class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity): identifiers={(DOMAIN, unit.id)}, name=unit.name, manufacturer="Mitsubishi Electric", + configuration_url=URL( + f"{WEB_BASE_URL}/{self._unit_type_path}/{unit.id}/temperature" + ), ) @abstractmethod @@ -51,6 +57,8 @@ class MelCloudHomeUnitEntity[_UnitT: (ATAUnit, ATWUnit)](MelCloudHomeEntity): class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]): """Base entity for a MELCloud Home Air-to-Air unit.""" + _unit_type_path = DEVICE_ATA + @override def _units_dict(self) -> dict[str, ATAUnit]: """Return ATA units dict from coordinator.""" @@ -60,6 +68,8 @@ class MelCloudHomeATAUnitEntity(MelCloudHomeUnitEntity[ATAUnit]): class MelCloudHomeATWUnitEntity(MelCloudHomeUnitEntity[ATWUnit]): """Base entity for a MELCloud Home Air-to-Water unit.""" + _unit_type_path = DEVICE_ATW + @override def _units_dict(self) -> dict[str, ATWUnit]: """Return ATW units dict from coordinator.""" diff --git a/homeassistant/components/melcloud_home/icons.json b/homeassistant/components/melcloud_home/icons.json index 2c7d55ef5f27..dc98ac2263b0 100644 --- a/homeassistant/components/melcloud_home/icons.json +++ b/homeassistant/components/melcloud_home/icons.json @@ -29,6 +29,20 @@ } } }, + "number": { + "frost_protection_max_temp": { + "default": "mdi:thermometer" + }, + "frost_protection_min_temp": { + "default": "mdi:thermometer-low" + }, + "overheat_protection_max_temp": { + "default": "mdi:thermometer-high" + }, + "overheat_protection_min_temp": { + "default": "mdi:thermometer" + } + }, "sensor": { "room_temperature": { "default": "mdi:home-thermometer" diff --git a/homeassistant/components/melcloud_home/manifest.json b/homeassistant/components/melcloud_home/manifest.json index 0ba62597594d..63adffcef260 100644 --- a/homeassistant/components/melcloud_home/manifest.json +++ b/homeassistant/components/melcloud_home/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiomelcloudhome"], "quality_scale": "bronze", - "requirements": ["aiomelcloudhome==0.1.9"] + "requirements": ["aiomelcloudhome==0.2.1"] } diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py new file mode 100644 index 000000000000..2c78d443922a --- /dev/null +++ b/homeassistant/components/melcloud_home/number.py @@ -0,0 +1,292 @@ +"""Number platform for MELCloud Home.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, override + +from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import async_setup_unit_entities, perform_action, unit_ids +from .const import DOMAIN +from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator +from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class MelCloudHomeNumberEntityDescription[_UnitT: ATAUnit | ATWUnit]( + NumberEntityDescription +): + """Class to hold MELCloud Home number description.""" + + available_fn: Callable[[_UnitT], bool] + value_fn: Callable[[_UnitT], float | None] + set_value_fn: Callable[[MELCloudHome, _UnitT, float], Coroutine[Any, Any, None]] + validate_fn: Callable[[_UnitT, float], str | None] | None = None + + +def _number_descriptions[_UnitT: ATAUnit | ATWUnit]( + unit_type: type[_UnitT], + *, + overheat_min_temp: float, + overheat_max_temp: float, +) -> tuple[MelCloudHomeNumberEntityDescription[_UnitT], ...]: + """Return the number descriptions for a unit type.""" + return ( + MelCloudHomeNumberEntityDescription( + key="frost_protection_min_temp", + translation_key="frost_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.min if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled + if unit.frost_protection + else False, + min_temp=value, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + **unit_ids(unit), + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.frost_protection and value >= unit.frost_protection.max + else None + ), + ), + MelCloudHomeNumberEntityDescription( + key="frost_protection_max_temp", + translation_key="frost_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=0.0, + native_max_value=30.0, + native_step=0.5, + available_fn=lambda unit: ( + unit.frost_protection is not None and unit.frost_protection.enabled + ), + value_fn=lambda unit: ( + unit.frost_protection.max if unit.frost_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_frost_protection( + enabled=unit.frost_protection.enabled + if unit.frost_protection + else False, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=value, + **unit_ids(unit), + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.frost_protection and value <= unit.frost_protection.min + else None + ), + ), + MelCloudHomeNumberEntityDescription( + key="overheat_protection_min_temp", + translation_key="overheat_protection_min_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=overheat_min_temp, + native_max_value=overheat_max_temp, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None + and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.min if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=value, + max_temp=unit.overheat_protection.max + if unit.overheat_protection + else 0.0, + **unit_ids(unit), + ), + validate_fn=lambda unit, value: ( + "temperature_min_exceeds_max" + if unit.overheat_protection and value >= unit.overheat_protection.max + else None + ), + ), + MelCloudHomeNumberEntityDescription( + key="overheat_protection_max_temp", + translation_key="overheat_protection_max_temp", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.CONFIG, + native_min_value=overheat_min_temp, + native_max_value=overheat_max_temp, + native_step=0.5, + available_fn=lambda unit: ( + unit.overheat_protection is not None + and unit.overheat_protection.enabled + ), + value_fn=lambda unit: ( + unit.overheat_protection.max if unit.overheat_protection else None + ), + set_value_fn=lambda client, unit, value: client.set_overheat_protection( + enabled=unit.overheat_protection.enabled + if unit.overheat_protection + else False, + min_temp=unit.overheat_protection.min + if unit.overheat_protection + else 0.0, + max_temp=value, + **unit_ids(unit), + ), + validate_fn=lambda unit, value: ( + "temperature_max_below_min" + if unit.overheat_protection and value <= unit.overheat_protection.min + else None + ), + ), + ) + + +ATA_NUMBERS: tuple[MelCloudHomeNumberEntityDescription[ATAUnit], ...] = ( + _number_descriptions(ATAUnit, overheat_min_temp=31.0, overheat_max_temp=40.0) +) +ATW_NUMBERS: tuple[MelCloudHomeNumberEntityDescription[ATWUnit], ...] = ( + _number_descriptions(ATWUnit, overheat_min_temp=20.0, overheat_max_temp=60.0) +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MelCloudHomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up MELCloud Home numbers.""" + + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: ( + ATANumber(entry.runtime_data, entity_description, unit) + for entity_description in ATA_NUMBERS + for unit in units + ), + lambda units: ( + ATWNumber(entry.runtime_data, entity_description, unit) + for entity_description in ATW_NUMBERS + for unit in units + ), + ) + + +class ATANumber(MelCloudHomeATAUnitEntity, NumberEntity): + """Representation of a MELCloud Home ATA number.""" + + entity_description: MelCloudHomeNumberEntityDescription[ATAUnit] + + def __init__( + self, + coordinator: MelCloudHomeCoordinator, + entity_description: MelCloudHomeNumberEntityDescription[ATAUnit], + unit: ATAUnit, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, unit) + self.entity_description = entity_description + self._attr_unique_id = f"{unit.id}_{entity_description.key}" + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self.entity_description.available_fn(self.unit) + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.unit) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the protection temperature threshold.""" + if self.entity_description.validate_fn and ( + error_key := self.entity_description.validate_fn(self.unit, value) + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_key, + ) + await perform_action( + self.coordinator, + self.entity_description.set_value_fn( + self.coordinator.client, self.unit, value + ), + ) + + +class ATWNumber(MelCloudHomeATWUnitEntity, NumberEntity): + """Representation of a MELCloud Home ATW number.""" + + entity_description: MelCloudHomeNumberEntityDescription[ATWUnit] + + def __init__( + self, + coordinator: MelCloudHomeCoordinator, + entity_description: MelCloudHomeNumberEntityDescription[ATWUnit], + unit: ATWUnit, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, unit) + self.entity_description = entity_description + self._attr_unique_id = f"{unit.id}_{entity_description.key}" + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self.entity_description.available_fn(self.unit) + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self.unit) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the protection temperature threshold.""" + if self.entity_description.validate_fn and ( + error_key := self.entity_description.validate_fn(self.unit, value) + ): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_key, + ) + await perform_action( + self.coordinator, + self.entity_description.set_value_fn( + self.coordinator.client, self.unit, value + ), + ) diff --git a/homeassistant/components/melcloud_home/quality_scale.yaml b/homeassistant/components/melcloud_home/quality_scale.yaml index c5f7a70aca4c..02c57da54e40 100644 --- a/homeassistant/components/melcloud_home/quality_scale.yaml +++ b/homeassistant/components/melcloud_home/quality_scale.yaml @@ -64,7 +64,7 @@ rules: entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: done diff --git a/homeassistant/components/melcloud_home/sensor.py b/homeassistant/components/melcloud_home/sensor.py index 907c7033b56f..5593ac3725cf 100644 --- a/homeassistant/components/melcloud_home/sensor.py +++ b/homeassistant/components/melcloud_home/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import override from aiomelcloudhome import ATAUnit, ATWUnit @@ -16,11 +17,14 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfEnergy, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow +from .common import async_setup_unit_entities from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -28,82 +32,94 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class ATASensorEntityDescription(SensorEntityDescription): - """Class to hold MELCloud Home ATA sensor description.""" +class MelCloudHomeSensorEntityDescription[_UnitT: ATAUnit | ATWUnit]( + SensorEntityDescription +): + """Class to hold MELCloud Home sensor description.""" - value_fn: Callable[[ATAUnit], StateType] + value_fn: Callable[[_UnitT, MelCloudHomeCoordinator], StateType] + exists_fn: Callable[[_UnitT], bool] = lambda _: True -@dataclass(frozen=True, kw_only=True) -class ATWSensorEntityDescription(SensorEntityDescription): - """Class to hold MELCloud Home ATW sensor description.""" - - value_fn: Callable[[ATWUnit], StateType] - exists_fn: Callable[[ATWUnit], bool] = lambda unit: True +def _common_sensor_descriptions[_UnitT: ATAUnit | ATWUnit]( + unit_type: type[_UnitT], +) -> tuple[MelCloudHomeSensorEntityDescription[_UnitT], ...]: + """Return the sensor descriptions shared by ATA and ATW units.""" + return ( + MelCloudHomeSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda unit, _: unit.rssi, + ), + MelCloudHomeSensorEntityDescription( + key="energy_consumed", + translation_key="energy_consumed", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda unit, coordinator: ( + coordinator.ata_energy + if isinstance(unit, ATAUnit) + else coordinator.atw_energy + ).get(unit.id), + exists_fn=lambda unit: bool( + unit.capabilities and unit.capabilities.has_energy_consumed_meter + ), + ), + ) -ATA_SENSORS: tuple[ATASensorEntityDescription, ...] = ( - ATASensorEntityDescription( +ATA_SENSORS: tuple[MelCloudHomeSensorEntityDescription[ATAUnit], ...] = ( + MelCloudHomeSensorEntityDescription( key="room_temperature", translation_key="room_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature, - ), - ATASensorEntityDescription( - key="rssi", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - value_fn=lambda unit: unit.rssi, + value_fn=lambda unit, _: unit.room_temperature, ), + *_common_sensor_descriptions(ATAUnit), ) -ATW_SENSORS: tuple[ATWSensorEntityDescription, ...] = ( - ATWSensorEntityDescription( +ATW_SENSORS: tuple[MelCloudHomeSensorEntityDescription[ATWUnit], ...] = ( + MelCloudHomeSensorEntityDescription( key="room_temperature_zone_1", translation_key="room_temperature_zone_1", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature_zone1, + value_fn=lambda unit, _: unit.room_temperature_zone1, ), - ATWSensorEntityDescription( + MelCloudHomeSensorEntityDescription( key="room_temperature_zone_2", translation_key="room_temperature_zone_2", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.room_temperature_zone2, + value_fn=lambda unit, _: unit.room_temperature_zone2, exists_fn=lambda unit: bool( (unit.capabilities and unit.capabilities.has_zone2) or (unit.capabilities is None and unit.has_zone2) ), ), - ATWSensorEntityDescription( + MelCloudHomeSensorEntityDescription( key="tank_water_temperature", translation_key="tank_water_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, suggested_display_precision=1, - value_fn=lambda unit: unit.tank_water_temperature, - ), - ATWSensorEntityDescription( - key="rssi", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - value_fn=lambda unit: unit.rssi, + value_fn=lambda unit, _: unit.tank_water_temperature, ), + *_common_sensor_descriptions(ATWUnit), ) @@ -113,39 +129,34 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home sensors.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities( - ATASensor(coordinator, entity_description, unit) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: ( + ATASensor(entry.runtime_data, entity_description, unit) for entity_description in ATA_SENSORS for unit in units - ) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWSensor(coordinator, entity_description, unit) + if entity_description.exists_fn(unit) + ), + lambda units: ( + ATWSensor(entry.runtime_data, entity_description, unit) for entity_description in ATW_SENSORS for unit in units if entity_description.exists_fn(unit) - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity): """Representation of a MELCloud Home ATA sensor.""" - entity_description: ATASensorEntityDescription + entity_description: MelCloudHomeSensorEntityDescription[ATAUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATASensorEntityDescription, + entity_description: MelCloudHomeSensorEntityDescription[ATAUnit], unit: ATAUnit, ) -> None: """Initialize the entity.""" @@ -157,18 +168,26 @@ class ATASensor(MelCloudHomeATAUnitEntity, SensorEntity): @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.unit) + return self.entity_description.value_fn(self.unit, self.coordinator) + + @property + @override + def last_reset(self) -> datetime | None: + """Return start of month for TOTAL energy sensors.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return None class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity): """Representation of a MELCloud Home ATW sensor.""" - entity_description: ATWSensorEntityDescription + entity_description: MelCloudHomeSensorEntityDescription[ATWUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATWSensorEntityDescription, + entity_description: MelCloudHomeSensorEntityDescription[ATWUnit], unit: ATWUnit, ) -> None: """Initialize the entity.""" @@ -180,4 +199,12 @@ class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity): @override def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.unit) + return self.entity_description.value_fn(self.unit, self.coordinator) + + @property + @override + def last_reset(self) -> datetime | None: + """Return start of month for TOTAL energy sensors.""" + if self.entity_description.state_class == SensorStateClass.TOTAL: + return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return None diff --git a/homeassistant/components/melcloud_home/strings.json b/homeassistant/components/melcloud_home/strings.json index b5eadcc104cf..330bfd52224a 100644 --- a/homeassistant/components/melcloud_home/strings.json +++ b/homeassistant/components/melcloud_home/strings.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account." }, "error": { @@ -24,6 +25,17 @@ "description": "The credentials for your MELCloud Home account are no longer valid. Enter your current credentials to reauthenticate.", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::melcloud_home::config::step::user::data_description::email%]", + "password": "[%key:component::melcloud_home::config::step::user::data_description::password%]" + }, + "description": "Re-enter your credentials to reconfigure your MELCloud Home account." + }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]", @@ -96,7 +108,24 @@ } } }, + "number": { + "frost_protection_max_temp": { + "name": "Frost protection maximum temperature" + }, + "frost_protection_min_temp": { + "name": "Frost protection minimum temperature" + }, + "overheat_protection_max_temp": { + "name": "Overheat protection maximum temperature" + }, + "overheat_protection_min_temp": { + "name": "Overheat protection minimum temperature" + } + }, "sensor": { + "energy_consumed": { + "name": "Energy consumed (monthly)" + }, "room_temperature": { "name": "Room temperature" }, @@ -126,6 +155,12 @@ "invalid_auth": { "message": "An error occurred while trying to authenticate" }, + "temperature_max_below_min": { + "message": "The maximum temperature must be higher than the minimum temperature." + }, + "temperature_min_exceeds_max": { + "message": "The minimum temperature must be lower than the maximum temperature." + }, "timeout_connect": { "message": "Timeout while communicating with MELCloud Home API" } diff --git a/homeassistant/components/melcloud_home/switch.py b/homeassistant/components/melcloud_home/switch.py index 75301cc5ddad..2d0f6ab230e0 100644 --- a/homeassistant/components/melcloud_home/switch.py +++ b/homeassistant/components/melcloud_home/switch.py @@ -5,11 +5,6 @@ from dataclasses import dataclass from typing import Any, override from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome -from aiomelcloudhome.exceptions import ( - MelCloudHomeAuthenticationError, - MelCloudHomeConnectionError, - MelCloudHomeTimeoutError, -) from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,10 +13,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .common import async_setup_unit_entities, perform_action, unit_ids from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -29,184 +23,117 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class ATASwitchEntityDescription(SwitchEntityDescription): - """Class to hold MELCloud Home ATA switch description.""" +class MelCloudHomeSwitchEntityDescription[_UnitT: ATAUnit | ATWUnit]( + SwitchEntityDescription +): + """Class to hold MELCloud Home switch description.""" - available_fn: Callable[[ATAUnit], bool] - is_on_fn: Callable[[ATAUnit], bool | None] - turn_on_fn: Callable[[MELCloudHome, ATAUnit], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[MELCloudHome, ATAUnit], Coroutine[Any, Any, None]] + available_fn: Callable[[_UnitT], bool] + is_on_fn: Callable[[_UnitT], bool | None] + turn_on_fn: Callable[[MELCloudHome, _UnitT], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[MELCloudHome, _UnitT], Coroutine[Any, Any, None]] -@dataclass(frozen=True, kw_only=True) -class ATWSwitchEntityDescription(SwitchEntityDescription): - """Class to hold MELCloud Home ATW switch description.""" - - available_fn: Callable[[ATWUnit], bool] - is_on_fn: Callable[[ATWUnit], bool | None] - turn_on_fn: Callable[[MELCloudHome, ATWUnit], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[MELCloudHome, ATWUnit], Coroutine[Any, Any, None]] +def _switch_descriptions[_UnitT: ATAUnit | ATWUnit]( + unit_type: type[_UnitT], +) -> tuple[MelCloudHomeSwitchEntityDescription[_UnitT], ...]: + """Return the switch descriptions for a unit type.""" + return ( + MelCloudHomeSwitchEntityDescription( + key="frost_protection", + translation_key="frost_protection", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + available_fn=lambda unit: unit.frost_protection is not None, + is_on_fn=lambda unit: ( + unit.frost_protection.enabled if unit.frost_protection else None + ), + turn_on_fn=lambda client, unit: client.set_frost_protection( + enabled=True, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + **unit_ids(unit), + ), + turn_off_fn=lambda client, unit: client.set_frost_protection( + enabled=False, + min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, + max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, + **unit_ids(unit), + ), + ), + MelCloudHomeSwitchEntityDescription( + key="overheat_protection", + translation_key="overheat_protection", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + available_fn=lambda unit: unit.overheat_protection is not None, + is_on_fn=lambda unit: ( + unit.overheat_protection.enabled if unit.overheat_protection else None + ), + turn_on_fn=lambda client, unit: client.set_overheat_protection( + enabled=True, + min_temp=unit.overheat_protection.min + if unit.overheat_protection + else 0.0, + max_temp=unit.overheat_protection.max + if unit.overheat_protection + else 0.0, + **unit_ids(unit), + ), + turn_off_fn=lambda client, unit: client.set_overheat_protection( + enabled=False, + min_temp=unit.overheat_protection.min + if unit.overheat_protection + else 0.0, + max_temp=unit.overheat_protection.max + if unit.overheat_protection + else 0.0, + **unit_ids(unit), + ), + ), + ) -ATA_SWITCHES: tuple[ATASwitchEntityDescription, ...] = ( - ATASwitchEntityDescription( - key="frost_protection", - translation_key="frost_protection", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - available_fn=lambda unit: unit.frost_protection is not None, - is_on_fn=lambda unit: ( - unit.frost_protection.enabled if unit.frost_protection else None - ), - turn_on_fn=lambda client, unit: client.set_frost_protection( - enabled=True, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - ata_unit_ids=[unit.id], - ), - turn_off_fn=lambda client, unit: client.set_frost_protection( - enabled=False, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - ata_unit_ids=[unit.id], - ), - ), - ATASwitchEntityDescription( - key="overheat_protection", - translation_key="overheat_protection", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - available_fn=lambda unit: unit.overheat_protection is not None, - is_on_fn=lambda unit: ( - unit.overheat_protection.enabled if unit.overheat_protection else None - ), - turn_on_fn=lambda client, unit: client.set_overheat_protection( - enabled=True, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - ata_unit_ids=[unit.id], - ), - turn_off_fn=lambda client, unit: client.set_overheat_protection( - enabled=False, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - ata_unit_ids=[unit.id], - ), - ), +ATA_SWITCHES: tuple[MelCloudHomeSwitchEntityDescription[ATAUnit], ...] = ( + _switch_descriptions(ATAUnit) ) - -ATW_SWITCHES: tuple[ATWSwitchEntityDescription, ...] = ( - ATWSwitchEntityDescription( - key="frost_protection", - translation_key="frost_protection", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - available_fn=lambda unit: unit.frost_protection is not None, - is_on_fn=lambda unit: ( - unit.frost_protection.enabled if unit.frost_protection else None - ), - turn_on_fn=lambda client, unit: client.set_frost_protection( - enabled=True, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - atw_unit_ids=[unit.id], - ), - turn_off_fn=lambda client, unit: client.set_frost_protection( - enabled=False, - min_temp=unit.frost_protection.min if unit.frost_protection else 0.0, - max_temp=unit.frost_protection.max if unit.frost_protection else 0.0, - atw_unit_ids=[unit.id], - ), - ), - ATWSwitchEntityDescription( - key="overheat_protection", - translation_key="overheat_protection", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - available_fn=lambda unit: unit.overheat_protection is not None, - is_on_fn=lambda unit: ( - unit.overheat_protection.enabled if unit.overheat_protection else None - ), - turn_on_fn=lambda client, unit: client.set_overheat_protection( - enabled=True, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - atw_unit_ids=[unit.id], - ), - turn_off_fn=lambda client, unit: client.set_overheat_protection( - enabled=False, - min_temp=unit.overheat_protection.min if unit.overheat_protection else 0.0, - max_temp=unit.overheat_protection.max if unit.overheat_protection else 0.0, - atw_unit_ids=[unit.id], - ), - ), +ATW_SWITCHES: tuple[MelCloudHomeSwitchEntityDescription[ATWUnit], ...] = ( + _switch_descriptions(ATWUnit) ) -async def _perform_action( - coordinator: MelCloudHomeCoordinator, - coroutine: Coroutine[Any, Any, None], -) -> None: - """Perform a MELCloud Home action with error handling and coordinator refresh.""" - try: - await coroutine - except MelCloudHomeAuthenticationError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) from err - except MelCloudHomeConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except MelCloudHomeTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout_connect", - ) from err - else: - await coordinator.async_request_refresh() - - async def async_setup_entry( hass: HomeAssistant, entry: MelCloudHomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud Home switches.""" - coordinator = entry.runtime_data - def _async_add_new_ata_units(units: list[ATAUnit]) -> None: - async_add_entities( - ATASwitch(coordinator, entity_description, unit) + async_setup_unit_entities( + entry.runtime_data, + async_add_entities, + lambda units: ( + ATASwitch(entry.runtime_data, entity_description, unit) for entity_description in ATA_SWITCHES for unit in units - ) - - def _async_add_new_atw_units(units: list[ATWUnit]) -> None: - async_add_entities( - ATWSwitch(coordinator, entity_description, unit) + ), + lambda units: ( + ATWSwitch(entry.runtime_data, entity_description, unit) for entity_description in ATW_SWITCHES for unit in units - ) - - coordinator.new_ata_callbacks.append(_async_add_new_ata_units) - coordinator.new_atw_callbacks.append(_async_add_new_atw_units) - - _async_add_new_ata_units(list(coordinator.ata_units.values())) - _async_add_new_atw_units(list(coordinator.atw_units.values())) + ), + ) class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): """Representation of a MELCloud Home ATA switch.""" - entity_description: ATASwitchEntityDescription + entity_description: MelCloudHomeSwitchEntityDescription[ATAUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATASwitchEntityDescription, + entity_description: MelCloudHomeSwitchEntityDescription[ATAUnit], unit: ATAUnit, ) -> None: """Initialize the entity.""" @@ -229,7 +156,7 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): @override async def async_turn_on(self, **kwargs: Any) -> None: """Enable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_on_fn(self.coordinator.client, self.unit), ) @@ -237,7 +164,7 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): @override async def async_turn_off(self, **kwargs: Any) -> None: """Disable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_off_fn(self.coordinator.client, self.unit), ) @@ -246,12 +173,12 @@ class ATASwitch(MelCloudHomeATAUnitEntity, SwitchEntity): class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity): """Representation of a MELCloud Home ATW switch.""" - entity_description: ATWSwitchEntityDescription + entity_description: MelCloudHomeSwitchEntityDescription[ATWUnit] def __init__( self, coordinator: MelCloudHomeCoordinator, - entity_description: ATWSwitchEntityDescription, + entity_description: MelCloudHomeSwitchEntityDescription[ATWUnit], unit: ATWUnit, ) -> None: """Initialize the entity.""" @@ -274,7 +201,7 @@ class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity): @override async def async_turn_on(self, **kwargs: Any) -> None: """Enable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_on_fn(self.coordinator.client, self.unit), ) @@ -282,7 +209,7 @@ class ATWSwitch(MelCloudHomeATWUnitEntity, SwitchEntity): @override async def async_turn_off(self, **kwargs: Any) -> None: """Disable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_off_fn(self.coordinator.client, self.unit), ) diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index f4025bf10079..43e32e55d571 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -16,7 +16,10 @@ from .coordinator import ( mikrotik_config_entry_errors, ) -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [ + Platform.DEVICE_TRACKER, + Platform.SENSOR, +] def _call_api(data: dict[str, Any]) -> Api: @@ -43,7 +46,7 @@ async def async_setup_entry( device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(DOMAIN, coordinator.serial_num)}, + identifiers={(DOMAIN, coordinator.serial_num)}, manufacturer=ATTR_MANUFACTURER, model=coordinator.model, name=coordinator.hostname, diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 06d14250756e..c710f66a14e3 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -15,36 +15,39 @@ CONF_ARP_PING: Final = "arp_ping" CONF_FORCE_DHCP: Final = "force_dhcp" CONF_DETECTION_TIME: Final = "detection_time" - NAME: Final = "name" -INFO: Final = "info" -IDENTITY: Final = "identity" -ARP: Final = "arp" +ARP: Final = "arp" CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" -WIRELESS: Final = "wireless" -WIFIWAVE2: Final = "wifiwave2" -WIFI: Final = "wifi" -IS_WIRELESS: Final = "is_wireless" +HEALTH: Final = "health" +IDENTITY: Final = "identity" +INFO: Final = "info" IS_CAPSMAN: Final = "is_capsman" -IS_WIFIWAVE2: Final = "is_wifiwave2" IS_WIFI: Final = "is_wifi" +IS_WIFIWAVE2: Final = "is_wifiwave2" +IS_WIRELESS: Final = "is_wireless" +SYSTEM: Final = "system" +WIFI: Final = "wifi" +WIFIWAVE2: Final = "wifiwave2" +WIRELESS: Final = "wireless" MIKROTIK_SERVICES: Final = { ARP: "/ip/arp/getall", CAPSMAN: "/caps-man/registration-table/getall", DHCP: "/ip/dhcp-server/lease/getall", + HEALTH: "/system/health/print", IDENTITY: "/system/identity/getall", INFO: "/system/routerboard/getall", - WIRELESS: "/interface/wireless/registration-table/getall", - WIFIWAVE2: "/interface/wifiwave2/registration-table/print", - WIFI: "/interface/wifi/registration-table/print", - IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", - IS_WIFIWAVE2: "/interface/wifiwave2/print", IS_WIFI: "/interface/wifi/print", + IS_WIFIWAVE2: "/interface/wifiwave2/print", + IS_WIRELESS: "/interface/wireless/print", + SYSTEM: "/system/resource/print", + WIFI: "/interface/wifi/registration-table/print", + WIFIWAVE2: "/interface/wifiwave2/registration-table/print", + WIRELESS: "/interface/wireless/registration-table/getall", } diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 902681670388..8b41b852e3be 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -30,6 +30,7 @@ from .const import ( DEFAULT_DETECTION_TIME, DHCP, DOMAIN, + HEALTH, IDENTITY, INFO, IS_CAPSMAN, @@ -38,6 +39,7 @@ from .const import ( IS_WIRELESS, MIKROTIK_SERVICES, NAME, + SYSTEM, WIFI, WIFIWAVE2, WIRELESS, @@ -72,6 +74,7 @@ class MikrotikData: self.model: str = "" self.firmware: str = "" self.serial_number: str = "" + self.sensors: dict[str, Any] = {} @staticmethod def load_mac(devices: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: @@ -164,6 +167,13 @@ class MikrotikData: # get new hub firmware version if updated self.firmware = self.get_info(ATTR_FIRMWARE) + self.sensors[HEALTH] = ( + self.command(MIKROTIK_SERVICES[HEALTH], suppress_errors=True) or [] + ) + self.sensors[SYSTEM] = ( + self.command(MIKROTIK_SERVICES[SYSTEM], suppress_errors=True) or [] + ) + if not device_list: return @@ -224,9 +234,7 @@ class MikrotikData: ) -> list[dict[str, Any]]: """Retrieve data from Mikrotik API.""" _LOGGER.debug("Running command %s", cmd) - with mikrotik_config_entry_errors( - suppress_errors=suppress_errors, host=self._host - ): + with mikrotik_config_entry_errors(suppress_errors=suppress_errors): if params: return list(self.api(cmd, **params)) return list(self.api(cmd)) @@ -331,7 +339,7 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api: _error = api_error if _error is not None: - _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], _error) + _LOGGER.debug("Mikrotik %s error: %s", entry[CONF_HOST], _error) if "invalid user name or password" in str(_error): raise LoginError from _error raise CannotConnect from _error diff --git a/homeassistant/components/mikrotik/entity.py b/homeassistant/components/mikrotik/entity.py new file mode 100644 index 000000000000..13573bbfb195 --- /dev/null +++ b/homeassistant/components/mikrotik/entity.py @@ -0,0 +1,43 @@ +"""Base class for Mikrotik routers entities.""" + +from yarl import URL + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MikrotikDataUpdateCoordinator + + +class MikrotikEntity[DescriptionT: EntityDescription]( + CoordinatorEntity[MikrotikDataUpdateCoordinator] +): + """Base class for Mikrotik entities.""" + + _attr_has_entity_name = True + entity_description: DescriptionT + + def __init__( + self, + coordinator: MikrotikDataUpdateCoordinator, + description: DescriptionT, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + + self._serial = coordinator.api.serial_number + self._attr_device_info = DeviceInfo( + configuration_url=URL.build( + scheme="http", + host=coordinator.host, + ), + identifiers={(DOMAIN, self._serial)}, + name=coordinator.hostname, + manufacturer="Mikrotik", + model=coordinator.model, + sw_version=coordinator.firmware, + serial_number=self._serial, + ) + self._attr_unique_id = f"{self._serial}_{description.key}" diff --git a/homeassistant/components/mikrotik/icons.json b/homeassistant/components/mikrotik/icons.json new file mode 100644 index 000000000000..73f0b262b450 --- /dev/null +++ b/homeassistant/components/mikrotik/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "cpu_load": { + "default": "mdi:chip" + }, + "disk_usage": { + "default": "mdi:harddisk" + }, + "memory_usage": { + "default": "mdi:memory" + } + } + } +} diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 3864af1f18c6..3234575d3277 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -1,7 +1,7 @@ { "domain": "mikrotik", "name": "Mikrotik", - "codeowners": ["@engrbm87"], + "codeowners": ["@engrbm87", "@chemelli74"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", "integration_type": "device", diff --git a/homeassistant/components/mikrotik/sensor.py b/homeassistant/components/mikrotik/sensor.py new file mode 100644 index 000000000000..fc3c1e48d1bf --- /dev/null +++ b/homeassistant/components/mikrotik/sensor.py @@ -0,0 +1,179 @@ +"""Support for Mikrotik routers sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Final, override + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + EntityCategory, + UnitOfElectricPotential, + UnitOfRatio, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow + +from .const import HEALTH, SYSTEM +from .coordinator import _LOGGER, MikrotikConfigEntry +from .entity import MikrotikEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class MikrotikSensorEntityDescription(SensorEntityDescription): + """Shared Mikrotik Sensors entity description.""" + + value: Callable[[dict[str, Any]], StateType | datetime] + type: str + index: int + + +def _calculate_uptime(data: dict[str, Any]) -> datetime | None: + """Calculate uptime.""" + # e.g. 1d3h39m30s + uptime_string = data["uptime"] + + total = 0 + num = 0 + + for ch in uptime_string.strip(): + if ch.isdigit(): + num = num * 10 + int(ch) + else: + if ch == "w": + total += num * (60 * 60 * 24 * 7) + elif ch == "d": + total += num * (60 * 60 * 24) + elif ch == "h": + total += num * (60 * 60) + elif ch == "m": + total += num * 60 + elif ch == "s": + total += num + else: + _LOGGER.warning("Unknown uptime format: %s", uptime_string) + return None + + num = 0 + + if num != 0: + _LOGGER.warning("Unknown uptime format: %s", uptime_string) + return None + + return utcnow() - timedelta(seconds=total) + + +SENSORS: Final = ( + MikrotikSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value=lambda _data: _data["value"], + type=HEALTH, + index=1, + ), + MikrotikSensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda _data: _data["value"], + type=HEALTH, + index=0, + ), + MikrotikSensorEntityDescription( + key="cpu-load", + translation_key="cpu_load", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + suggested_display_precision=2, + value=lambda _data: _data["cpu-load"], + type=SYSTEM, + index=0, + ), + MikrotikSensorEntityDescription( + key="memory-usage", + translation_key="memory_usage", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + suggested_display_precision=2, + value=lambda _data: ( + None + if (total := _data.get("total-memory", 0)) == 0 + else (total - _data.get("free-memory", 0)) / total * 100 + ), + type=SYSTEM, + index=0, + ), + MikrotikSensorEntityDescription( + key="disk-usage", + translation_key="disk_usage", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + suggested_display_precision=2, + value=lambda _data: ( + None + if (total := _data.get("total-hdd-space", 0)) == 0 + else (total - _data.get("free-hdd-space", 0)) / total * 100 + ), + type=SYSTEM, + index=0, + ), + MikrotikSensorEntityDescription( + key="uptime", + device_class=SensorDeviceClass.UPTIME, + value=_calculate_uptime, + type=SYSTEM, + index=0, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MikrotikConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Mikrotik sensors based on a config entry.""" + + coordinator = entry.runtime_data + + sensors_list = [ + MikrotikSensorEntity(coordinator, sensor_desc) + for sensor_desc in SENSORS + if len(coordinator.api.sensors.get(sensor_desc.type, [])) + >= (sensor_desc.index + 1) + ] + + async_add_entities(sensors_list) + + +class MikrotikSensorEntity( + MikrotikEntity[MikrotikSensorEntityDescription], SensorEntity +): + """Sensor device.""" + + entity_description: MikrotikSensorEntityDescription + + @property + @override + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + data_list = self.coordinator.api.sensors[self.entity_description.type] + data_entry = data_list[self.entity_description.index] + + return self.entity_description.value(data_entry) diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index 99a94d485f08..0f63b1e3ab70 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -30,6 +30,19 @@ } } }, + "entity": { + "sensor": { + "cpu_load": { + "name": "CPU usage" + }, + "disk_usage": { + "name": "Disk usage" + }, + "memory_usage": { + "name": "Memory usage" + } + } + }, "exceptions": { "cannot_connect": { "message": "Error connecting: {error}" diff --git a/homeassistant/components/mikrotik/utils.py b/homeassistant/components/mikrotik/utils.py index 46fe6d0beff0..4bc50d05f7c1 100644 --- a/homeassistant/components/mikrotik/utils.py +++ b/homeassistant/components/mikrotik/utils.py @@ -16,9 +16,7 @@ from .errors import CannotConnect, LoginError @contextmanager -def mikrotik_config_entry_errors( - suppress_errors: bool = False, host: str | None = None -) -> Generator[None]: +def mikrotik_config_entry_errors(suppress_errors: bool = False) -> Generator[None]: """Handle common Mikrotik API exceptions as ConfigEntry errors.""" try: yield diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index cc1f4b437012..972237a6b6ea 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -16,12 +16,12 @@ 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, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -325,11 +325,11 @@ class MinMaxSensor(SensorEntity): if self._unit_of_measurement is None: self._unit_of_measurement = new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ) if self._unit_of_measurement != new_state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT + EntityStateAttribute.UNIT_OF_MEASUREMENT ): _LOGGER.warning( "Units of measurement do not match for entity %s", self.entity_id diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json index c93ca805da6a..c4935aa408cb 100644 --- a/homeassistant/components/mitsubishi_comfort/manifest.json +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["mitsubishi-comfort==0.3.2"] + "requirements": ["mitsubishi-comfort==0.5.0"] } diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 189f8d95bf4a..ce6d1774bfc2 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -13,6 +13,7 @@ from homeassistant.components.device_tracker import ( ATTR_IN_ZONES, ATTR_LOCATION_NAME, TrackerEntity, + TrackerEntityStateAttribute, ) from homeassistant.components.zone import ( DOMAIN as ZONE_DOMAIN, @@ -24,9 +25,8 @@ from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, STATE_HOME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -221,8 +221,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): attr = state.attributes data = { - ATTR_GPS: (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), - ATTR_GPS_ACCURACY: attr.get(ATTR_GPS_ACCURACY), + ATTR_GPS: ( + attr.get(EntityStateAttribute.LATITUDE), + attr.get(EntityStateAttribute.LONGITUDE), + ), + ATTR_GPS_ACCURACY: attr.get(TrackerEntityStateAttribute.GPS_ACCURACY), ATTR_BATTERY: attr.get(ATTR_BATTERY_LEVEL), } data.update({key: attr[key] for key in attr if key in ATTR_KEYS}) diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 4e89b7202fc9..b5798b0db03c 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -5,11 +5,11 @@ from typing import override from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import State, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -93,8 +93,10 @@ class MobileAppEntity(RestoreEntity): **last_state.attributes, **self._config[ATTR_SENSOR_ATTRIBUTES], } - if ATTR_ICON in last_state.attributes: - config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON] + if EntityStateAttribute.ICON in last_state.attributes: + config[ATTR_SENSOR_ICON] = last_state.attributes[ + EntityStateAttribute.ICON + ] @property @override diff --git a/homeassistant/components/mobile_app/logbook.py b/homeassistant/components/mobile_app/logbook.py index 8a36eaabd530..1a3e4827eb7a 100644 --- a/homeassistant/components/mobile_app/logbook.py +++ b/homeassistant/components/mobile_app/logbook.py @@ -9,7 +9,7 @@ from homeassistant.components.logbook import ( LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME, ) -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.const import EntityStateAttribute from homeassistant.core import Event, HomeAssistant, callback from homeassistant.util.event_type import EventType @@ -46,8 +46,8 @@ def async_describe_events( zone_name = None zone_icon = None if zone_entity_id and (zone_state := hass.states.get(zone_entity_id)): - zone_name = zone_state.attributes.get(ATTR_FRIENDLY_NAME) - zone_icon = zone_state.attributes.get(ATTR_ICON) + zone_name = zone_state.attributes.get(EntityStateAttribute.FRIENDLY_NAME) + zone_icon = zone_state.attributes.get(EntityStateAttribute.ICON) description = { LOGBOOK_ENTRY_NAME: source_device_name, LOGBOOK_ENTRY_MESSAGE: f"{event_description} {zone_name or zone_entity_id}", diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 0121f5558ecc..c2186f772db6 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -35,11 +35,11 @@ from homeassistant.const import ( ATTR_MODEL, ATTR_SERVICE, ATTR_SERVICE_DATA, - ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID, CONF_WEBHOOK_ID, EntityCategory, + EntityStateAttribute, ) from homeassistant.core import EventOrigin, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError @@ -352,7 +352,10 @@ async def webhook_stream_camera( "mjpeg_path": f"/api/camera_proxy_stream/{camera_state.entity_id}" } - if camera_state.attributes[ATTR_SUPPORTED_FEATURES] & CameraEntityFeature.STREAM: + if ( + camera_state.attributes[EntityStateAttribute.SUPPORTED_FEATURES] + & CameraEntityFeature.STREAM + ): try: resp["hls_path"] = await camera.async_request_stream( hass, camera_state.entity_id, "hls" diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 402bf30d7a54..8d2590d442fa 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -21,6 +21,7 @@ from homeassistant.components.climate import ( SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACAction, HVACMode, ) @@ -314,8 +315,10 @@ class ModbusThermostat(ModbusStructEntity, RestoreEntity, ClimateEntity): """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() - if state and state.attributes.get(ATTR_TEMPERATURE): - self._attr_target_temperature = float(state.attributes[ATTR_TEMPERATURE]) + if state and state.attributes.get(ClimateEntityStateAttribute.TEMPERATURE): + self._attr_target_temperature = float( + state.attributes[ClimateEntityStateAttribute.TEMPERATURE] + ) @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index a829e6432e2f..39c1c60754af 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -7,6 +7,7 @@ from homeassistant.components.light import ( ATTR_COLOR_TEMP_KELVIN, ColorMode, LightEntity, + LightEntityStateAttribute, ) from homeassistant.const import CONF_LIGHTS, CONF_NAME from homeassistant.core import HomeAssistant @@ -79,10 +80,16 @@ class ModbusLight(ModbusToggleEntity, LightEntity): if (state := await self.async_get_last_state()) is None: return - if (brightness := state.attributes.get(ATTR_BRIGHTNESS)) is not None: + if ( + brightness := state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) + ) is not None: self._attr_brightness = brightness - if (color_temp := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) is not None: + if ( + color_temp := state.attributes.get( + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ) + ) is not None: self._attr_color_temp_kelvin = color_temp @staticmethod diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 7709841a3b96..30945c8a13df 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,6 +1,6 @@ { "domain": "modbus", - "name": "Manual Modbus", + "name": "Modbus", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 08d29cc9aafb..d0d78d726e05 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -90,6 +90,5 @@ }, "name": "Write register" } - }, - "title": "Manual Modbus" + } } diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index d60b5f0c696d..77bbb507849f 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -37,7 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to the humidity sensor's device. async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 47e0ceb56bed..5dae594c371e 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -16,12 +16,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIQUE_ID, PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfTemperature, ) from homeassistant.core import ( @@ -298,7 +298,9 @@ class MoldIndicator(SensorEntity): ) return None - return validator(value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + return validator( + value, state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ) def _get_temperature_from_state(self, state: State | None) -> float | None: """Get temperature value in Celsius from state.""" diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index a0d4d714bc8c..df2d01d96b70 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -68,9 +68,8 @@ async def validate_login( LOGGER.debug("Attempting to authenticate with MFA code") try: await monarch_client.multi_factor_authenticate(email, password, mfa_code) - except KeyError as err: - # A bug in the backing lib that I don't control - # throws a KeyError if the MFA code is wrong + except (KeyError, RequireMFAException, LoginFailedException) as err: + # Backing library MFA failures can surface as a KeyError or auth error. LOGGER.debug("Bad MFA Code") raise BadMFA from err else: diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json index 7ada72badb1a..015418de814e 100644 --- a/homeassistant/components/monarch_money/manifest.json +++ b/homeassistant/components/monarch_money/manifest.json @@ -1,10 +1,10 @@ { "domain": "monarch_money", "name": "Monarch Money", - "codeowners": ["@jeeftor"], + "codeowners": ["@jeeftor", "@bradleyseanf"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/monarchmoney", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["typedmonarchmoney==0.7.0"] + "requirements": ["monarchmoneycommunity==1.5.1"] } diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py index 7ee75fa67a9f..81749cca18c5 100644 --- a/homeassistant/components/monoprice/config_flow.py +++ b/homeassistant/components/monoprice/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import SerialPortSelector from homeassistant.helpers.typing import VolDictType from .const import ( @@ -42,7 +43,9 @@ SOURCES = [ OPTIONS_FOR_DATA: VolDictType = {vol.Optional(source): str for source in SOURCES} -DATA_SCHEMA = vol.Schema({vol.Required(CONF_PORT): str, **OPTIONS_FOR_DATA}) +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_PORT): SerialPortSelector(), **OPTIONS_FOR_DATA} +) @callback diff --git a/homeassistant/components/monoprice/manifest.json b/homeassistant/components/monoprice/manifest.json index 7f4631f2aeef..25854086371b 100644 --- a/homeassistant/components/monoprice/manifest.json +++ b/homeassistant/components/monoprice/manifest.json @@ -3,6 +3,7 @@ "name": "Monoprice 6-Zone Amplifier", "codeowners": ["@etsinko", "@OnFreund"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/monoprice", "integration_type": "hub", "iot_class": "local_polling", diff --git a/homeassistant/components/moon/const.py b/homeassistant/components/moon/const.py index 3e926b4ff3e8..f51f80431804 100644 --- a/homeassistant/components/moon/const.py +++ b/homeassistant/components/moon/const.py @@ -8,3 +8,5 @@ DOMAIN: Final = "moon" PLATFORMS: Final = [Platform.SENSOR] DEFAULT_NAME: Final = "Moon" + +CONF_PHASE: Final = "phase" diff --git a/homeassistant/components/moon/helpers.py b/homeassistant/components/moon/helpers.py new file mode 100644 index 000000000000..dbf3b7907b3d --- /dev/null +++ b/homeassistant/components/moon/helpers.py @@ -0,0 +1,48 @@ +"""Helpers for moon phases.""" + +from astral import moon + +from homeassistant.core import callback +from homeassistant.util import dt as dt_util + +STATE_FIRST_QUARTER = "first_quarter" +STATE_FULL_MOON = "full_moon" +STATE_LAST_QUARTER = "last_quarter" +STATE_NEW_MOON = "new_moon" +STATE_WANING_CRESCENT = "waning_crescent" +STATE_WANING_GIBBOUS = "waning_gibbous" +STATE_WAXING_CRESCENT = "waxing_crescent" +STATE_WAXING_GIBBOUS = "waxing_gibbous" + +# The eight moon phases in chronological order (new moon to waning crescent). +MOON_PHASES: tuple[str, ...] = ( + STATE_NEW_MOON, + STATE_WAXING_CRESCENT, + STATE_FIRST_QUARTER, + STATE_WAXING_GIBBOUS, + STATE_FULL_MOON, + STATE_WANING_GIBBOUS, + STATE_LAST_QUARTER, + STATE_WANING_CRESCENT, +) + + +@callback +def moon_phase() -> str: + """Return the current moon phase.""" + value: float = moon.phase(dt_util.now().date()) + if value < 0.5 or value > 27.5: + return STATE_NEW_MOON + if value < 6.5: + return STATE_WAXING_CRESCENT + if value < 7.5: + return STATE_FIRST_QUARTER + if value < 13.5: + return STATE_WAXING_GIBBOUS + if value < 14.5: + return STATE_FULL_MOON + if value < 20.5: + return STATE_WANING_GIBBOUS + if value < 21.5: + return STATE_LAST_QUARTER + return STATE_WANING_CRESCENT diff --git a/homeassistant/components/moon/icons.json b/homeassistant/components/moon/icons.json index 77c578c8f0d8..288925f28be3 100644 --- a/homeassistant/components/moon/icons.json +++ b/homeassistant/components/moon/icons.json @@ -15,5 +15,10 @@ } } } + }, + "triggers": { + "phase_changed": { + "trigger": "mdi:moon-waning-crescent" + } } } diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 3f7f25eb8149..c20a0a392dc6 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -1,24 +1,13 @@ """Support for tracking the moon phases.""" -from astral import moon - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util from .const import DOMAIN - -STATE_FIRST_QUARTER = "first_quarter" -STATE_FULL_MOON = "full_moon" -STATE_LAST_QUARTER = "last_quarter" -STATE_NEW_MOON = "new_moon" -STATE_WANING_CRESCENT = "waning_crescent" -STATE_WANING_GIBBOUS = "waning_gibbous" -STATE_WAXING_CRESCENT = "waxing_crescent" -STATE_WAXING_GIBBOUS = "waxing_gibbous" +from .helpers import MOON_PHASES, moon_phase async def async_setup_entry( @@ -35,16 +24,7 @@ class MoonSensorEntity(SensorEntity): _attr_has_entity_name = True _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [ - STATE_NEW_MOON, - STATE_WAXING_CRESCENT, - STATE_FIRST_QUARTER, - STATE_WAXING_GIBBOUS, - STATE_FULL_MOON, - STATE_WANING_GIBBOUS, - STATE_LAST_QUARTER, - STATE_WANING_CRESCENT, - ] + _attr_options = list(MOON_PHASES) _attr_translation_key = "phase" def __init__(self, entry: ConfigEntry) -> None: @@ -58,22 +38,4 @@ class MoonSensorEntity(SensorEntity): async def async_update(self) -> None: """Get the time and updates the states.""" - today = dt_util.now().date() - state = moon.phase(today) - - if state < 0.5 or state > 27.5: - self._attr_native_value = STATE_NEW_MOON - elif state < 6.5: - self._attr_native_value = STATE_WAXING_CRESCENT - elif state < 7.5: - self._attr_native_value = STATE_FIRST_QUARTER - elif state < 13.5: - self._attr_native_value = STATE_WAXING_GIBBOUS - elif state < 14.5: - self._attr_native_value = STATE_FULL_MOON - elif state < 20.5: - self._attr_native_value = STATE_WANING_GIBBOUS - elif state < 21.5: - self._attr_native_value = STATE_LAST_QUARTER - else: - self._attr_native_value = STATE_WANING_CRESCENT + self._attr_native_value = moon_phase() diff --git a/homeassistant/components/moon/strings.json b/homeassistant/components/moon/strings.json index 8048f344c7b1..65baaed8766a 100644 --- a/homeassistant/components/moon/strings.json +++ b/homeassistant/components/moon/strings.json @@ -37,5 +37,32 @@ } } }, - "title": "Moon" + "selector": { + "phase": { + "options": { + "any": "Any", + "first_quarter": "[%key:component::moon::entity::sensor::phase::state::first_quarter%]", + "full_moon": "[%key:component::moon::entity::sensor::phase::state::full_moon%]", + "last_quarter": "[%key:component::moon::entity::sensor::phase::state::last_quarter%]", + "new_moon": "[%key:component::moon::entity::sensor::phase::state::new_moon%]", + "waning_crescent": "[%key:component::moon::entity::sensor::phase::state::waning_crescent%]", + "waning_gibbous": "[%key:component::moon::entity::sensor::phase::state::waning_gibbous%]", + "waxing_crescent": "[%key:component::moon::entity::sensor::phase::state::waxing_crescent%]", + "waxing_gibbous": "[%key:component::moon::entity::sensor::phase::state::waxing_gibbous%]" + } + } + }, + "title": "Moon", + "triggers": { + "phase_changed": { + "description": "Triggers when the moon enters a new phase.", + "fields": { + "phase": { + "description": "Limit the trigger to a specific moon phase, or leave as Any to trigger on every phase change.", + "name": "Phase" + } + }, + "name": "Moon phase changed" + } + } } diff --git a/homeassistant/components/moon/trigger.py b/homeassistant/components/moon/trigger.py new file mode 100644 index 000000000000..174436020f9a --- /dev/null +++ b/homeassistant/components/moon/trigger.py @@ -0,0 +1,88 @@ +"""Provides triggers for the moon.""" + +from datetime import datetime +from typing import cast, override + +import voluptuous as vol + +from homeassistant.const import CONF_OPTIONS +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_change +from homeassistant.helpers.trigger import ( + Trigger, + TriggerActionRunner, + TriggerConfig, + TriggerNotTriggeredReporter, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_PHASE +from .helpers import MOON_PHASES, moon_phase + +PHASE_ANY = "any" + +_PHASE_CHANGED_TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_OPTIONS, default=dict): { + vol.Optional(CONF_PHASE, default=PHASE_ANY): vol.In( + [PHASE_ANY, *MOON_PHASES] + ), + } + } +) + + +class MoonPhaseChangedTrigger(Trigger): + """Trigger that fires when the moon enters a new phase.""" + + @override + @classmethod + async def async_validate_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return cast(ConfigType, _PHASE_CHANGED_TRIGGER_SCHEMA(config)) + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize the trigger.""" + super().__init__(hass, config) + options = config.options or {} + self._phase: str = options[CONF_PHASE] + + @override + async def async_attach_runner( + self, + run_action: TriggerActionRunner, + did_not_trigger: TriggerNotTriggeredReporter | None = None, + ) -> CALLBACK_TYPE: + """Attach the trigger to an action runner.""" + last_phase = moon_phase() + + @callback + def check_phase(_now: datetime) -> None: + nonlocal last_phase + current_phase = moon_phase() + if current_phase == last_phase: + return + previous_phase = last_phase + last_phase = current_phase + if self._phase in (PHASE_ANY, current_phase): + run_action( + {"phase": current_phase, "previous_phase": previous_phase}, + "moon phase changed", + ) + + # The binned phase can only change when the local date rolls over. + return async_track_time_change( + self._hass, check_phase, hour=0, minute=0, second=0 + ) + + +TRIGGERS: dict[str, type[Trigger]] = { + "phase_changed": MoonPhaseChangedTrigger, +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for the moon.""" + return TRIGGERS diff --git a/homeassistant/components/moon/triggers.yaml b/homeassistant/components/moon/triggers.yaml new file mode 100644 index 000000000000..7a6457d452e3 --- /dev/null +++ b/homeassistant/components/moon/triggers.yaml @@ -0,0 +1,18 @@ +phase_changed: + fields: + phase: + required: true + default: any + selector: + select: + translation_key: phase + options: + - any + - new_moon + - waxing_crescent + - first_quarter + - waxing_gibbous + - full_moon + - waning_gibbous + - last_quarter + - waning_crescent diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b67949b3c01d..f40d2d5fc04a 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -133,7 +133,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): self._expiration_trigger() self._expiration_trigger = None self._expired = False - await MqttEntity.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() @staticmethod @override diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 0efba71bbf72..8cf181e7699d 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -7,16 +7,18 @@ from typing import TYPE_CHECKING, Any, override import voluptuous as vol from homeassistant.components import device_tracker -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import ( + SourceType, + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_HOME, STATE_NOT_HOME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -162,16 +164,18 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): ) -> None: """Extract the location from the extra state attributes.""" if ( - ATTR_LATITUDE in extra_state_attributes - or ATTR_LONGITUDE in extra_state_attributes + EntityStateAttribute.LATITUDE in extra_state_attributes + or EntityStateAttribute.LONGITUDE in extra_state_attributes ): latitude: float | None longitude: float | None gps_accuracy: float if isinstance( - latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float) + latitude := extra_state_attributes.get(EntityStateAttribute.LATITUDE), + (int, float), ) and isinstance( - longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float) + longitude := extra_state_attributes.get(EntityStateAttribute.LONGITUDE), + (int, float), ): self._attr_latitude = latitude self._attr_longitude = longitude @@ -187,9 +191,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): extra_state_attributes, ) - if ATTR_GPS_ACCURACY in extra_state_attributes: + if TrackerEntityStateAttribute.GPS_ACCURACY in extra_state_attributes: if isinstance( - gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY], + gps_accuracy := extra_state_attributes[ + TrackerEntityStateAttribute.GPS_ACCURACY + ], (int, float), ): self._attr_location_accuracy = gps_accuracy @@ -210,5 +216,10 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): self._attr_extra_state_attributes = { attribute: value for attribute, value in extra_state_attributes.items() - if attribute not in {ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE} + if attribute + not in { + TrackerEntityStateAttribute.GPS_ACCURACY, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, + } } diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 68d4b2fb9c7c..5ab4861201f4 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -5,12 +5,7 @@ from typing import Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_PASSWORD, - CONF_USERNAME, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry @@ -18,7 +13,10 @@ from homeassistant.helpers.device_registry import DeviceEntry from . import debug_info, is_connected REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} -REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} +REDACT_STATE_DEVICE_TRACKER = { + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, +} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index f50790616a07..65edaf103317 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -557,6 +557,7 @@ class MqttAttributesMixin(Entity): self._attributes_sub_state = async_unsubscribe_topics( self.hass, self._attributes_sub_state ) + await super().async_will_remove_from_hass() @callback def _attributes_message_received(self, msg: ReceiveMessage) -> None: @@ -708,6 +709,7 @@ class MqttAvailabilityMixin(Entity): self._availability_sub_state = async_unsubscribe_topics( self.hass, self._availability_sub_state ) + await super().async_will_remove_from_hass() @property @override @@ -1253,6 +1255,7 @@ class MqttDiscoveryUpdateMixin(Entity): async def async_will_remove_from_hass(self) -> None: """Stop listening to signal and cleanup discovery data.""" self._cleanup_discovery_on_remove() + await super().async_will_remove_from_hass() def _cleanup_discovery_on_remove(self) -> None: """Stop listening to signal and cleanup discovery data.""" @@ -1575,9 +1578,7 @@ class MqttEntity( self._sub_state = subscription.async_unsubscribe_topics( self.hass, self._sub_state ) - await MqttAttributesMixin.async_will_remove_from_hass(self) - await MqttAvailabilityMixin.async_will_remove_from_hass(self) - await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() debug_info.remove_entity_data(self.hass, self.entity_id) async def async_publish_with_config( diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index f9958db74253..80766f89499b 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -19,6 +19,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + LightEntityStateAttribute, filter_supported_color_modes, ) from homeassistant.const import ( @@ -361,17 +362,21 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): last_state = await self.async_get_last_state() if self._optimistic and last_state: self._attr_is_on = last_state.state == STATE_ON - if last_state.attributes.get(ATTR_BRIGHTNESS): - self._attr_brightness = last_state.attributes.get(ATTR_BRIGHTNESS) - if last_state.attributes.get(ATTR_HS_COLOR): - self._attr_hs_color = last_state.attributes.get(ATTR_HS_COLOR) + if brightness := last_state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ): + self._attr_brightness = brightness + if hs_color := last_state.attributes.get( + LightEntityStateAttribute.HS_COLOR + ): + self._attr_hs_color = hs_color self._update_color_mode() - if last_state.attributes.get(ATTR_COLOR_TEMP_KELVIN): - self._attr_color_temp_kelvin = last_state.attributes.get( - ATTR_COLOR_TEMP_KELVIN - ) - if last_state.attributes.get(ATTR_EFFECT): - self._attr_effect = last_state.attributes.get(ATTR_EFFECT) + if color_temp_kelvin := last_state.attributes.get( + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ): + self._attr_color_temp_kelvin = color_temp_kelvin + if effect := last_state.attributes.get(LightEntityStateAttribute.EFFECT): + self._attr_effect = effect @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 8c6879fa534f..bab9483b3767 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -246,7 +246,7 @@ class MqttSensor(MqttEntity, RestoreSensor): self._expiration_trigger() self._expiration_trigger = None self._expired = False - await MqttEntity.async_will_remove_from_hass(self) + await super().async_will_remove_from_hass() @staticmethod @override diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index a4c05b8843bb..30604aa4e3a2 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -329,7 +329,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): self.add_subscription( CONF_STATE_TOPIC, self._state_message_received, - {"_attr_battery_level", "_attr_fan_speed", "_attr_activity"}, + {"_attr_fan_speed", "_attr_activity"}, ) @override diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index f11d73a6af63..17f714a45ec2 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -248,9 +248,7 @@ async def async_setup_entry( # noqa: C901 for device in dev_entries: for identifier in device.identifiers: if identifier[0] == DOMAIN and identifier[1] not in player_ids: - dev_reg.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) + dev_reg.async_remove_device(device.id) return True diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index 9ee4117b1e61..12d2fe496885 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -44,3 +44,21 @@ def get_music_assistant_client( if entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError("Entry not loaded") return entry.runtime_data.mass + + +async def async_resolve_mass_username( + hass: HomeAssistant, user_id: str, available_usernames: list[str] +) -> str | None: + """Resolve the Music Assistant username for the Home Assistant user.""" + if (user := await hass.auth.async_get_user(user_id)) is None: + return None + for cred in user.credentials: + if cred.auth_provider_type == "homeassistant": + username: str = cred.data["username"] + break + else: + return None + username = username.strip().lower() + if username in available_usernames: + return username + return None diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 121273348a5b..9baa8ea1735f 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["music_assistant"], "quality_scale": "bronze", - "requirements": ["music-assistant-client==1.3.6"], + "requirements": ["music-assistant-client==1.4.3"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index cdf843333004..74d1db426191 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -61,7 +61,7 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity -from .helpers import catch_musicassistant_error +from .helpers import async_resolve_mass_username, catch_musicassistant_error from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -460,22 +460,28 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): username: str | None = None, ) -> None: """Send the play_media command to the media player.""" - # verify username availability - if username is not None: - users = await self.mass.auth.list_users() + # An explicit username is validated strictly; when omitted we default to + # the Home Assistant user that made the call (best-effort, never raises). + user_id = self._context.user_id if self._context is not None else None + if username is not None or user_id is not None: available_usernames = [ user.username - for user in users + for user in await self.mass.auth.list_users() if user.enabled and user.role != UserRole.GUEST ] - if username not in available_usernames: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_username", - translation_placeholders={ - "username": username, - "available_usernames": ", ".join(available_usernames), - }, + if username is not None: + if username not in available_usernames: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_username", + translation_placeholders={ + "username": username, + "available_usernames": ", ".join(available_usernames), + }, + ) + elif user_id is not None: + username = await async_resolve_mass_username( + self.hass, user_id, available_usernames ) media_uris: list[str] = [] diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index dbb65667e6d0..722e272741d3 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -426,7 +426,7 @@ "name": "Enable radio mode" }, "username": { - "description": "Music Assistant username used for the play media request. This affects the playlog entry and will also take provider filters of a media item into account, if they are configured.", + "description": "Music Assistant username used for the play media request. This affects the playlog entry and will also take provider filters of a media item into account, if they are configured. Defaults to the Home Assistant user that made the request, when the username matches a Music Assistant user.", "name": "Username" } }, diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 699c621a09fc..0cc545cf2711 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -63,8 +63,7 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): """Representation of a Neato Connected Vacuum.""" _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.PAUSE + VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.STOP | VacuumEntityFeature.START @@ -172,8 +171,6 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): self._attr_activity = VacuumActivity.ERROR self._status_state = ERRORS.get(self._state["error"]) - self._attr_battery_level = self._state["details"]["charge"] - if self._mapdata is None or not self._mapdata.get(self._robot_serial, {}).get( "maps", [] ): diff --git a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py index 7061bdc83f60..6d6b2a7c1e98 100644 --- a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -109,6 +109,7 @@ class NSBinarySensor(CoordinatorEntity[NSDataUpdateCoordinator], BinarySensorEnt name=coordinator.name, manufacturer=INTEGRATION_TITLE, model=ROUTE_MODEL, + entry_type=DeviceEntryType.SERVICE, ) @property diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index c88ef824aa11..7eef35544195 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +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 @@ -202,6 +202,7 @@ class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity): name=coordinator.name, manufacturer=INTEGRATION_TITLE, model=ROUTE_MODEL, + entry_type=DeviceEntryType.SERVICE, ) @property diff --git a/homeassistant/components/neopool/__init__.py b/homeassistant/components/neopool/__init__.py new file mode 100644 index 000000000000..166f04f02d20 --- /dev/null +++ b/homeassistant/components/neopool/__init__.py @@ -0,0 +1,28 @@ +"""NeoPool integration for Home Assistant.""" + +from neopool_modbus import NeoPoolModbusClient + +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS +from .coordinator import NeoPoolConfigEntry, NeoPoolCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: NeoPoolConfigEntry) -> bool: + """Set up the NeoPool integration from a config entry.""" + client = NeoPoolModbusClient(entry.data) + coordinator = NeoPoolCoordinator(hass, client, 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: NeoPoolConfigEntry) -> bool: + """Unload a NeoPool config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + await entry.runtime_data.client.close() + return unload_ok diff --git a/homeassistant/components/neopool/config_flow.py b/homeassistant/components/neopool/config_flow.py new file mode 100644 index 000000000000..13a4c547f150 --- /dev/null +++ b/homeassistant/components/neopool/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for the NeoPool integration.""" + +from typing import Any, override + +from neopool_modbus import async_probe_serial +from neopool_modbus.exceptions import ( + NeoPoolConnectionError, + NeoPoolModbusError, + NeoPoolTimeoutError, +) +from neopool_modbus.registers import DEFAULT_MODBUS_FRAMER +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import CURRENT_VERSION, DEFAULT_PORT, DEFAULT_UNIT_ID, DOMAIN + + +async def _async_probe(user_input: dict[str, Any]) -> tuple[str | None, str | None]: + """Probe a device using user-supplied connection parameters.""" + try: + serial = await async_probe_serial( + user_input[CONF_HOST], + port=user_input[CONF_PORT], + unit_id=user_input["unit_id"], + framer=user_input["modbus_framer"], + ) + except NeoPoolConnectionError, NeoPoolTimeoutError: + return None, "cannot_connect" + except NeoPoolModbusError: + return None, "cannot_read_modbus" + return serial, None + + +class NeoPoolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for NeoPool.""" + + VERSION = CURRENT_VERSION + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step of the configuration flow.""" + data_schema = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + vol.Optional("unit_id", default=DEFAULT_UNIT_ID): vol.Coerce(int), + vol.Optional( + "modbus_framer", + default=DEFAULT_MODBUS_FRAMER, + ): vol.In(("tcp", "rtu")), + } + ) + errors: dict[str, str] = {} + if user_input is not None: + serial, error_key = await _async_probe(user_input) + if error_key: + errors[CONF_HOST] = error_key + else: + assert serial is not None + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/neopool/const.py b/homeassistant/components/neopool/const.py new file mode 100644 index 000000000000..0307581f2da2 --- /dev/null +++ b/homeassistant/components/neopool/const.py @@ -0,0 +1,14 @@ +"""Constants for the NeoPool integration.""" + +from homeassistant.const import Platform + +DOMAIN = "neopool" +NAME = "NeoPool" + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +DEFAULT_SCAN_INTERVAL = 20 # in seconds +DEFAULT_PORT = 502 +DEFAULT_UNIT_ID = 1 + +CURRENT_VERSION = 6 diff --git a/homeassistant/components/neopool/coordinator.py b/homeassistant/components/neopool/coordinator.py new file mode 100644 index 000000000000..01b3d62dcf6c --- /dev/null +++ b/homeassistant/components/neopool/coordinator.py @@ -0,0 +1,100 @@ +"""Data update coordinator for the NeoPool integration.""" + +from datetime import timedelta +import logging +from typing import Any, override + +from neopool_modbus import NeoPoolModbusClient +from neopool_modbus.exceptions import NeoPoolError +from neopool_modbus.registers import MAX_RELAY_GPIO, find_corrupted_gpio_registers + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type NeoPoolConfigEntry = ConfigEntry["NeoPoolCoordinator"] + + +class NeoPoolCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for NeoPool platform.""" + + client: NeoPoolModbusClient + config_entry: NeoPoolConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: NeoPoolModbusClient, + entry: NeoPoolConfigEntry, + ) -> None: + """Initialise the NeoPool data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN} coordinator", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, + ) + self.client = client + self._corrupted_gpio_state: frozenset[tuple[str, int]] | None = None + + def _check_gpio_registers(self, data: dict[str, Any]) -> None: + """Validate GPIO register values and (re-)raise or clear the repair issue.""" + corrupted = find_corrupted_gpio_registers(data) + corrupted_state = frozenset((key, value) for key, _, value in corrupted) + + if corrupted_state == self._corrupted_gpio_state: + return + + for key, label, value in corrupted: + _LOGGER.error( + "Corrupted GPIO register %s (%s): value %d (0x%04X) is outside " + "valid range 0-%d. The pool controller may malfunction", + key, + label, + value, + value & 0xFFFF, + MAX_RELAY_GPIO, + ) + + self._corrupted_gpio_state = corrupted_state + + if corrupted: + details = "\n".join( + f"- **{label}** (`{key}`): value **{value}** (expected 0-{MAX_RELAY_GPIO})" + for key, label, value in corrupted + ) + ir.async_create_issue( + self.hass, + DOMAIN, + "corrupted_gpio", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="corrupted_gpio", + translation_placeholders={"details": details}, + ) + else: + # Clear a previously raised repair issue once the device is healthy. + ir.async_delete_issue(self.hass, DOMAIN, "corrupted_gpio") + + @override + async def _async_update_data(self) -> dict[str, Any]: + """Fetch the latest data from the pool controller.""" + try: + data = await self.client.async_read_all() + except (NeoPoolError, OSError, TimeoutError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="modbus_communication_error", + translation_placeholders={"error": str(err)}, + ) from err + + self._check_gpio_registers(data) + + return data diff --git a/homeassistant/components/neopool/entity.py b/homeassistant/components/neopool/entity.py new file mode 100644 index 000000000000..3c3fdd3e7a1c --- /dev/null +++ b/homeassistant/components/neopool/entity.py @@ -0,0 +1,36 @@ +"""Base entity class for the NeoPool integration.""" + +from typing import override + +from neopool_modbus.decoders import get_machine_name, parse_version + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, NAME +from .coordinator import NeoPoolCoordinator + + +class NeoPoolEntity(CoordinatorEntity[NeoPoolCoordinator]): + """Base class for NeoPool entities.""" + + _attr_has_entity_name = True + + @property + @override + def device_info(self) -> DeviceInfo: + """Return device information for the entity.""" + data = self.coordinator.data or {} + unique_id = self.coordinator.config_entry.unique_id + assert unique_id is not None + machine_type = (get_machine_name(data) or "").strip() + model_prefix = "NeoPool Compatible: " if machine_type else "NeoPool Compatible" + + return DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=NAME, + model=f"{model_prefix}{machine_type}".strip(), + manufacturer="Hayward (Sugar Valley)", + sw_version=f"v{parse_version(data.get('MBF_POWER_MODULE_VERSION'))} (v{parse_version(data.get('MBF_PAR_VERSION'))})", + serial_number=unique_id, + ) diff --git a/homeassistant/components/neopool/icons.json b/homeassistant/components/neopool/icons.json new file mode 100644 index 000000000000..90d561743cf1 --- /dev/null +++ b/homeassistant/components/neopool/icons.json @@ -0,0 +1,65 @@ +{ + "entity": { + "sensor": { + "filt_mode": { + "default": "mdi:water-sync", + "state": { + "auto": "mdi:water-boiler-auto", + "backwash": "mdi:water-boiler-off", + "heating": "mdi:water-boiler-alert", + "intelligent": "mdi:water-boiler-auto", + "manual": "mdi:water-boiler-alert", + "smart": "mdi:water-boiler-auto" + } + }, + "filtration_speed": { + "default": "mdi:fan" + }, + "filtvalve_remaining": { + "default": "mdi:timer-sand" + }, + "hidro_current": { + "default": "mdi:air-humidifier-off", + "range": { + "10": "mdi:air-humidifier" + } + }, + "hidro_polarity": { + "default": "mdi:plus-minus-variant" + }, + "intelligent_intervals": { + "default": "mdi:counter" + }, + "intelligent_tt_next_interval": { + "default": "mdi:timeline-clock-outline" + }, + "ion_current": { + "default": "mdi:atom" + }, + "ion_polarity": { + "default": "mdi:plus-minus-variant" + }, + "measure_cl": { + "default": "mdi:shaker-outline" + }, + "measure_rx": { + "default": "mdi:gradient-vertical" + }, + "ph_pump_status": { + "default": "mdi:pump" + }, + "ph_status_alarm": { + "default": "mdi:ph", + "state": { + "ok": "mdi:check-circle-outline", + "ph_high": "mdi:alert", + "ph_low": "mdi:alert", + "ph_over": "mdi:alert", + "ph_under": "mdi:alert", + "pump_stopped": "mdi:alert", + "tank_level": "mdi:alert" + } + } + } + } +} diff --git a/homeassistant/components/neopool/manifest.json b/homeassistant/components/neopool/manifest.json new file mode 100644 index 000000000000..9c0658e5031b --- /dev/null +++ b/homeassistant/components/neopool/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "neopool", + "name": "NeoPool", + "codeowners": ["@svasek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/neopool", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["neopool_modbus"], + "quality_scale": "silver", + "requirements": ["neopool-modbus==3.6.0"] +} diff --git a/homeassistant/components/neopool/quality_scale.yaml b/homeassistant/components/neopool/quality_scale.yaml new file mode 100644 index 000000000000..1893a01bff50 --- /dev/null +++ b/homeassistant/components/neopool/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register 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 register any service actions. + docs-conditions: + status: exempt + comment: The integration does not provide any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: The integration does not provide any triggers. + entity-event-setup: + status: exempt + comment: | + Entities use the coordinator pattern and do not subscribe to + integration-specific 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 register any service 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: done + reauthentication-flow: + status: exempt + comment: Modbus TCP has no authentication mechanism. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + Modbus TCP gateways have no standard discovery protocol + (no zeroconf, SSDP, or DHCP signal that uniquely identifies + a NeoPool controller behind the gateway). + discovery-update-info: + status: exempt + comment: See discovery exemption above. + 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: | + One config entry maps to one physical NeoPool controller; multiple + controllers are supported via separate config entries. The single + device per entry is created during initial setup and cannot change. + 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: done + stale-devices: + status: exempt + comment: | + One config entry maps to one physical device; the device is not + removed during runtime, so there are no stale devices to clean up. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Integration uses Modbus TCP, not HTTP, so no aiohttp session is involved. + strict-typing: done diff --git a/homeassistant/components/neopool/sensor.py b/homeassistant/components/neopool/sensor.py new file mode 100644 index 000000000000..830628fb73e3 --- /dev/null +++ b/homeassistant/components/neopool/sensor.py @@ -0,0 +1,380 @@ +"""Sensor platform for the NeoPool integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Any, override + +from neopool_modbus.capabilities import ( + has_filtvalve, + has_heating_relay, + has_variable_speed_pump, + is_chlorine_module_present, + is_conductivity_module_present, + is_hydrolysis_present, + is_ionization_present, + is_ph_module_present, + is_redox_module_present, + is_temperature_active, +) +from neopool_modbus.decoders import ( + FILTRATION_MODE_LABELS, + FILTRATION_SPEED_STATE_LABELS, + HIDRO_POLARITY_LABELS, + ION_POLARITY_LABELS, + PH_STATUS_ALARM_LABELS, + calculate_next_interval_time, + decode_hidro_polarity, + decode_ion_polarity, + decode_ph_alarm, + decode_ph_pump_status, + is_hydrolysis_in_percent, + ph_pump_options, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + EntityCategory, + UnitOfElectricPotential, + UnitOfRatio, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NeoPoolConfigEntry +from .coordinator import NeoPoolCoordinator +from .entity import NeoPoolEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class NeoPoolSensorEntityDescription(SensorEntityDescription): + """Describes a NeoPool sensor entity.""" + + supported_fn: Callable[[dict[str, Any]], bool] | None = None + value_fn: Callable[[dict[str, Any]], Any] | None = None + options_fn: Callable[[dict[str, Any]], list[str]] | None = None + unit_fn: Callable[[dict[str, Any]], str | None] | None = None + precision_fn: Callable[[dict[str, Any]], int | None] | None = None + + +SENSOR_DESCRIPTIONS: dict[str, NeoPoolSensorEntityDescription] = { + "MBF_ION_CURRENT": NeoPoolSensorEntityDescription( + key="MBF_ION_CURRENT", + translation_key="ion_current", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_ionization_present, + ), + "MBF_HIDRO_CURRENT": NeoPoolSensorEntityDescription( + key="MBF_HIDRO_CURRENT", + translation_key="hidro_current", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=is_hydrolysis_present, + unit_fn=lambda data: ( + UnitOfRatio.PERCENTAGE if is_hydrolysis_in_percent(data) else "g/h" + ), + precision_fn=lambda data: 0 if is_hydrolysis_in_percent(data) else 1, + ), + "MBF_MEASURE_PH": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_PH", + device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_ph_module_present, + ), + "MBF_MEASURE_RX": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_RX", + translation_key="measure_rx", + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_redox_module_present, + ), + "MBF_MEASURE_CL": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_CL", + translation_key="measure_cl", + native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_chlorine_module_present, + ), + "MBF_MEASURE_CONDUCTIVITY": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_CONDUCTIVITY", + translation_key="measure_conductivity", + native_unit_of_measurement=UnitOfRatio.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=is_conductivity_module_present, + ), + "MBF_MEASURE_TEMPERATURE": NeoPoolSensorEntityDescription( + key="MBF_MEASURE_TEMPERATURE", + translation_key="measure_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=is_temperature_active, + ), + "MBF_HIDRO_VOLTAGE": NeoPoolSensorEntityDescription( + key="MBF_HIDRO_VOLTAGE", + translation_key="hidro_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "MBF_PAR_FILT_MODE": NeoPoolSensorEntityDescription( + key="MBF_PAR_FILT_MODE", + translation_key="filt_mode", + device_class=SensorDeviceClass.ENUM, + options=list(FILTRATION_MODE_LABELS.values()), + value_fn=lambda data: data.get("filtration_mode"), + ), + "MBF_PH_STATUS_ALARM": NeoPoolSensorEntityDescription( + key="MBF_PH_STATUS_ALARM", + translation_key="ph_status_alarm", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(PH_STATUS_ALARM_LABELS.values()), + value_fn=decode_ph_alarm, + supported_fn=is_ph_module_present, + ), + "HIDRO_POLARITY": NeoPoolSensorEntityDescription( + key="HIDRO_POLARITY", + translation_key="hidro_polarity", + device_class=SensorDeviceClass.ENUM, + options=list(HIDRO_POLARITY_LABELS), + value_fn=decode_hidro_polarity, + supported_fn=is_hydrolysis_present, + ), + "ION_POLARITY": NeoPoolSensorEntityDescription( + key="ION_POLARITY", + translation_key="ion_polarity", + device_class=SensorDeviceClass.ENUM, + options=list(ION_POLARITY_LABELS), + value_fn=decode_ion_polarity, + supported_fn=is_ionization_present, + ), + "PH_PUMP_STATUS": NeoPoolSensorEntityDescription( + key="PH_PUMP_STATUS", + translation_key="ph_pump_status", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options_fn=ph_pump_options, + value_fn=decode_ph_pump_status, + supported_fn=is_ph_module_present, + ), + "FILTRATION_SPEED": NeoPoolSensorEntityDescription( + key="FILTRATION_SPEED", + translation_key="filtration_speed", + device_class=SensorDeviceClass.ENUM, + options=list(FILTRATION_SPEED_STATE_LABELS), + value_fn=lambda data: data.get("filtration_speed_state"), + supported_fn=has_variable_speed_pump, + ), + "MBF_PAR_INTELLIGENT_INTERVALS": NeoPoolSensorEntityDescription( + key="MBF_PAR_INTELLIGENT_INTERVALS", + translation_key="intelligent_intervals", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=lambda data: ( + has_heating_relay(data) and is_temperature_active(data) + ), + ), + "MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL": NeoPoolSensorEntityDescription( + key="MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL", + translation_key="intelligent_tt_next_interval", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: calculate_next_interval_time( + data.get("MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL") + ), + supported_fn=lambda data: ( + has_heating_relay(data) and is_temperature_active(data) + ), + ), + "MBF_PAR_FILTVALVE_REMAINING": NeoPoolSensorEntityDescription( + key="MBF_PAR_FILTVALVE_REMAINING", + translation_key="filtvalve_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + supported_fn=has_filtvalve, + ), + "CELL_RUNTIME_TOTAL": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_TOTAL", + translation_key="cell_runtime_total", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_PART": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_PART", + translation_key="cell_runtime_part", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POLA": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POLA", + translation_key="cell_runtime_pola", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POLB": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POLB", + translation_key="cell_runtime_polb", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), + "CELL_RUNTIME_POL_CHANGES": NeoPoolSensorEntityDescription( + key="CELL_RUNTIME_POL_CHANGES", + translation_key="cell_runtime_pol_changes", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + supported_fn=is_hydrolysis_present, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NeoPoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NeoPool sensors from a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + NeoPoolSensor(coordinator, key, desc) + for key, desc in SENSOR_DESCRIPTIONS.items() + if desc.supported_fn is None or desc.supported_fn(coordinator.data) + ) + + +_PRODUCTION_KEYS_REQUIRING_FILTRATION = frozenset( + { + "MBF_HIDRO_CURRENT", + "MBF_HIDRO_VOLTAGE", + "MBF_ION_CURRENT", + } +) + +_MEASURE_KEYS_REQUIRING_FILTRATION = frozenset( + { + "MBF_MEASURE_TEMPERATURE", + "MBF_MEASURE_PH", + "MBF_MEASURE_RX", + "MBF_MEASURE_CL", + "MBF_MEASURE_CONDUCTIVITY", + } +) + + +class NeoPoolSensor(NeoPoolEntity, SensorEntity): + """Representation of a NeoPool sensor.""" + + entity_description: NeoPoolSensorEntityDescription + + def __init__( + self, + coordinator: NeoPoolCoordinator, + key: str, + description: NeoPoolSensorEntityDescription, + ) -> None: + """Initialize the NeoPool sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._key = key + self._attr_unique_id = ( + f"{self.coordinator.config_entry.unique_id}_{key.lower()}" + ) + + @property + @override + def suggested_display_precision(self) -> int | None: + """Return the suggested display precision for the sensor value.""" + if (precision_fn := self.entity_description.precision_fn) is not None: + return precision_fn(self.coordinator.data) + return super().suggested_display_precision + + @property + @override + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement for the sensor value.""" + if (unit_fn := self.entity_description.unit_fn) is not None: + return unit_fn(self.coordinator.data) + return super().native_unit_of_measurement + + def _filtration_off(self) -> bool: + """Return True when the filtration pump is off.""" + return self.coordinator.data.get("Filtration Pump") is False + + def _is_measurement_suppressed(self) -> bool: + """Return True if a measurement sensor should report None.""" + if self._key not in _MEASURE_KEYS_REQUIRING_FILTRATION: + return False + return self._filtration_off() + + def _is_production_suppressed(self) -> bool: + """Return True if a production sensor should report 0.""" + if self._key not in _PRODUCTION_KEYS_REQUIRING_FILTRATION: + return False + return self._filtration_off() + + @property + @override + def native_value(self) -> float | int | str | datetime | None: + """Return the actual sensor value from coordinator data.""" + if self._is_measurement_suppressed(): + return None + if self._is_production_suppressed(): + return 0 + if (value_fn := self.entity_description.value_fn) is not None: + value: float | int | str | datetime | None = value_fn(self.coordinator.data) + return value + return self.coordinator.data.get(self._key) + + @property + @override + def options(self) -> list[str] | None: + """Return the list of options for the sensor.""" + if (options_fn := self.entity_description.options_fn) is not None: + return options_fn(self.coordinator.data) + return super().options diff --git a/homeassistant/components/neopool/strings.json b/homeassistant/components/neopool/strings.json new file mode 100644 index 000000000000..4f3bea3e33d8 --- /dev/null +++ b/homeassistant/components/neopool/strings.json @@ -0,0 +1,150 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_read_modbus": "Connected, but cannot read from the Modbus device. Check unit ID and framer settings." + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "modbus_framer": "Modbus framer", + "port": "[%key:common::config_flow::data::port%]", + "unit_id": "Unit ID" + }, + "data_description": { + "host": "Enter the IP address of the Modbus TCP gateway connected to your pool controller.", + "modbus_framer": "Wire protocol used by the gateway. Select TCP for MBAP framing (default for Ethernet-native gateways). Select RTU for RTU framing tunnelled through a TCP socket, used by passthrough serial-to-TCP bridges like ESPHome's stream_server.", + "port": "Standard Modbus TCP port (default: 502). Change only if your gateway uses a non-standard port.", + "unit_id": "Modbus device address (1-247). Identifies the specific device on a shared bus. Most direct-connected devices use 1. Gateways may route requests to different addresses." + }, + "description": "Configure the connection to your NeoPool controller.", + "title": "NeoPool Connection" + } + } + }, + "entity": { + "sensor": { + "cell_runtime_part": { + "name": "Cell runtime since reset" + }, + "cell_runtime_pol_changes": { + "name": "Cell polarity changes" + }, + "cell_runtime_pola": { + "name": "Cell runtime in polarity 1" + }, + "cell_runtime_polb": { + "name": "Cell runtime in polarity 2" + }, + "cell_runtime_total": { + "name": "Cell runtime total" + }, + "filt_mode": { + "name": "Filtration mode", + "state": { + "auto": "Automatic", + "backwash": "Backwash", + "heating": "Heating", + "intelligent": "Intelligent", + "manual": "[%key:common::state::manual%]", + "smart": "Smart" + } + }, + "filtration_speed": { + "name": "Current filtration speed", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "mid": "Medium", + "off": "[%key:common::state::off%]" + } + }, + "filtvalve_remaining": { + "name": "Backwash time remaining" + }, + "hidro_current": { + "name": "Hydrolysis intensity" + }, + "hidro_polarity": { + "name": "Hydrolysis polarity", + "state": { + "dead_time": "Dead time", + "no_flow": "No flow", + "off": "[%key:common::state::off%]", + "pol1": "Polarity 1", + "pol2": "Polarity 2" + } + }, + "hidro_voltage": { + "name": "Hydrolysis voltage" + }, + "intelligent_intervals": { + "name": "Intelligent mode intervals" + }, + "intelligent_tt_next_interval": { + "name": "Intelligent mode next interval start" + }, + "ion_current": { + "name": "Ionization level" + }, + "ion_polarity": { + "name": "Ionizer polarity", + "state": { + "dead_time": "Dead time", + "off": "[%key:common::state::off%]", + "pol1": "Polarity 1", + "pol2": "Polarity 2" + } + }, + "measure_cl": { + "name": "Salt level" + }, + "measure_conductivity": { + "name": "Conductivity level" + }, + "measure_rx": { + "name": "Redox potential" + }, + "measure_temperature": { + "name": "Water temperature" + }, + "ph_pump_status": { + "name": "pH pump status", + "state": { + "acid": "Acid pump", + "base": "Base pump", + "both": "Both pumps", + "idle": "[%key:common::state::idle%]", + "off": "[%key:common::state::off%]" + } + }, + "ph_status_alarm": { + "name": "pH alarm", + "state": { + "ok": "OK", + "ph_high": "pH too high", + "ph_low": "pH too low", + "ph_over": "pH higher than the set point", + "ph_under": "pH lower than the set point", + "pump_stopped": "Pump stopped (exceeded working time)", + "tank_level": "Tank level alarm" + } + } + } + }, + "exceptions": { + "modbus_communication_error": { + "message": "An error occurred while communicating with the NeoPool controller: {error}" + } + }, + "issues": { + "corrupted_gpio": { + "description": "The following GPIO register(s) on your pool controller contain invalid values:\n\n{details}\n\nThis typically happens when the Modbus gateway framing mode does not match the integration's framer setting. The affected function(s) will not work correctly until the register(s) are restored to valid values.\n\nSee the integration documentation for repair instructions.", + "title": "Corrupted GPIO register(s) detected" + } + } +} diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index fec919b72372..174b8686a4aa 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -236,10 +236,7 @@ class SignalUpdateCallback: if device_id in devices: continue _LOGGER.info("Removing stale device entry '%s'", device_id) - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=self._config_entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool: diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 8289b2bf0539..183732c5ea1c 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -29,17 +29,8 @@ from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from . import api -from .const import ( - DATA_CAMERAS, - DATA_DEVICE_IDS, - DATA_EVENTS, - DATA_HOMES, - DATA_PERSONS, - DATA_SCHEDULES, - DOMAIN, - PLATFORMS, -) -from .data_handler import NetatmoConfigEntry, NetatmoDataHandler +from .const import DOMAIN, PLATFORMS +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler from .services import async_setup_services from .webhook import async_register_webhook, async_unregister_webhook @@ -52,17 +43,6 @@ MAX_WEBHOOK_RETRIES = 3 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Netatmo component.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - hass.data[DOMAIN] = { - DATA_PERSONS: {}, - DATA_DEVICE_IDS: {}, - DATA_SCHEDULES: {}, - DATA_HOMES: {}, - DATA_EVENTS: {}, - DATA_CAMERAS: {}, - } - async_setup_services(hass) return True diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index d0dc4348690a..e8bb4e15a18a 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -37,11 +37,13 @@ from .const import ( NETATMO_CREATE_OPENING_BINARY_SENSOR, NETATMO_CREATE_WEATHER_BINARY_SENSOR, ) -from .data_handler import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity, NetatmoWeatherModuleEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_OPENING_SENSOR_KEY = "opening_sensor" OPENING_STATUS_TO_BINARY_SENSOR_STATE: Final[dict[str, bool | None]] = { diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index dd0895a28157..a2f41356e1ce 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -11,12 +11,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -36,7 +38,7 @@ async def async_setup_entry( ) -class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): +class NetatmoCoverPreferredPositionButton(NetatmoReachabilityEntity, ButtonEntity): """Representation of a Netatmo cover preferred position button device.""" _attr_configuration_url = CONF_URL_CONTROL @@ -67,7 +69,7 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - # No state to update for button + self.async_write_ha_state() @override async def async_press(self) -> None: diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index b1dc17f4f5d9..9ae69c6a2435 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -1,5 +1,4 @@ """Support for the Netatmo cameras.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast, override @@ -24,8 +23,6 @@ from .const import ( CAMERA_LIGHT_MODES, CAMERA_TRIGGERS, CONF_URL_SECURITY, - DATA_CAMERAS, - DATA_EVENTS, DOMAIN, EVENT_TYPE_CONNECTION, EVENT_TYPE_DISCONNECTION, @@ -40,12 +37,14 @@ from .const import ( SERVICE_SET_PERSONS_HOME, WEBHOOK_PUSH_TYPE, ) -from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .coordinator import EVENT, HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_QUALITY = "high" @@ -137,7 +136,7 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): ) ) - self.hass.data[DOMAIN][DATA_CAMERAS][self.device.entity_id] = self.device.name + self.data_handler.cameras[self.device.entity_id] = self.device.name @callback def handle_event(self, event: dict) -> None: @@ -281,10 +280,12 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): self._attr_is_streaming = self.device.monitoring self._attr_motion_detection_enabled = self.device.monitoring - self.hass.data[DOMAIN][DATA_EVENTS][self.device.entity_id] = ( - self.process_events(self.device.events) + self.data_handler.events[self.device.entity_id] = self.process_events( + self.device.events ) + self.async_write_ha_state() + def process_events(self, event_list: list[NaEvent]) -> dict: """Add meta data to events.""" events = {} diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 936be63126f2..0b5fea41517a 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,5 +1,4 @@ """Support for Netatmo Smart thermostats.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast, override @@ -39,7 +38,6 @@ from .const import ( ATTR_SELECTED_SCHEDULE_ID, ATTR_TARGET_TEMPERATURE, ATTR_TIME_PERIOD, - DATA_SCHEDULES, DOMAIN, EVENT_TYPE_CANCEL_SET_POINT, EVENT_TYPE_SCHEDULE, @@ -52,12 +50,14 @@ from .const import ( SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom from .entity import NetatmoRoomEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + PRESET_FROST_GUARD = "frost_guard" PRESET_SCHEDULE = "schedule" PRESET_MANUAL = "manual" @@ -254,7 +254,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if data["event_type"] == EVENT_TYPE_SCHEDULE: # handle schedule change if "schedule_id" in data: - selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ + selected_schedule = self.data_handler.schedules[ self.home.entity_id ].get(data["schedule_id"]) self._selected_schedule = getattr( @@ -290,6 +290,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): elif self._attr_preset_mode in [PRESET_SCHEDULE, PRESET_HOME]: self.async_update_callback() self.data_handler.async_force_update(self._signal_name) + return self.async_write_ha_state() return @@ -325,7 +326,6 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._attr_preset_mode = PRESET_MAP_NETATMO[PRESET_SCHEDULE] self.async_update_callback() - self.async_write_ha_state() return @property @@ -414,15 +414,16 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): @override def available(self) -> bool: """If the device hasn't been able to connect, mark as unavailable.""" - return bool(self._connected) + return super().available and bool(self._connected) @callback @override def async_update_callback(self) -> None: """Update the entity's state.""" if not self.device.reachable: - if self.available: + if self._connected: self._connected = False + self.async_write_ha_state() return self._connected = True @@ -458,12 +459,12 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._boilerstatus = module.boiler_status break + self.async_write_ha_state() + async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None - for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].items(): + for sid, schedule in self.data_handler.schedules[self.home.entity_id].items(): if schedule.name == schedule_name: schedule_id = sid break diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 0883cf89d2ac..3693bb76105c 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -7,7 +7,7 @@ import uuid import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_SHOW_ON_MAP, CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv @@ -24,7 +24,7 @@ from .const import ( CONF_WEATHER_AREAS, DOMAIN, ) -from .data_handler import NetatmoConfigEntry +from .coordinator import NetatmoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -62,10 +62,6 @@ class NetatmoFlowHandler( async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: """Handle a flow start.""" await self.async_set_unique_id(DOMAIN) - - if self.source != SOURCE_REAUTH and self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - return await super().async_step_user(user_input) async def async_step_reauth( diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 41bfc09dc905..96bdb16b9201 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -71,13 +71,6 @@ CONF_WEATHER_AREAS = "weather_areas" OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize" OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token" -DATA_CAMERAS = "cameras" -DATA_DEVICE_IDS = "netatmo_device_ids" -DATA_EVENTS = "netatmo_events" -DATA_HOMES = "netatmo_homes" -DATA_PERSONS = "netatmo_persons" -DATA_SCHEDULES = "netatmo_schedules" - NETATMO_EVENT = "netatmo_event" DEFAULT_DISCOVERY = True diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/coordinator.py similarity index 91% rename from homeassistant/components/netatmo/data_handler.py rename to homeassistant/components/netatmo/coordinator.py index 8d63a2016f3d..34eb67f3a7ce 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/coordinator.py @@ -1,5 +1,4 @@ """The Netatmo data handler.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections import deque from dataclasses import dataclass @@ -15,6 +14,7 @@ from pyatmo.modules.device_types import ( DeviceCategory as NetatmoDeviceCategory, DeviceType as NetatmoDeviceType, ) +from pyatmo.schedule import Schedule from homeassistant.components import cloud from homeassistant.config_entries import ConfigEntry @@ -27,8 +27,6 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( CAMERA_CONNECTION_WEBHOOKS, - DATA_PERSONS, - DATA_SCHEDULES, DOMAIN, MANUFACTURER, NETATMO_CREATE_BUTTON, @@ -91,6 +89,12 @@ SCAN_INTERVAL = 60 type NetatmoConfigEntry = ConfigEntry[NetatmoDataHandler] +def async_get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry | None: + """Return the single loaded Netatmo config entry, if any.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + return entries[0] if entries else None + + @dataclass class NetatmoDevice: """Netatmo device class.""" @@ -131,6 +135,7 @@ class NetatmoPublisher: subscriptions: set[CALLBACK_TYPE | None] method: str kwargs: dict + available: bool = True class NetatmoDataHandler: @@ -160,6 +165,11 @@ class NetatmoDataHandler: self._rate_limit = DEV_LIMIT self.poll_start = time() self.poll_count = 0 + self.persons: dict[str, dict[str, str | None]] = {} + self.schedules: dict[str, dict[str, Schedule]] = {} + self.device_ids: dict[str, str] = {} + self.cameras: dict[str, str] = {} + self.events: dict[str, dict] = {} async def async_setup(self) -> None: """Set up the Netatmo data handler.""" @@ -245,19 +255,29 @@ class NetatmoDataHandler: **self.publisher[signal_name].kwargs ) - except (pyatmo.NoDeviceError, pyatmo.ApiError) as err: + except ( + pyatmo.NoDeviceError, + pyatmo.ApiError, + TimeoutError, + aiohttp.ClientConnectorError, + ) as err: _LOGGER.debug(err) has_error = True - except (TimeoutError, aiohttp.ClientConnectorError) as err: - _LOGGER.debug(err) - return True + self.publisher[signal_name].available = not has_error + self._notify_subscribers(signal_name) + return has_error + def _notify_subscribers(self, signal_name: str) -> None: + """Notify all subscribers of a publisher to update their state.""" for update_callback in self.publisher[signal_name].subscriptions: if update_callback: update_callback() - return has_error + def is_signal_available(self, signal_name: str) -> bool: + """Return whether the last fetch for a publisher succeeded.""" + publisher = self.publisher.get(signal_name) + return publisher is None or publisher.available async def subscribe( self, @@ -330,7 +350,7 @@ class NetatmoDataHandler: self.setup_rooms(home, signal_home) self.setup_modules(home, signal_home) - self.hass.data[DOMAIN][DATA_PERSONS][home.entity_id] = { + self.persons[home.entity_id] = { person.entity_id: person.pseudo for person in home.persons.values() } @@ -459,7 +479,7 @@ class NetatmoDataHandler: if NetatmoDeviceCategory.climate in [ next(iter(x)) for x in [room.features for room in home.rooms.values()] if x ]: - self.hass.data[DOMAIN][DATA_SCHEDULES][home.entity_id] = self.account.homes[ + self.schedules[home.entity_id] = self.account.homes[ home.entity_id ].schedules diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index ba244724f736..82c97c0c45c6 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -16,12 +16,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -41,7 +43,7 @@ async def async_setup_entry( ) -class NetatmoCover(NetatmoModuleEntity, CoverEntity): +class NetatmoCover(NetatmoReachabilityEntity, CoverEntity): """Representation of a Netatmo cover device.""" _attr_supported_features = ( @@ -103,5 +105,7 @@ class NetatmoCover(NetatmoModuleEntity, CoverEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_closed = self.device.current_position == 0 - self._attr_current_cover_position = self.device.current_position + if self.device.reachable is not False: + self._attr_is_closed = self.device.current_position == 0 + self._attr_current_cover_position = self.device.current_position + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py index a3b4f032714d..08bbd0a1ff89 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from .data_handler import ACCOUNT, NetatmoConfigEntry +from .coordinator import ACCOUNT, NetatmoConfigEntry TO_REDACT = { "access_token", diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 2b2b5ea52b8c..ae301cb06fc9 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -7,7 +7,7 @@ from pyatmo import DeviceType, Home, Module, Room from pyatmo.modules.base_class import NetatmoBase, Place from pyatmo.modules.device_types import DEVICE_DESCRIPTION_MAP -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -16,12 +16,11 @@ from homeassistant.helpers.entity import Entity from .const import ( CONF_URL_ENERGY, CONF_URL_WEATHER, - DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, SIGNAL_NAME, ) -from .data_handler import PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom +from .coordinator import PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom class NetatmoBaseEntity(Entity): @@ -36,6 +35,15 @@ class NetatmoBaseEntity(Entity): self._publishers: list[dict[str, Any]] = [] self._attr_extra_state_attributes = {} + @property + @override + def available(self) -> bool: + """Return True if the underlying data publishers are reachable.""" + return super().available and all( + self.data_handler.is_signal_available(publisher[SIGNAL_NAME]) + for publisher in self._publishers + ) + @override async def async_added_to_hass(self) -> None: """Entity created.""" @@ -141,9 +149,7 @@ class NetatmoRoomEntity(NetatmoDeviceEntity): if device := registry.async_get_device( identifiers={(DOMAIN, self.device.entity_id)} ): - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - self.hass.data[DOMAIN][DATA_DEVICE_IDS][self.device.entity_id] = device.id + self.data_handler.device_ids[self.device.entity_id] = device.id @property @override @@ -177,6 +183,16 @@ class NetatmoModuleEntity(NetatmoDeviceEntity): return self.device.device_type +class NetatmoReachabilityEntity(NetatmoModuleEntity): + """Module entity that is unavailable when its device is unreachable.""" + + @property + @override + def available(self) -> bool: + """Return True unless the device explicitly reports as unreachable.""" + return super().available and self.device.reachable is not False + + class NetatmoWeatherModuleEntity(NetatmoModuleEntity): """Netatmo weather module entity base class.""" @@ -201,8 +217,8 @@ class NetatmoWeatherModuleEntity(NetatmoModuleEntity): if hasattr(place, "location") and place.location is not None: self._attr_extra_state_attributes.update( { - ATTR_LATITUDE: place.location.latitude, - ATTR_LONGITUDE: place.location.longitude, + EntityStateAttribute.LATITUDE: place.location.latitude, + EntityStateAttribute.LONGITUDE: place.location.longitude, } ) diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 3dacf5b3fe5f..0e4a4eb828a3 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -11,12 +11,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + DEFAULT_PERCENTAGE: Final = 50 PRESET_MAPPING = {"slow": 1, "fast": 2} @@ -41,7 +43,7 @@ async def async_setup_entry( ) -class NetatmoFan(NetatmoModuleEntity, FanEntity): +class NetatmoFan(NetatmoReachabilityEntity, FanEntity): """Representation of a Netatmo fan.""" _attr_preset_modes = ["slow", "fast"] @@ -76,7 +78,9 @@ class NetatmoFan(NetatmoModuleEntity, FanEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if self.device.fan_speed is None: - self._attr_preset_mode = None - return - self._attr_preset_mode = PRESETS.get(self.device.fan_speed) + if self.device.reachable is not False: + if self.device.fan_speed is None: + self._attr_preset_mode = None + else: + self._attr_preset_mode = PRESETS.get(self.device.fan_speed) + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 3bbc6f320c9e..2e84133e1707 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -19,11 +19,13 @@ from .const import ( NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_LIGHT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .entity import NetatmoModuleEntity, NetatmoReachabilityEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -122,7 +124,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity): @override def available(self) -> bool: """If the webhook is not established, mark as unavailable.""" - return bool(self.data_handler.webhook) + return super().available and bool(self.data_handler.webhook) @override async def async_turn_on(self, **kwargs: Any) -> None: @@ -141,9 +143,10 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity): def async_update_callback(self) -> None: """Update the entity's state.""" self._attr_is_on = bool(self.device.floodlight == "on") + self.async_write_ha_state() -class NetatmoLight(NetatmoModuleEntity, LightEntity): +class NetatmoLight(NetatmoReachabilityEntity, LightEntity): """Representation of a dimmable light by Legrand/BTicino.""" _attr_name = None @@ -198,10 +201,12 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_on = self.device.on is True + if self.device.reachable is not False: + self._attr_is_on = self.device.on is True - if (brightness := self.device.brightness) is not None: - # Netatmo uses a range of [0, 100] to control brightness - self._attr_brightness = round(brightness * 2.55) - else: - self._attr_brightness = None + if (brightness := self.device.brightness) is not None: + # Netatmo uses a range of [0, 100] to control brightness + self._attr_brightness = round(brightness * 2.55) + else: + self._attr_brightness = None + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 6d6aea230f18..51b827f1edb9 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==9.4.0"] + "quality_scale": "bronze", + "requirements": ["pyatmo==9.4.0"], + "single_config_entry": true } diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 653b82e5acc2..2006e43a94a2 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -1,5 +1,4 @@ """Netatmo Media Source Implementation.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import datetime as dt import logging @@ -15,9 +14,11 @@ from homeassistant.components.media_source import ( PlayMedia, Unresolvable, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback -from .const import DATA_CAMERAS, DATA_EVENTS, DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler, async_get_loaded_entry _LOGGER = logging.getLogger(__name__) MIME_TYPE = "application/x-mpegURL" @@ -29,7 +30,7 @@ class IncompatibleMediaSource(MediaSourceError): async def async_get_media_source(hass: HomeAssistant) -> NetatmoSource: """Set up Netatmo media source.""" - return NetatmoSource(hass) + return NetatmoSource(hass, async_get_loaded_entry(hass)) class NetatmoSource(MediaSource): @@ -37,11 +38,24 @@ class NetatmoSource(MediaSource): name: str = MANUFACTURER - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: NetatmoConfigEntry | None) -> None: """Initialize Netatmo source.""" super().__init__(DOMAIN) self.hass = hass - self.events = self.hass.data[DOMAIN][DATA_EVENTS] + self.entry = entry + + @property + def _data_handler(self) -> NetatmoDataHandler | None: + """Return the data handler of the config entry, if it is loaded.""" + if self.entry is None or self.entry.state is not ConfigEntryState.LOADED: + return None + return self.entry.runtime_data + + @property + def events(self) -> dict[str, dict]: + """Return the camera events.""" + data_handler = self._data_handler + return data_handler.events if data_handler else {} @override async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: @@ -85,7 +99,12 @@ class NetatmoSource(MediaSource): ) title = f"{created} - {message}" else: - title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER) + data_handler = self._data_handler + title = ( + data_handler.cameras.get(camera_id, MANUFACTURER) + if data_handler + else MANUFACTURER + ) thumbnail = None if event_id: diff --git a/homeassistant/components/netatmo/quality_scale.yaml b/homeassistant/components/netatmo/quality_scale.yaml new file mode 100644 index 000000000000..d69785e45207 --- /dev/null +++ b/homeassistant/components/netatmo/quality_scale.yaml @@ -0,0 +1,70 @@ +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-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: exempt + comment: >- + The integration is set up via an OAuth2 flow that has no user-entered + connection parameters to validate before configuration. + 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: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: todo + 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: done + 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: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 7596146b4749..3527e8ae7dff 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -1,5 +1,4 @@ """Support for the Netatmo climate schedule selector.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import override @@ -12,17 +11,18 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_URL_ENERGY, - DATA_SCHEDULES, DOMAIN, EVENT_TYPE_SCHEDULE, MANUFACTURER, NETATMO_CREATE_SELECT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -100,18 +100,16 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): return if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: - if schedule := self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].get(data["schedule_id"]): + if schedule := self.data_handler.schedules[self.home.entity_id].get( + data["schedule_id"] + ): self._attr_current_option = schedule.name self.async_write_ha_state() @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" - for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].items(): + for sid, schedule in self.data_handler.schedules[self.home.entity_id].items(): if schedule.name != option: continue _LOGGER.debug( @@ -130,9 +128,8 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): schedule = self.home.get_selected_schedule() assert schedule self._attr_current_option = schedule.name - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id] = ( - self.home.schedules - ) + self.data_handler.schedules[self.home.entity_id] = self.home.schedules self._attr_options = [ schedule.name for schedule in self.home.schedules.values() if schedule.name ] + self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 24bc7e149bd2..043b7cfb9842 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -17,10 +17,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, DEGREE, EntityCategory, + EntityStateAttribute, UnitOfPower, UnitOfPrecipitationDepth, UnitOfPressure, @@ -53,7 +52,7 @@ from .const import ( NETATMO_CREATE_WEATHER_SENSOR, SIGNAL_NAME, ) -from .data_handler import ( +from .coordinator import ( HOME, PUBLIC, NetatmoConfigEntry, @@ -63,6 +62,7 @@ from .data_handler import ( ) from .entity import ( NetatmoBaseEntity, + NetatmoDeviceEntity, NetatmoModuleEntity, NetatmoRoomEntity, NetatmoWeatherModuleEntity, @@ -71,6 +71,9 @@ from .helper import NetatmoArea _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + + DIRECTION_OPTIONS = [ "n", "ne", @@ -629,7 +632,25 @@ async def async_setup_entry( await add_public_entities(False) -class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): +class NetatmoLegacyReachableSensor(NetatmoDeviceEntity, SensorEntity): + """Sensor mixin that goes unavailable, keeping its last value, when unreachable.""" + + @callback + def _async_set_unavailable_if_unreachable(self) -> bool: + """Set the entity unavailable and write state when the device is unreachable. + + Returns True when the device is unreachable so callers return early. + """ + device = cast("pyatmo.Module | pyatmo.Room", self.device) + if device.reachable: + return False + if self.available: + self._attr_available = False + self.async_write_ha_state() + return True + + +class NetatmoBaseSensor(NetatmoModuleEntity, NetatmoLegacyReachableSensor): """Implementation of a Netatmo sensor.""" entity_description: NetatmoSensorEntityDescription @@ -664,16 +685,11 @@ class NetatmoBaseSensor(NetatmoModuleEntity, SensorEntity): """Update the entity's state (the legacy way).""" # Keep the last known value for these legacy sensors when the device is # unreachable to preserve the historical behavior expected by existing entities. - if not self.device.reachable: - if self.available: - self._attr_available = False - return - - if (state := getattr(self.device, self.entity_description.key)) is None: + if self._async_set_unavailable_if_unreachable(): return self._attr_available = True - self._attr_native_value = state + self._attr_native_value = getattr(self.device, self.entity_description.key) self.async_write_ha_state() @@ -698,7 +714,7 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, NetatmoBaseSensor): @override def available(self) -> bool: """Return True if entity is available.""" - return ( + return super().available and ( self.device.reachable or getattr( self.device, @@ -790,9 +806,7 @@ class NetatmoClimateBatterySensor(NetatmoLegacySensor): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if not self.device.reachable: - if self.available: - self._attr_available = False + if self._async_set_unavailable_if_unreachable(): return self._attr_available = True @@ -859,7 +873,7 @@ class NetatmoSensor(NetatmoBaseSensor): self.async_write_ha_state() -class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity): +class NetatmoRoomSensor(NetatmoRoomEntity, NetatmoLegacyReachableSensor): """Implementation of a Netatmo room sensor.""" entity_description: NetatmoSensorEntityDescription @@ -891,10 +905,11 @@ class NetatmoRoomSensor(NetatmoRoomEntity, SensorEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - if (state := getattr(self.device, self.entity_description.key)) is None: + if self._async_set_unavailable_if_unreachable(): return - self._attr_native_value = state + self._attr_available = True + self._attr_native_value = getattr(self.device, self.entity_description.key) self.async_write_ha_state() @@ -936,8 +951,8 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): self._attr_extra_state_attributes.update( { - ATTR_LATITUDE: (area.lat_ne + area.lat_sw) / 2, - ATTR_LONGITUDE: (area.lon_ne + area.lon_sw) / 2, + EntityStateAttribute.LATITUDE: (area.lat_ne + area.lat_sw) / 2, + EntityStateAttribute.LONGITUDE: (area.lon_ne + area.lon_sw) / 2, } ) self._attr_device_info = DeviceInfo( @@ -974,6 +989,17 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): self._signal_name = f"{PUBLIC}-{area.uuid}" self._mode = area.mode self._show_on_map = area.show_on_map + self._publishers = [ + { + "name": PUBLIC, + "lat_ne": area.lat_ne, + "lon_ne": area.lon_ne, + "lat_sw": area.lat_sw, + "lon_sw": area.lon_sw, + "area_name": area.area_name, + SIGNAL_NAME: self._signal_name, + } + ] await self.data_handler.subscribe( PUBLIC, self._signal_name, @@ -999,6 +1025,7 @@ class NetatmoPublicSensor(NetatmoBaseEntity, SensorEntity): ) self._attr_available = False + self.async_write_ha_state() return if values := [x for x in data.values() if x is not None]: diff --git a/homeassistant/components/netatmo/services.py b/homeassistant/components/netatmo/services.py index 750e88baf308..1265b70fa665 100644 --- a/homeassistant/components/netatmo/services.py +++ b/homeassistant/components/netatmo/services.py @@ -1,12 +1,11 @@ """Services for the Netatmo integration.""" -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .data_handler import NetatmoConfigEntry +from .coordinator import NetatmoConfigEntry, async_get_loaded_entry from .webhook import async_register_webhook, async_unregister_webhook SERVICE_REGISTER_WEBHOOK = "register_webhook" @@ -15,10 +14,7 @@ SERVICE_UNREGISTER_WEBHOOK = "unregister_webhook" def _get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry: """Return the loaded config entry or raise if unavailable.""" - entry: NetatmoConfigEntry | None = ( - hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) - ) - if entry is None or entry.state is not ConfigEntryState.LOADED: + if (entry := async_get_loaded_entry(hass)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="entry_not_loaded", diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 728381c7d33f..ad5d45da49a9 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -181,6 +181,15 @@ "mode": "Calculation", "show_on_map": "Show on map" }, + "data_description": { + "area_name": "Name used to identify this weather area.", + "lat_ne": "Latitude of the northeast corner of the area.", + "lat_sw": "Latitude of the southwest corner of the area.", + "lon_ne": "Longitude of the northeast corner of the area.", + "lon_sw": "Longitude of the southwest corner of the area.", + "mode": "How values from multiple stations in the area are aggregated.", + "show_on_map": "Whether to show the sensors on the map." + }, "description": "Configure a public weather sensor for an area.", "title": "Netatmo public weather sensor" }, @@ -189,6 +198,10 @@ "new_area": "Area name", "weather_areas": "Weather areas" }, + "data_description": { + "new_area": "Name of a new weather area to add.", + "weather_areas": "Configured weather areas to edit or remove." + }, "description": "Configure public weather sensors.", "title": "[%key:component::netatmo::options::step::public_weather::title%]" } diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 7bb3f83387ef..8a07e7951dcd 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -11,12 +11,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH -from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice -from .entity import NetatmoModuleEntity +from .coordinator import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice +from .entity import NetatmoReachabilityEntity from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, @@ -36,7 +38,7 @@ async def async_setup_entry( ) -class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity): +class NetatmoSwitch(NetatmoReachabilityEntity, SwitchEntity): """Representation of a Netatmo switch device.""" _attr_name = None @@ -68,7 +70,9 @@ class NetatmoSwitch(NetatmoModuleEntity, SwitchEntity): @override def async_update_callback(self) -> None: """Update the entity's state.""" - self._attr_is_on = self.device.on + if self.device.reachable is not False: + self._attr_is_on = self.device.on + self.async_write_ha_state() @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index c34abe3c76bf..b5cd935abdcb 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -1,9 +1,8 @@ """The Netatmo integration.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern - import logging import secrets +from typing import Any from aiohttp.web import Request import pyatmo @@ -31,8 +30,6 @@ from .const import ( ATTR_HOME_ID, ATTR_IS_KNOWN, CONF_CLOUDHOOK_URL, - DATA_DEVICE_IDS, - DATA_PERSONS, DEFAULT_PERSON, DOMAIN, EVENT_ID_MAP, @@ -40,7 +37,7 @@ from .const import ( WEBHOOK_DEACTIVATION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import NetatmoConfigEntry +from .coordinator import NetatmoConfigEntry, NetatmoDataHandler _LOGGER = logging.getLogger(__name__) @@ -62,19 +59,31 @@ async def async_handle_webhook( _LOGGER.debug("Got webhook data: %s", data) + entry = next( + ( + entry + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.data.get(CONF_WEBHOOK_ID) == webhook_id + ), + None, + ) + if entry is None: + return + data_handler = entry.runtime_data + event_type = data.get(ATTR_EVENT_TYPE) if event_type in SUBEVENT_TYPE_MAP: - async_send_event(hass, event_type, data) + async_send_event(data_handler, event_type, data) for event_data in data.get(SUBEVENT_TYPE_MAP[event_type], []): - async_evaluate_event(hass, event_data) + async_evaluate_event(data_handler, event_data) else: - async_evaluate_event(hass, data) + async_evaluate_event(data_handler, data) -def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: +def async_evaluate_event(data_handler: NetatmoDataHandler, event_data: dict) -> None: """Evaluate events from webhook.""" event_type = event_data.get(ATTR_EVENT_TYPE, "None") @@ -82,20 +91,23 @@ def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: for person in event_data.get(ATTR_PERSONS, {}): person_event_data = dict(event_data) person_event_data[ATTR_ID] = person.get(ATTR_ID) - person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS][ + person_event_data[ATTR_NAME] = data_handler.persons[ event_data[ATTR_HOME_ID] ].get(person_event_data[ATTR_ID], DEFAULT_PERSON) person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN) person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL) - async_send_event(hass, event_type, person_event_data) + async_send_event(data_handler, event_type, person_event_data) else: - async_send_event(hass, event_type, event_data) + async_send_event(data_handler, event_type, event_data) -def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: +def async_send_event( + data_handler: NetatmoDataHandler, event_type: str, data: dict +) -> None: """Send events.""" + hass = data_handler.hass _LOGGER.debug("%s: %s", event_type, data) async_dispatcher_send( hass, @@ -103,16 +115,14 @@ def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: {"type": event_type, "data": data}, ) - event_data = { + event_data: dict[str, Any] = { "type": event_type, "data": data, } if event_type in EVENT_ID_MAP: data_device_id = data[EVENT_ID_MAP[event_type]] - event_data[ATTR_DEVICE_ID] = hass.data[DOMAIN][DATA_DEVICE_IDS].get( - data_device_id - ) + event_data[ATTR_DEVICE_ID] = data_handler.device_ids.get(data_device_id) hass.bus.async_fire( event_type=NETATMO_EVENT, diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index afc32d4c5be6..2212644bce60 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -94,9 +94,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearConfigEntry) -> if device_entry.via_device_id is None: router_id = device_entry.id continue # do not remove the router itself - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) # Remove entities that are no longer tracked entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 6221de06693e..f05a571755af 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -50,6 +50,7 @@ PORT_5555 = 5555 # update method V2 models MODELS_V2 = [ "Orbi", + "RBE", "RBK", "RBR", "RBS", diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index a2c6338f21cf..af4a3a2fa1cd 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,4 +1,5 @@ """Support for Netgear LTE modems.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from typing import Any diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json index a76da88914d6..7dee6332f583 100644 --- a/homeassistant/components/network/manifest.json +++ b/homeassistant/components/network/manifest.json @@ -2,7 +2,6 @@ "domain": "network", "name": "Network Configuration", "codeowners": ["@home-assistant/core"], - "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/network", "integration_type": "system", "iot_class": "local_push", diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index b21853618cef..5d0029f4448e 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -17,6 +17,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_stop = entry.data[CONF_STOP] coordinator_key = f"{entry_agency}-{entry_stop}" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data coordinator: NextBusDataUpdateCoordinator | None = hass.data.setdefault( DOMAIN, {} ).get( diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index 06cf5d662a7b..8b72fc856690 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -11,7 +11,11 @@ from nextcloudmonitor import ( ) 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_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from .const import DEFAULT_VERIFY_SSL, DOMAIN @@ -46,8 +50,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) - @override - async def async_step_user( + async def async_step_config( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" @@ -62,16 +65,37 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): except NextcloudMonitorConnectionError, NextcloudMonitorRequestError: errors["base"] = "connection_error" else: + 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=user_input[CONF_URL], data=user_input, ) - data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, user_input) + data = user_input + if self.source == SOURCE_RECONFIGURE: + data = data or dict(self._get_reconfigure_entry().data) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, data) return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="config", data_schema=data_schema, errors=errors ) + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_config(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfigure flow initialized by the user.""" + return await self.async_step_config(user_input) + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 373bd86b4f42..a4997e6e78e2 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "connection_error_during_import": "Connection error occurred during yaml configuration import", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "connection_error": "[%key:common::config_flow::error::cannot_connect%]", @@ -11,14 +12,7 @@ }, "flow_title": "Nextcloud", "step": { - "reauth_confirm": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "description": "Update your login information for {url}." - }, - "user": { + "config": { "data": { "password": "[%key:common::config_flow::data::password%]", "url": "[%key:common::config_flow::data::url%]", @@ -26,6 +20,13 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "description": "Enter your Nextcloud information." + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "description": "Update your login information for {url}." } } }, diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 24feb44320b5..f06a06ffeeb2 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -4,6 +4,8 @@ from typing import Any from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( _LOGGER, @@ -12,11 +14,14 @@ from .const import ( CONF_FILTER_CORONA, CONF_FILTERS, CONF_HEADLINE_FILTER, + DOMAIN, NO_MATCH_REGEX, ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: @@ -32,6 +37,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool return True +async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: + """Set up services.""" + async_setup_services(hass) + return True + + async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index d7e4a4711dd0..d5e53cbcd359 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -23,6 +23,17 @@ from .const import ( ATTR_WEB, CONF_MESSAGE_SLOTS, CONF_REGIONS, + SERVICE_DATA_AFFECTED_AREAS, + SERVICE_DATA_DESCRIPTION, + SERVICE_DATA_EXPIRES, + SERVICE_DATA_HEADLINE, + SERVICE_DATA_ID, + SERVICE_DATA_RECOMMENDED_ACTIONS, + SERVICE_DATA_SENDER, + SERVICE_DATA_SENT, + SERVICE_DATA_SEVERITY, + SERVICE_DATA_START, + SERVICE_DATA_WEB, ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator from .entity import NinaEntity @@ -105,3 +116,24 @@ class NINAMessage(NinaEntity, BinarySensorEntity): if data.expires else "", # Deprecated, remove in 2026.11 } + + def get_details(self) -> dict[str, str] | None: + """Return the details of the warning.""" + if not self.is_on: + return None + + data = self._get_warning_data() + + return { + SERVICE_DATA_HEADLINE: data.headline, + SERVICE_DATA_DESCRIPTION: data.description, + SERVICE_DATA_SENDER: data.sender, + SERVICE_DATA_SEVERITY: data.severity or "Unknown", + SERVICE_DATA_RECOMMENDED_ACTIONS: data.recommended_actions, + SERVICE_DATA_AFFECTED_AREAS: data.affected_areas, + SERVICE_DATA_WEB: data.more_info_url, + SERVICE_DATA_ID: data.id, + SERVICE_DATA_SENT: data.sent.isoformat(), + SERVICE_DATA_START: data.start.isoformat() if data.start else "", + SERVICE_DATA_EXPIRES: data.expires.isoformat() if data.expires else "", + } diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 9af64322eacf..56e69dc4fc1f 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -15,6 +15,20 @@ ALL_MATCH_REGEX: str = ".*" SEVERITY_VALUES: list[str] = ["extreme", "severe", "moderate", "minor", "unknown"] +SERVICE_GET_DETAILS: str = "get_details" + +SERVICE_DATA_HEADLINE: str = "headline" +SERVICE_DATA_DESCRIPTION: str = "description" +SERVICE_DATA_SENDER: str = "sender" +SERVICE_DATA_SEVERITY: str = "severity" +SERVICE_DATA_RECOMMENDED_ACTIONS: str = "recommended_actions" +SERVICE_DATA_AFFECTED_AREAS: str = "affected_areas" +SERVICE_DATA_WEB: str = "web" +SERVICE_DATA_ID: str = "id" +SERVICE_DATA_SENT: str = "sent" +SERVICE_DATA_START: str = "start" +SERVICE_DATA_EXPIRES: str = "expires" + CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" CONF_FILTERS: str = "filters" diff --git a/homeassistant/components/nina/icons.json b/homeassistant/components/nina/icons.json new file mode 100644 index 000000000000..f0b4ac1a9115 --- /dev/null +++ b/homeassistant/components/nina/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "affected_areas": { + "default": "mdi:map-marker-radius" + }, + "headline": { + "default": "mdi:text-short" + }, + "more_info_url": { + "default": "mdi:web" + }, + "sender": { + "default": "mdi:account-tie-voice" + }, + "severity": { + "default": "mdi:alert" + } + } + }, + "services": { + "get_details": { "service": "mdi:download" } + } +} diff --git a/homeassistant/components/nina/quality_scale.yaml b/homeassistant/components/nina/quality_scale.yaml index 9f0f051f6549..39e827890a00 100644 --- a/homeassistant/components/nina/quality_scale.yaml +++ b/homeassistant/components/nina/quality_scale.yaml @@ -1,19 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: | - This integration does not provide additional actions. + 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: | - This integration does not provide additional actions. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -35,10 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: | - This integration does not provide additional actions. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -78,7 +69,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/nina/services.py b/homeassistant/components/nina/services.py new file mode 100644 index 000000000000..b26e9394de25 --- /dev/null +++ b/homeassistant/components/nina/services.py @@ -0,0 +1,21 @@ +"""Services for NINA.""" + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_GET_DETAILS + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_GET_DETAILS, + entity_domain=BINARY_SENSOR_DOMAIN, + schema=None, + func="get_details", + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/nina/services.yaml b/homeassistant/components/nina/services.yaml new file mode 100644 index 000000000000..2c57ec07fc2a --- /dev/null +++ b/homeassistant/components/nina/services.yaml @@ -0,0 +1,5 @@ +get_details: + target: + entity: + domain: binary_sensor + integration: nina diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 2e36e2fd61e0..ea885c6c1b00 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -129,5 +129,11 @@ "title": "Options" } } + }, + "services": { + "get_details": { + "description": "Retrieves the details of a warning.", + "name": "Get warning details" + } } } diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index ad2d7aa3705b..590783d61a4d 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -1,4 +1,5 @@ """The Nmap Tracker integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from dataclasses import dataclass diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 97c489aeb14f..0d47a125e2b4 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -1,4 +1,5 @@ """The NMBS component.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 8e7cdc298eba..e38b7de4af11 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -10,10 +10,9 @@ from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_NAME, CONF_SHOW_ON_MAP, + EntityStateAttribute, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -275,8 +274,8 @@ class NMBSSensor(SensorEntity): attrs["departure_minutes"] = departure if self._show_on_map and self.station_coordinates: - attrs[ATTR_LATITUDE] = self.station_coordinates[0] - attrs[ATTR_LONGITUDE] = self.station_coordinates[1] + attrs[EntityStateAttribute.LATITUDE] = self.station_coordinates[0] + attrs[EntityStateAttribute.LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: via = self._attrs.vias[0] diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 59f3ad1789b8..2529610da6c1 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -1,6 +1,8 @@ """The Nobø Ecohub integration.""" -from pynobo import nobo +import logging + +from pynobo import PynoboConnectionError, nobo from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -10,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -25,6 +27,8 @@ from .const import ( NOBO_MANUFACTURER, ) +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] type NoboHubConfigEntry = ConfigEntry[nobo] @@ -49,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b try: hub = await _connect(stored_ip) - except OSError as err: + except PynoboConnectionError as err: # Stored IP may be stale - try UDP rediscovery to pick up a new # DHCP lease (or a hub that's been moved). discovered = await nobo.async_discover_hubs(serial=serial) @@ -62,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b new_ip, _ = next(iter(discovered)) try: hub = await _connect(new_ip) - except OSError as rediscover_err: + except PynoboConnectionError as rediscover_err: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_connect", @@ -80,6 +84,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) ) + + def _log_connection_state(_hub: nobo, connected: bool) -> None: + """Log hub connection-state transitions.""" + if connected: + _LOGGER.info("Reconnected to Nobø Ecohub %s", serial) + else: + _LOGGER.info("Lost connection to Nobø Ecohub %s", serial) + + hub.register_connection_callback(_log_connection_state) + entry.async_on_unload( + lambda: hub.deregister_connection_callback(_log_connection_state) + ) entry.runtime_data = hub device_registry = dr.async_get(hass) @@ -100,6 +116,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + @callback + def _cleanup_devices(_hub: nobo) -> None: + """Remove devices for zones and components no longer on the hub.""" + if not hub.connected: + # While disconnected pynobo may hold stale topology; only reconcile + # against a live, fully-synced hub. + return + expected_identifiers = {(DOMAIN, hub.hub_serial)} + expected_identifiers.update( + (DOMAIN, f"{hub.hub_serial}:{zone_id}") for zone_id in hub.zones + ) + expected_identifiers.update((DOMAIN, serial) for serial in hub.components) + # Runs inside pynobo's update-callback dispatch: removing a device + # deregisters its entities' callbacks mid-iteration, which can skip a + # following callback. Safe because a pynobo message carries a single + # topology change, so a removal never coincides with a surviving + # entity's update in the same dispatch. + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if device.identifiers.isdisjoint(expected_identifiers): + device_registry.async_remove_device(device.id) + + _cleanup_devices(hub) + hub.register_callback(_cleanup_devices) + entry.async_on_unload(lambda: hub.deregister_callback(_cleanup_devices)) + await hub.start() return True diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 6cb8449a5828..aa09b8fba97f 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -56,8 +56,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nobø Ecohub platform from UI configuration.""" - - # Setup connection with hub hub = config_entry.runtime_data override_type = ( @@ -66,8 +64,26 @@ async def async_setup_entry( else nobo.API.OVERRIDE_TYPE_CONSTANT ) - # Add zones as entities - async_add_entities(NoboZone(zone_id, hub, override_type) for zone_id in hub.zones) + known_zones: set[str] = set() + + @callback + def _add_zones(_hub: nobo) -> None: + """Add climate entities for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) + new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] + known_zones.update(new_zones) + async_add_entities( + NoboZone(zone_id, hub, override_type) for zone_id in new_zones + ) + + _add_zones(hub) + hub.register_callback(_add_zones) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_zones)) class NoboZone(NoboBaseEntity, ClimateEntity): @@ -161,15 +177,18 @@ class NoboZone(NoboBaseEntity, ClimateEntity): """Fetch new state data for this zone.""" self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the zone still exists.""" + return super().available and self._id in self._nobo.zones + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.zones: - # Zone removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. These are only local calls.""" + if not self.available: return - self._attr_available = True state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 58a6c90e2696..ecbd23a7d487 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -1,12 +1,13 @@ """Config flow for Nobø Ecohub integration.""" -import socket +import ipaddress from typing import TYPE_CHECKING, Any, override -from pynobo import nobo +from pynobo import PynoboConnectionError, nobo import voluptuous as vol from homeassistant.config_entries import ( + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -199,6 +200,68 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing hub. + + Only the IP address is editable. When the entry is not loaded, + the new IP is probed here before updating. When the entry is + loaded, probing is skipped to avoid competing with the active + connection for the hub's limited concurrent-connection slots; + the reload's ``async_setup_entry`` re-validates the updated IP. + """ + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + new_ip = user_input[CONF_IP_ADDRESS] + is_loaded = reconfigure_entry.state is ConfigEntryState.LOADED + try: + ipaddress.ip_address(new_ip) + except ValueError: + errors[CONF_IP_ADDRESS] = "invalid_ip" + else: + try: + # Probe the new IP only when the integration is not currently + # loaded — if it were, the running connection would compete + # with the probe for the hub's limited concurrent-connection + # slots. + if not is_loaded: + await self._test_connection( + reconfigure_entry.data[CONF_SERIAL], new_ip + ) + except NoboHubConnectError as error: + # The serial is fixed in reconfigure, so blame the IP rather + # than the (uneditable) serial number. + errors[CONF_IP_ADDRESS] = ( + "cannot_connect_ip" + if error.msg == "cannot_connect" + else error.msg + ) + else: + if new_ip == reconfigure_entry.data[CONF_IP_ADDRESS] and is_loaded: + # No-op: IP unchanged and the running integration already + # proves it works. Skip the reload to avoid a needless + # reconnect. + return self.async_abort(reason="reconfigure_successful") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_IP_ADDRESS: new_ip}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_IP_ADDRESS): str}), + user_input or reconfigure_entry.data, + ), + errors=errors, + description_placeholders={ + CONF_SERIAL: reconfigure_entry.data[CONF_SERIAL], + }, + ) + async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -245,19 +308,19 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): if len(serial) != SERIAL_LENGTH or not serial.isdigit(): raise NoboHubConnectError("invalid_serial") try: - socket.inet_aton(ip_address) - except OSError as err: + ipaddress.ip_address(ip_address) + except ValueError as err: raise NoboHubConnectError("invalid_ip") from err hub = nobo(serial=serial, ip=ip_address, discover=False, synchronous=False) # pynobo distinguishes the two failure modes: TCP-level errors - # (wrong IP, hub offline, port closed) raise OSError, while a - # successful TCP connection followed by a handshake REJECT + # (wrong IP, hub offline, port closed) raise PynoboConnectionError, + # while a successful TCP connection followed by a handshake REJECT # (serial mismatch) returns False. try: if not await hub.async_connect_hub(ip_address, serial): raise NoboHubConnectError("cannot_connect") return hub.hub_info["name"] - except OSError as err: + except PynoboConnectionError as err: raise NoboHubConnectError("cannot_connect_ip") from err finally: await hub.close() diff --git a/homeassistant/components/nobo_hub/diagnostics.py b/homeassistant/components/nobo_hub/diagnostics.py new file mode 100644 index 000000000000..7916848774cd --- /dev/null +++ b/homeassistant/components/nobo_hub/diagnostics.py @@ -0,0 +1,53 @@ +"""Diagnostics support for Nobø Ecohub.""" + +from typing import Any + +from pynobo import ComponentInfo, nobo + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC +from homeassistant.core import HomeAssistant + +from . import NoboHubConfigEntry +from .const import ATTR_SERIAL, CONF_SERIAL + +TO_REDACT_ENTRY = {CONF_IP_ADDRESS, CONF_MAC, CONF_SERIAL} +TO_REDACT_HUB = {ATTR_SERIAL} + +_MODEL_FIELDS = ( + "model_id", + "name", + "type", + "has_temp_sensor", + "requires_control_panel", + "supports_comfort", + "supports_eco", +) + + +def _component_to_dict(component: ComponentInfo) -> dict[str, Any]: + model = component["model"] + formatted: dict[str, Any] = dict(component) + formatted["model"] = {field: getattr(model, field, None) for field in _MODEL_FIELDS} + if model.type == nobo.Model.UNKNOWN: + # Unknown models carry the serial number in the name. + formatted["model"]["name"] = REDACTED + return formatted + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NoboHubConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hub = entry.runtime_data + return { + "entry_data": async_redact_data(entry.data, TO_REDACT_ENTRY), + "hub_info": async_redact_data(hub.hub_info, TO_REDACT_HUB), + "zones": hub.zones, + "components": async_redact_data( + [_component_to_dict(c) for c in hub.components.values()], + TO_REDACT_HUB, + ), + "week_profiles": hub.week_profiles, + "overrides": hub.overrides, + } diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py index 22445ed7150e..7ad26edb40bf 100644 --- a/homeassistant/components/nobo_hub/entity.py +++ b/homeassistant/components/nobo_hub/entity.py @@ -17,16 +17,21 @@ class NoboBaseEntity(Entity): def __init__(self, hub: nobo) -> None: """Initialize the entity.""" self._nobo = hub + self._attr_available = hub.connected @override async def async_added_to_hass(self) -> None: - """Register callback with hub.""" + """Register callbacks with hub.""" await super().async_added_to_hass() self._nobo.register_callback(self._handle_hub_update) + self._nobo.register_connection_callback(self._handle_hub_connection) + # Resync in case the state changed between __init__ and callback registration. + self._attr_available = self._nobo.connected @override async def async_will_remove_from_hass(self) -> None: - """Deregister callback from hub.""" + """Deregister callbacks from hub.""" + self._nobo.deregister_connection_callback(self._handle_hub_connection) self._nobo.deregister_callback(self._handle_hub_update) await super().async_will_remove_from_hass() @@ -36,6 +41,16 @@ class NoboBaseEntity(Entity): self._read_state() self.async_write_ha_state() + @callback + def _handle_hub_connection(self, _hub: nobo, connected: bool) -> None: + """Handle a connection-state transition from the hub.""" + self._attr_available = connected + if connected: + # Refresh state values so the first state write after reconnect + # carries fresh data, not whatever was cached pre-disconnect. + self._read_state() + self.async_write_ha_state() + @callback def _read_state(self) -> None: """Copy the current hub state from the pynobo client onto the entity attributes. diff --git a/homeassistant/components/nobo_hub/icons.json b/homeassistant/components/nobo_hub/icons.json new file mode 100644 index 000000000000..74c20b18fe5b --- /dev/null +++ b/homeassistant/components/nobo_hub/icons.json @@ -0,0 +1,17 @@ +{ + "entity": { + "select": { + "global_override": { + "default": "mdi:calendar-clock", + "state": { + "away": "mdi:account-arrow-right", + "comfort": "mdi:sofa", + "eco": "mdi:leaf" + } + }, + "week_profile": { + "default": "mdi:calendar-clock" + } + } + } +} diff --git a/homeassistant/components/nobo_hub/manifest.json b/homeassistant/components/nobo_hub/manifest.json index 13d7dfd60794..3350742c38d5 100644 --- a/homeassistant/components/nobo_hub/manifest.json +++ b/homeassistant/components/nobo_hub/manifest.json @@ -15,6 +15,6 @@ "documentation": "https://www.home-assistant.io/integrations/nobo_hub", "integration_type": "hub", "iot_class": "local_push", - "quality_scale": "bronze", + "quality_scale": "gold", "requirements": ["pynobo==1.9.0"] } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index bd6a427a0c28..fb94595ed48f 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -11,7 +11,7 @@ rules: dependency-transparency: done docs-actions: status: exempt - comment: Integration does not register custom actions. + comment: This integration does not register custom actions. docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -34,52 +34,52 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: status: exempt comment: The hub does not require authentication. - test-coverage: - status: done - comment: > - Investigate whether the `_spec_hub` helper in `test_init.py` can be - replaced by the conftest base mock. + test-coverage: done # Gold devices: status: done comment: > Model name "Nobø Ecohub" under review for rename to "Nobø Hub". - diagnostics: todo + diagnostics: done discovery: done discovery-update-info: done - 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 + 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: done + entity-category: + status: exempt + comment: > + All entities are primary controls or measurements; none are configuration + or diagnostic entities that need a non-default entity category. entity-device-class: done - entity-disabled-by-default: todo - entity-translations: todo - exception-translations: todo - icon-translations: todo - reconfiguration-flow: todo + entity-disabled-by-default: + status: exempt + comment: This integration has no entities that should be disabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done repair-issues: status: exempt - comment: Integration has no repair scenarios. - stale-devices: todo + comment: This integration has no repair scenarios. + stale-devices: done # Platinum async-dependency: done inject-websession: status: exempt - comment: Integration uses a local TCP socket (via pynobo); no HTTP client is used. - strict-typing: - status: todo - comment: Requires release of pynobo 1.9.0 + comment: This integration uses a local TCP socket (via pynobo); no HTTP client is used. + strict-typing: todo diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index a4850fc6c76d..85ad51e78e04 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -32,8 +32,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" - - # Setup connection with hub hub = config_entry.runtime_data override_type = ( @@ -42,11 +40,28 @@ async def async_setup_entry( else nobo.API.OVERRIDE_TYPE_CONSTANT ) - entities: list[SelectEntity] = [ - NoboProfileSelector(zone_id, hub) for zone_id in hub.zones - ] - entities.append(NoboGlobalSelector(hub, override_type)) - async_add_entities(entities, True) + async_add_entities([NoboGlobalSelector(hub, override_type)], True) + + known_zones: set[str] = set() + + @callback + def _add_profiles(_hub: nobo) -> None: + """Add week-profile selectors for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) + new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] + known_zones.update(new_zones) + async_add_entities( + (NoboProfileSelector(zone_id, hub) for zone_id in new_zones), True + ) + + _add_profiles(hub) + hub.register_callback(_add_profiles) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_profiles)) class NoboGlobalSelector(NoboBaseEntity, SelectEntity): @@ -143,15 +158,18 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity): """Fetch new state data for this zone.""" self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the zone still exists.""" + return super().available and self._id in self._nobo.zones + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.zones: - # Zone removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. These are only local calls.""" + if not self.available: return - self._attr_available = True self._profiles = { profile["week_profile_id"]: profile["name"].replace("\xa0", " ") for profile in self._nobo.week_profiles.values() diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index c82718449035..371fa96e6823 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -28,15 +28,32 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" - - # Setup connection with hub hub = config_entry.runtime_data - async_add_entities( - NoboTemperatureSensor(component["serial"], hub) - for component in hub.components.values() - if component[ATTR_MODEL].has_temp_sensor - ) + known_components: set[str] = set() + + @callback + def _add_sensors(_hub: nobo) -> None: + """Add temperature sensors for components added to the hub.""" + if hub.connected: + # Forget components no longer on the hub so a removed-then-re-added + # component is detected as new again. Skip while disconnected: a + # stale/empty snapshot would drop live components and cause + # duplicate re-adds on reconnect. + known_components.intersection_update(hub.components) + new_components = [ + serial + for serial, component in hub.components.items() + if component[ATTR_MODEL].has_temp_sensor and serial not in known_components + ] + known_components.update(new_components) + async_add_entities( + NoboTemperatureSensor(serial, hub) for serial in new_components + ) + + _add_sensors(hub) + hub.register_callback(_add_sensors) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_sensors)) class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): @@ -69,14 +86,17 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): ) self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the component still exists.""" + return super().available and self._id in self._nobo.components + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.components: - # Component removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. This is a local call.""" + if not self.available: return - self._attr_available = True value = self._nobo.get_current_component_temperature(self._id) self._attr_native_value = None if value is None else float(value) diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 07cd3d15c269..4725b8cb05d8 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -3,7 +3,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address." + "cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "Failed to connect - check serial number", @@ -24,6 +25,15 @@ }, "description": "Configure a Nobø Ecohub not discovered on your local network. If your hub is on another network, you can still connect to it by entering the complete serial number (12 digits) and its IP address." }, + "reconfigure": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "[%key:component::nobo_hub::config::step::manual::data_description::ip_address%]" + }, + "description": "Update the IP address for Nobø Ecohub with serial number {serial}." + }, "selected": { "data": { "serial_suffix": "Serial number suffix (3 digits)" @@ -62,7 +72,7 @@ }, "exceptions": { "cannot_connect": { - "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, remove and re-add the integration." + "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, reconfigure the integration with the new IP address." }, "set_global_override_failed": { "message": "Failed to set global override." diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py index 2b744e01d0da..6937f9f82022 100644 --- a/homeassistant/components/nordpool/__init__.py +++ b/homeassistant/components/nordpool/__init__.py @@ -66,6 +66,4 @@ async def cleanup_device( continue LOGGER.debug("Removing device %s", entry.name) - device_reg.async_update_device( - entry.id, remove_config_entry_id=config_entry.entry_id - ) + device_reg.async_remove_device(entry.id) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index 01872567d2ed..2366ef3cf6d0 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, override import aiohttp +from aiozoneinfo import get_time_zone from pynordpool import ( Currency, DeliveryPeriodData, @@ -27,6 +28,13 @@ from .const import CONF_AREAS, DOMAIN, LOGGER if TYPE_CHECKING: from . import NordPoolConfigEntry +NORDPOOL_TIMEZONE = get_time_zone("Europe/Oslo") + + +def get_nordpool_current_time() -> datetime: + """Return the Nord Pool current time.""" + return dt_util.utcnow().astimezone(NORDPOOL_TIMEZONE) + class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): """A Nord Pool Data Update Coordinator.""" @@ -47,26 +55,20 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): 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( - next_hour.year, - next_hour.month, - next_hour.day, - next_hour.hour, - tzinfo=dt_util.UTC, - ) - LOGGER.debug("Next data update at %s", next_run) + next_data_run = now + timedelta(hours=1) + next_run = next_data_run.replace(minute=0, second=0, microsecond=0) + LOGGER.debug("Next data update at %s", next_run.astimezone(NORDPOOL_TIMEZONE)) 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_run = now + 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 - ) + next_run = next_run.replace(minute=next_minute, second=0, microsecond=0) - LOGGER.debug("Next listener update at %s", next_run) + LOGGER.debug( + "Next listener update at %s", next_run.astimezone(NORDPOOL_TIMEZONE) + ) return next_run @override @@ -85,14 +87,14 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): self.listener_unsub = async_track_point_in_utc_time( self.hass, self.update_listeners, - self.get_next_15_interval(dt_util.utcnow()), + self.get_next_15_interval(now), ) 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()) + self.hass, self.fetch_data, self.get_next_data_interval(now) ) if self.config_entry.pref_disable_polling and not initial: return @@ -107,7 +109,7 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): """Fetch data from Nord Pool.""" data = await self.api_call() if data and data.entries: - current_day = dt_util.now().date() + current_day = get_nordpool_current_time().date() if current_day in data.entries: LOGGER.debug("Data for current day found") return data @@ -129,9 +131,9 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): try: data = await self.client.async_get_delivery_periods( [ - dt_util.now() - timedelta(days=1), - dt_util.now(), - dt_util.now() + timedelta(days=1), + get_nordpool_current_time() - timedelta(days=1), + get_nordpool_current_time(), + get_nordpool_current_time() + timedelta(days=1), ], Currency(self.config_entry.data[CONF_CURRENCY]), self.config_entry.data[CONF_AREAS], @@ -164,10 +166,10 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): def get_data_current_day(self) -> DeliveryPeriodData: """Return the current day data.""" - current_day = dt_util.now().date() + current_day = get_nordpool_current_time().date() return self.data.entries[current_day] def get_data_tomorrow(self) -> DeliveryPeriodData | None: """Return tomorrow's day data if available.""" - tomorrow = dt_util.now().date() + timedelta(days=1) + tomorrow = get_nordpool_current_time().date() + timedelta(days=1) return self.data.entries.get(tomorrow) diff --git a/homeassistant/components/novy_cooker_hood/entity.py b/homeassistant/components/novy_cooker_hood/entity.py index 41d86462055c..96d6001cee36 100644 --- a/homeassistant/components/novy_cooker_hood/entity.py +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -1,76 +1,24 @@ """Common entity for the Novy Cooker Hood integration.""" -import logging -from typing import override - +from homeassistant.components.radio_frequency import ( + RadioFrequencyTransmitterConsumerEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import Event, EventStateChangedData, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_TRANSMITTER, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -class NovyCookerHoodEntity(Entity): +class NovyCookerHoodEntity(RadioFrequencyTransmitterConsumerEntity): """Novy Cooker Hood base entity.""" _attr_assumed_state = True _attr_has_entity_name = True - _attr_should_poll = False def __init__(self, entry: ConfigEntry) -> None: """Initialize the entity.""" - self._transmitter = entry.data[CONF_TRANSMITTER] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Novy", model="Cooker Hood", ) - - @override - async def async_added_to_hass(self) -> None: - """Subscribe to transmitter entity state changes.""" - await super().async_added_to_hass() - - transmitter_entity_id = er.async_validate_entity_id( - er.async_get(self.hass), self._transmitter - ) - - @callback - def _async_transmitter_state_changed( - event: Event[EventStateChangedData], - ) -> None: - """Handle transmitter entity state changes.""" - new_state = event.data["new_state"] - transmitter_available = ( - new_state is not None and new_state.state != STATE_UNAVAILABLE - ) - if transmitter_available != self.available: - _LOGGER.info( - "Transmitter %s used by %s is %s", - transmitter_entity_id, - self.entity_id, - "available" if transmitter_available else "unavailable", - ) - - self._attr_available = transmitter_available - self.async_write_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [transmitter_entity_id], - _async_transmitter_state_changed, - ) - ) - - transmitter_state = self.hass.states.get(transmitter_entity_id) - self._attr_available = ( - transmitter_state is not None - and transmitter_state.state != STATE_UNAVAILABLE - ) diff --git a/homeassistant/components/novy_cooker_hood/fan.py b/homeassistant/components/novy_cooker_hood/fan.py index f59281e6c7e3..eef061011034 100644 --- a/homeassistant/components/novy_cooker_hood/fan.py +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -1,13 +1,17 @@ """Fan platform for the Novy Cooker Hood (calibrated speed control).""" +import asyncio import math from typing import Any, override +from rf_protocols import RadioFrequencyCommand from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton -from rf_protocols.commands.novy import NovyCookerHoodCommand -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature -from homeassistant.components.radio_frequency import async_send_command +from homeassistant.components.fan import ( + FanEntity, + FanEntityFeature, + FanEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE from homeassistant.core import HomeAssistant @@ -18,13 +22,18 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import SPEED_COUNT +from .const import CONF_TRANSMITTER, SPEED_COUNT from .entity import NovyCookerHoodEntity PARALLEL_UPDATES = 1 _SPEED_RANGE = (1, SPEED_COUNT) +# Minimum gap the hood needs to register consecutive presses as distinct +# button events. Without it, low-latency transmitters collapse rapid presses +# into a single one. +_COMMAND_DELAY = 0.5 + async def async_setup_entry( hass: HomeAssistant, @@ -49,6 +58,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the fan.""" super().__init__(entry) + self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER] self._code: int = entry.data[CONF_CODE] self._level = 0 self._attr_unique_id = entry.entry_id @@ -74,7 +84,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): last = await self.async_get_last_state() if last is None: return - last_pct = last.attributes.get(ATTR_PERCENTAGE) + last_pct = last.attributes.get(FanEntityStateAttribute.PERCENTAGE) if isinstance(last_pct, (int, float)) and last_pct > 0: self._level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, last_pct)) @@ -111,8 +121,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): """Bump speed up by N hardware levels (no recalibration).""" steps = self._steps_from_percentage(percentage_step) plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) - for _ in range(steps): - await self._async_send(plus) + await self._async_send_repeated(plus, steps) self._level = min(SPEED_COUNT, self._level + steps) self.async_write_ha_state() @@ -121,8 +130,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): """Bump speed down by N hardware levels (no recalibration).""" steps = self._steps_from_percentage(percentage_step) minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) - for _ in range(steps): - await self._async_send(minus) + await self._async_send_repeated(minus, steps) self._level = max(0, self._level - steps) self.async_write_ha_state() @@ -136,17 +144,19 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity): async def _async_set_level(self, level: int) -> None: """Reset to off with `SPEED_COUNT` minus presses, then climb to level.""" minus = NovyCookerHoodButton.MINUS.to_command(channel=self._code) - for _ in range(SPEED_COUNT): - await self._async_send(minus) + await self._async_send_repeated(minus, SPEED_COUNT) if level > 0: + await asyncio.sleep(_COMMAND_DELAY) plus = NovyCookerHoodButton.PLUS.to_command(channel=self._code) - for _ in range(level): - await self._async_send(plus) + await self._async_send_repeated(plus, level) self._level = level self.async_write_ha_state() - async def _async_send(self, command: NovyCookerHoodCommand) -> None: - """Send a single RF command via the configured transmitter.""" - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) + async def _async_send_repeated( + self, command: RadioFrequencyCommand, count: int + ) -> None: + """Send the same RF command N times, pausing between presses.""" + for i in range(count): + if i > 0: + await asyncio.sleep(_COMMAND_DELAY) + await self._send_command(command) diff --git a/homeassistant/components/novy_cooker_hood/light.py b/homeassistant/components/novy_cooker_hood/light.py index 597c4873fcad..8a456b396723 100644 --- a/homeassistant/components/novy_cooker_hood/light.py +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -5,13 +5,13 @@ from typing import Any, override from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.components.radio_frequency import async_send_command from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from .const import CONF_TRANSMITTER from .entity import NovyCookerHoodEntity PARALLEL_UPDATES = 1 @@ -36,6 +36,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): def __init__(self, entry: ConfigEntry) -> None: """Initialize the light.""" super().__init__(entry) + self._rf_transmitter_entity_id_or_uuid = entry.data[CONF_TRANSMITTER] self._code = entry.data[CONF_CODE] self._attr_unique_id = entry.entry_id @@ -63,6 +64,4 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity): async def _async_send_light(self) -> None: """Send the light toggle command via the configured transmitter.""" command = NovyCookerHoodButton.LIGHT.to_command(channel=self._code) - await async_send_command( - self.hass, self._transmitter, command, context=self._context - ) + await self._send_command(command) diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 1bda5ab4e4d5..06b965ae3cb6 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -2,9 +2,11 @@ from typing import Any -import attr - -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import ( + async_redact_data, + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -41,7 +43,7 @@ async def async_get_config_entry_diagnostics( assert hass_device is not None data["device"] = { - **attr.asdict(hass_device), + **device_entry_as_dict(hass_device), "entities": {}, } @@ -61,10 +63,11 @@ async def async_get_config_entry_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) + entity_dict = entity_entry_as_dict(entity_entry) + # The entity_id is already provided at root level (the key). + del entity_dict["entity_id"] data["device"]["entities"][entity_entry.entity_id] = { - **attr.asdict( - entity_entry, filter=lambda attr, value: attr.name != "entity_id" - ), + **entity_dict, "state": state_dict, } diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 80b6e67aa217..eebc2751dfab 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -9,11 +9,10 @@ from pynws import NwsNoDataError, SimpleNWS, call_with_retry from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, + EntityStateAttribute, Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback @@ -102,8 +101,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool: translation_key="entity_unavailable", translation_placeholders={"entity_id": location_entity_id}, ) - latitude = state.attributes[ATTR_LATITUDE] - longitude = state.attributes[ATTR_LONGITUDE] + latitude = state.attributes[EntityStateAttribute.LATITUDE] + longitude = state.attributes[EntityStateAttribute.LONGITUDE] station = None else: latitude = entry.data[CONF_LATITUDE] @@ -217,8 +216,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool: new_state = event.data["new_state"] if new_state is None or not has_location(new_state): return - new_lat = new_state.attributes[ATTR_LATITUDE] - new_lon = new_state.attributes[ATTR_LONGITUDE] + new_lat = new_state.attributes[EntityStateAttribute.LATITUDE] + new_lon = new_state.attributes[EntityStateAttribute.LONGITUDE] if ( new_lat == entry.runtime_data.latitude and new_lon == entry.runtime_data.longitude diff --git a/homeassistant/components/nws/config_flow.py b/homeassistant/components/nws/config_flow.py index b1b5e237b07b..e107d5f54215 100644 --- a/homeassistant/components/nws/config_flow.py +++ b/homeassistant/components/nws/config_flow.py @@ -9,11 +9,10 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -134,8 +133,12 @@ class NWSConfigFlow(ConfigFlow, domain=DOMAIN): self.hass, { CONF_API_KEY: user_input[CONF_API_KEY], - CONF_LATITUDE: state.attributes[ATTR_LATITUDE], - CONF_LONGITUDE: state.attributes[ATTR_LONGITUDE], + CONF_LATITUDE: state.attributes[ + EntityStateAttribute.LATITUDE + ], + CONF_LONGITUDE: state.attributes[ + EntityStateAttribute.LONGITUDE + ], }, ) return self.async_create_entry(title=location_entity, data=data) diff --git a/homeassistant/components/nws/coordinator.py b/homeassistant/components/nws/coordinator.py index a838907823f2..4824cbd85217 100644 --- a/homeassistant/components/nws/coordinator.py +++ b/homeassistant/components/nws/coordinator.py @@ -8,7 +8,7 @@ import aiohttp from aiohttp import ClientResponseError from pynws import NwsError, NwsNoDataError, SimpleNWS, call_with_retry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY +from homeassistant.const import CONF_API_KEY, EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers import debounce from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -90,8 +90,8 @@ class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): self._location_entity_id, ) return - new_lat = state.attributes[ATTR_LATITUDE] - new_lon = state.attributes[ATTR_LONGITUDE] + new_lat = state.attributes[EntityStateAttribute.LATITUDE] + new_lon = state.attributes[EntityStateAttribute.LONGITUDE] if self._previous_position is not None: prev_lat, prev_lon = self._previous_position if new_lat == prev_lat and new_lon == prev_lon: diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 86d7d964c3a5..1bc5c85925e4 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfElectricCurrent, UnitOfElectricPotential, - UnitOfEnergy, UnitOfPower, ) from homeassistant.core import HomeAssistant @@ -60,15 +59,6 @@ SENSORS = [ state_class=SensorStateClass.MEASUREMENT, value_fn=lambda client: client.power.watts, ), - OhmeSensorDescription( - key="energy", - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - suggested_display_precision=1, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda client: client.energy, - ), OhmeSensorDescription( key="voltage", device_class=SensorDeviceClass.VOLTAGE, diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 16717b66d4d2..7ee31e4e8662 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -26,7 +26,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.util.ssl import get_default_context from .const import ( @@ -192,7 +192,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # 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 + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -202,20 +202,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 78a3b3a9636f..19f79bff7a0f 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -118,7 +118,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): str(err), ) errors["base"] = "unknown" - except TimeoutError, httpx.ConnectError: + except TimeoutError, ConnectionError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") @@ -278,7 +278,7 @@ class OllamaSubentryFlowHandler(ConfigSubentryFlow): downloaded_models: set[str] = { model_info["model"] for model_info in response.get("models", []) } - except TimeoutError, httpx.ConnectError, httpx.HTTPError: + except TimeoutError, httpx.HTTPError, ConnectionError: _LOGGER.exception("Failed to get models from Ollama server") return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/omie/__init__.py b/homeassistant/components/omie/__init__.py index a0e1334ff4c9..e4bade0040f5 100644 --- a/homeassistant/components/omie/__init__.py +++ b/homeassistant/components/omie/__init__.py @@ -2,11 +2,23 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN from .coordinator import OMIEConfigEntry, OMIECoordinator +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the OMIE integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: OMIEConfigEntry) -> bool: """Set up from a config entry.""" diff --git a/homeassistant/components/omie/coordinator.py b/homeassistant/components/omie/coordinator.py index 0538e42fcd8e..a69731662bd1 100644 --- a/homeassistant/components/omie/coordinator.py +++ b/homeassistant/components/omie/coordinator.py @@ -47,7 +47,7 @@ class OMIECoordinator(DataUpdateCoordinator[OMIEResults[SpotData]]): if self.data and self.data.market_date == cet_today: data = self.data else: - data = await self._spot_price(cet_today) + data = await self.async_get_spot_price(cet_today) self._set_update_interval() return data @@ -58,7 +58,7 @@ class OMIECoordinator(DataUpdateCoordinator[OMIEResults[SpotData]]): self.update_interval = calc_update_interval(now) _LOGGER.debug("Next refresh at %s", (now + self.update_interval).isoformat()) - async def _spot_price(self, date: dt.date) -> OMIEResults[SpotData]: + async def async_get_spot_price(self, date: dt.date) -> OMIEResults[SpotData]: """Fetch OMIE spot price data for the given date.""" _LOGGER.debug("Fetching OMIE spot data for %s", date) return await pyomie.spot_price(self._client_session, date) diff --git a/homeassistant/components/omie/icons.json b/homeassistant/components/omie/icons.json new file mode 100644 index 000000000000..460e22df9c06 --- /dev/null +++ b/homeassistant/components/omie/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_prices_for_date": { + "service": "mdi:cash-multiple" + } + } +} diff --git a/homeassistant/components/omie/quality_scale.yaml b/homeassistant/components/omie/quality_scale.yaml index 5290b86a3672..e9eec6e01950 100644 --- a/homeassistant/components/omie/quality_scale.yaml +++ b/homeassistant/components/omie/quality_scale.yaml @@ -1,17 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: No custom service 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 service actions are defined. + docs-actions: done docs-conditions: status: exempt comment: This integration does not have any conditions. @@ -35,9 +31,7 @@ rules: Coordinators handle any connection issues gracefully during runtime. unique-config-entry: done # Silver - action-exceptions: - status: exempt - comment: No custom service actions are defined. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -49,3 +43,29 @@ rules: status: exempt comment: OMIE API is public data service that doesn't require authentication. test-coverage: done + # Gold + devices: todo + diagnostics: todo + discovery: todo + discovery-update-info: 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/omie/services.py b/homeassistant/components/omie/services.py new file mode 100644 index 000000000000..9dc3eab60a37 --- /dev/null +++ b/homeassistant/components/omie/services.py @@ -0,0 +1,119 @@ +"""Services for the OMIE - Spain and Portugal electricity prices integration.""" + +import datetime as dt +from enum import StrEnum +from typing import Final + +import aiohttp +from pyomie import QUARTER_HOURLY_START_DATE +import voluptuous as vol + +from homeassistant.const import ATTR_DATE +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import OMIEConfigEntry +from .util import CET, pick_series_cet + +ATTR_COUNTRIES: Final = "countries" + + +class Country(StrEnum): + """Country to retrieve prices for.""" + + ES = "es" + PT = "pt" + + +SERVICE_GET_PRICES_FOR_DATE: Final = "get_prices_for_date" +SERVICE_GET_PRICES_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_DATE): cv.date, + vol.Required(ATTR_COUNTRIES, default=[Country.ES, Country.PT]): vol.All( + cv.ensure_list, [vol.Coerce(Country)] + ), + } +) + +_QUARTER_HOUR: Final = dt.timedelta(minutes=15) +_SERIES_BY_COUNTRY: Final = {Country.ES: "es_spot_price", Country.PT: "pt_spot_price"} + + +async def _get_prices_for_date(call: ServiceCall) -> ServiceResponse: + """Get OMIE spot prices for a specific date.""" + loaded_entries: list[OMIEConfigEntry] = ( + call.hass.config_entries.async_loaded_entries(DOMAIN) + ) + if not loaded_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + entry = loaded_entries[0] + market_date: dt.date = call.data[ATTR_DATE] + countries: list[Country] = call.data[ATTR_COUNTRIES] + + if market_date < QUARTER_HOURLY_START_DATE: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="date_before_market_start", + translation_placeholders={ + "date": market_date.isoformat(), + "start_date": QUARTER_HOURLY_START_DATE.isoformat(), + }, + ) + + try: + results = await entry.runtime_data.async_get_spot_price(market_date) + except aiohttp.ClientResponseError as err: + if err.status == 404: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="data_not_available", + translation_placeholders={"date": market_date.isoformat()}, + ) from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except (aiohttp.ClientError, TimeoutError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + return { + country.value: [ + { + "start": start.isoformat(), + # Add in UTC otherwise the end will be wrong across DST changes + "end": (start.astimezone(dt.UTC) + _QUARTER_HOUR) + .astimezone(CET) + .isoformat(), + "price": price_mwh / 1000, + } + for start, price_mwh in pick_series_cet(results, series_name).items() + ] + for country, series_name in _SERIES_BY_COUNTRY.items() + if country in countries + } + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the OMIE integration.""" + hass.services.async_register( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + _get_prices_for_date, + schema=SERVICE_GET_PRICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/omie/services.yaml b/homeassistant/components/omie/services.yaml new file mode 100644 index 000000000000..b862e194bfa6 --- /dev/null +++ b/homeassistant/components/omie/services.yaml @@ -0,0 +1,18 @@ +get_prices_for_date: + fields: + date: + required: true + selector: + date: + countries: + required: true + default: + - es + - pt + selector: + select: + multiple: true + options: + - es + - pt + translation_key: country diff --git a/homeassistant/components/omie/strings.json b/homeassistant/components/omie/strings.json index fe6e2f85e5bc..1646d7a856b8 100644 --- a/homeassistant/components/omie/strings.json +++ b/homeassistant/components/omie/strings.json @@ -21,5 +21,43 @@ "name": "Portugal spot price" } } + }, + "exceptions": { + "cannot_connect": { + "message": "Error connecting to the OMIE API." + }, + "data_not_available": { + "message": "Prices for {date} have not been published yet." + }, + "date_before_market_start": { + "message": "No quarter-hourly prices exist for {date}. Prices are available from {start_date} onwards." + }, + "entry_not_loaded": { + "message": "The OMIE integration is not loaded." + } + }, + "selector": { + "country": { + "options": { + "es": "Spain", + "pt": "Portugal" + } + } + }, + "services": { + "get_prices_for_date": { + "description": "Retrieves the electricity spot prices (in €/kWh) for a specific date. Prices for the next day are published daily at around 13:30 CET.", + "fields": { + "countries": { + "description": "The countries to get the prices for.", + "name": "Countries" + }, + "date": { + "description": "The date to get the prices for.", + "name": "Date" + } + }, + "name": "Get prices for date" + } } } diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py index 1693454f3864..fdd69d2f5fd6 100644 --- a/homeassistant/components/onedrive/services.py +++ b/homeassistant/components/onedrive/services.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import asdict +import glob from pathlib import Path, PurePosixPath from typing import cast @@ -48,20 +49,82 @@ DELETE_SERVICE_SCHEMA = vol.Schema( CONTENT_SIZE_LIMIT = 250 * 1024 * 1024 -def _read_file_contents( +def _split_glob_pattern(pattern: str) -> tuple[str, str]: + """Split a glob pattern into its non-magic base directory and remaining pattern.""" + parts = Path(pattern).parts + base_parts: list[str] = [] + for part in parts: + if glob.has_magic(part): + break + base_parts.append(part) + base = str(Path(*base_parts)) if base_parts else "." + relative_pattern = str(Path(*parts[len(base_parts) :])) + return base, relative_pattern + + +def _expand_filenames( hass: HomeAssistant, filenames: list[str] -) -> list[tuple[str, bytes]]: - """Return the mime types and file contents for each file.""" - missing: list[str] = [] +) -> list[tuple[str, str]]: + """Expand wildcard patterns, preserving subfolder structure.""" + expanded: dict[str, str] = {} + no_matches: list[str] = [] for filename in filenames: - if not hass.config.is_allowed_path(filename): + if not glob.has_magic(filename) or Path(filename).is_file(): + expanded.setdefault(filename, Path(filename).name) + continue + base, relative_pattern = _split_glob_pattern(filename) + + if not hass.config.is_allowed_path(base): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="no_access_to_path", - translation_placeholders={"filename": filename}, + translation_placeholders={"filename": base}, ) - if not Path(filename).exists(): - missing.append(filename) + matches = sorted( + match + for match in glob.glob(relative_pattern, root_dir=base, recursive=True) + if (Path(base) / match).is_file() + ) + if not matches: + no_matches.append(filename) + continue + for match in matches: + full_path = str(Path(base) / match) + relative_path = str(PurePosixPath(*Path(match).parts)) + expanded.setdefault(full_path, relative_path) + if no_matches: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_files_match_pattern", + translation_placeholders={ + "patterns": ", ".join(f"`{p}`" for p in no_matches) + }, + ) + return list(expanded.items()) + + +def _destination_parts(relative_path: str) -> tuple[str, str]: + """Split a relative path into its subfolder path and file name.""" + path = PurePosixPath(relative_path) + parent = str(path.parent) + return ("" if parent == "." else parent, path.name) + + +def _read_file_contents( + hass: HomeAssistant, filenames: list[str] +) -> list[tuple[str, bytes]]: + """Return the destination-relative path and file contents for each file.""" + files = _expand_filenames(hass, filenames) + missing: list[str] = [] + for full_path, _ in files: + if not hass.config.is_allowed_path(full_path): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": full_path}, + ) + if not Path(full_path).exists(): + missing.append(full_path) if missing: raise HomeAssistantError( translation_domain=DOMAIN, @@ -71,20 +134,20 @@ def _read_file_contents( }, ) results = [] - for filename in filenames: - filename_path = Path(filename) - file_size = filename_path.stat().st_size + for full_path, relative_path in files: + path = Path(full_path) + file_size = path.stat().st_size if file_size > CONTENT_SIZE_LIMIT: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="file_too_large", translation_placeholders={ - "filename": filename, + "filename": full_path, "size": str(file_size), "limit": str(CONTENT_SIZE_LIMIT), }, ) - results.append((filename_path.name, filename_path.read_bytes())) + results.append((relative_path, path.read_bytes())) return results @@ -121,18 +184,33 @@ def async_setup_services(hass: HomeAssistant) -> None: hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID] ) client = config_entry.runtime_data.client - upload_tasks = [] file_results = await hass.async_add_executor_job( _read_file_contents, hass, call.data[CONF_FILENAME] ) - # make sure the destination folder exists + # make sure the destination folders exist, preserving subfolder structure + folder_ids: dict[str, str] = {} try: - folder_id = (await client.get_approot()).id + base_folder_id = (await client.get_approot()).id for folder in ( cast(str, call.data[CONF_DESTINATION_FOLDER]).strip("/").split("/") ): - folder_id = (await client.create_folder(folder_id, folder)).id + base_folder_id = (await client.create_folder(base_folder_id, folder)).id + folder_ids[""] = base_folder_id + + for relative_path, _ in file_results: + sub_folder, _ = _destination_parts(relative_path) + if sub_folder in folder_ids: + continue + parent_id = base_folder_id + accumulated = "" + for part in PurePosixPath(sub_folder).parts: + accumulated = f"{accumulated}/{part}" if accumulated else part + if accumulated not in folder_ids: + folder_ids[accumulated] = ( + await client.create_folder(parent_id, part) + ).id + parent_id = folder_ids[accumulated] except OneDriveException as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -140,10 +218,12 @@ def async_setup_services(hass: HomeAssistant) -> None: translation_placeholders={"message": str(err)}, ) from err - upload_tasks = [ - client.upload_file(folder_id, file_name, content) - for file_name, content in file_results - ] + upload_tasks = [] + for relative_path, content in file_results: + sub_folder, name = _destination_parts(relative_path) + upload_tasks.append( + client.upload_file(folder_ids[sub_folder], name, content) + ) try: upload_results = await asyncio.gather(*upload_tasks) except OneDriveException as err: diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 5ba210929b00..11befd773654 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -117,6 +117,9 @@ "no_access_to_path": { "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" }, + "no_files_match_pattern": { + "message": "No files match the following patterns: {patterns}" + }, "oauth2_implementation_unavailable": { "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" }, @@ -179,7 +182,7 @@ "name": "Destination folder" }, "filename": { - "description": "One or more paths to files to upload.", + "description": "One or more paths to files to upload. Supports wildcards, for example `/config/www/*.jpg` to upload all JPG files in a folder, or `/config/www/**/*.jpg` to also include subfolders. Subfolders matched by a wildcard are recreated inside the destination folder. The characters `*`, `?` and `[` are treated as wildcards; to upload a file whose name contains one of these characters literally, make sure the file exists under that exact name.", "example": "{example_image_path}", "name": "Filenames" } diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index d29370d58d8b..c41c0e7de568 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -14,7 +14,7 @@ "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], "requirements": [ - "onvif-zeep-async==4.2.0", + "onvif-zeep-async==4.2.1", "onvif_parsers==2.3.0", "WSDiscovery==2.1.2" ] diff --git a/homeassistant/components/open_meteo/coordinator.py b/homeassistant/components/open_meteo/coordinator.py index e4834994a12b..138594cb204e 100644 --- a/homeassistant/components/open_meteo/coordinator.py +++ b/homeassistant/components/open_meteo/coordinator.py @@ -14,7 +14,7 @@ from open_meteo import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE +from homeassistant.const import CONF_ZONE, EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -49,8 +49,8 @@ class OpenMeteoDataUpdateCoordinator(DataUpdateCoordinator[Forecast]): try: return await self.open_meteo.forecast( - latitude=zone.attributes[ATTR_LATITUDE], - longitude=zone.attributes[ATTR_LONGITUDE], + latitude=zone.attributes[EntityStateAttribute.LATITUDE], + longitude=zone.attributes[EntityStateAttribute.LONGITUDE], current_weather=True, daily=[ DailyParameters.PRECIPITATION_SUM, diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 5be81a48a75f..1631b3f9df5b 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.21.0", "python-open-router==0.3.3"] + "requirements": ["openai==2.45.0", "python-open-router==0.3.3"] } diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index f34f88c2cae7..77ccb98d9a35 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -36,7 +36,7 @@ from homeassistant.helpers import ( selector, ) from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( CONF_CHAT_MODEL, @@ -386,7 +386,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: # 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 + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -396,20 +396,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index e2aa19b4ad23..05ed4fe18f5f 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -20,12 +20,11 @@ from homeassistant.config_entries import ( SubentryFlowResult, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_PROMPT, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm @@ -49,6 +48,7 @@ from .const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, @@ -78,6 +78,7 @@ from .const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_IMAGE_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_PRO_MODE, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, @@ -422,6 +423,18 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) + if model.startswith("gpt-5.6"): + step_schema.update( + { + vol.Optional( + CONF_PRO_MODE, + default=RECOMMENDED_PRO_MODE, + ): bool, + } + ) + elif CONF_PRO_MODE in options: + options.pop(CONF_PRO_MODE) + if model.startswith("gpt-5"): step_schema.update( { @@ -593,6 +606,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): return [] models_reasoning_map: dict[str | tuple[str, ...], list[str]] = { + "gpt-5.6": ["none", "low", "medium", "high", "xhigh", "max"], ("gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"): ["medium", "high", "xhigh"], ("gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"): [ "none", @@ -654,8 +668,8 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): { "role": "system", "content": "Where are the following coordinates located: " - f"({zone_home.attributes[ATTR_LATITUDE]}," - f" {zone_home.attributes[ATTR_LONGITUDE]})?", + f"({zone_home.attributes[EntityStateAttribute.LATITUDE]}," + f" {zone_home.attributes[EntityStateAttribute.LONGITUDE]})?", } ], text={ diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 5236a0d9f53a..6f76455b0c02 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -20,6 +20,7 @@ CONF_IMAGE_MODEL = "image_model" CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" +CONF_PRO_MODE = "pro_mode" CONF_REASONING_EFFORT = "reasoning_effort" CONF_REASONING_SUMMARY = "reasoning_summary" CONF_RECOMMENDED = "recommended" @@ -41,6 +42,7 @@ RECOMMENDED_CODE_INTERPRETER = False RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_IMAGE_MODEL = "gpt-image-2" RECOMMENDED_MAX_TOKENS = 3000 +RECOMMENDED_PRO_MODE = False RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_STORE_RESPONSES = False RECOMMENDED_REASONING_SUMMARY = "auto" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 5ac94beb19a5..5fa447e9b925 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -73,6 +73,7 @@ from .const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, @@ -93,6 +94,7 @@ from .const import ( RECOMMENDED_CHAT_MODEL, RECOMMENDED_IMAGE_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_PRO_MODE, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, @@ -497,7 +499,7 @@ class OpenAIBaseLLMEntity(Entity): entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log( + async def _async_handle_chat_log( # noqa: C901 self, chat_log: conversation.ChatLog, structure_name: str | None = None, @@ -528,11 +530,16 @@ class OpenAIBaseLLMEntity(Entity): if not model_args["model"].startswith("gpt-5-pro") else "high", # GPT-5 pro only supports reasoning.effort: high } + reasoning_summary = options.get( CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY ) if reasoning_summary != "off": reasoning["summary"] = reasoning_summary + + if options.get(CONF_PRO_MODE, RECOMMENDED_PRO_MODE): + reasoning["mode"] = "pro" + model_args["reasoning"] = reasoning model_args["include"] = ["reasoning.encrypted_content"] diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 7460bf938a7f..95fb8fc7d211 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==2.21.0"] + "requirements": ["openai==2.45.0"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 03637baf4868..6b7d21ea44c2 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -71,6 +71,7 @@ "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::code_interpreter%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::image_model%]", "inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::inline_citations%]", + "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::pro_mode%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", @@ -82,6 +83,7 @@ "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::code_interpreter%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::image_model%]", "inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::inline_citations%]", + "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::pro_mode%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", @@ -138,6 +140,7 @@ "code_interpreter": "Enable code interpreter tool", "image_model": "Image generation model", "inline_citations": "Include links in web search results", + "pro_mode": "Pro mode", "reasoning_effort": "Reasoning effort", "reasoning_summary": "Reasoning summary", "search_context_size": "Search context size", @@ -149,6 +152,7 @@ "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", "image_model": "The model to use when generating images", "inline_citations": "If disabled, additional prompt is added to ask the model to not include source citations", + "pro_mode": "Perform more model work to improve reliability on difficult tasks and return a single final answer", "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model", "search_context_size": "High level guidance for the amount of context window space to use for the search", @@ -233,6 +237,7 @@ "options": { "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", + "max": "Max", "medium": "[%key:common::state::medium%]", "minimal": "Minimal", "none": "None", diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 3bbaabf3b0f7..323b2ae28867 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -287,9 +287,7 @@ async def async_setup_entry( if entity_entry.config_entry_id != entry.entry_id: continue entity_registry.async_remove(entity_entry.entity_id) - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) # Prune sensor tracking for accounts that are no longer present if created_sensors: diff --git a/homeassistant/components/oralb/icons.json b/homeassistant/components/oralb/icons.json index 7f28dede4ae1..a3b464edc1d2 100644 --- a/homeassistant/components/oralb/icons.json +++ b/homeassistant/components/oralb/icons.json @@ -37,8 +37,7 @@ "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" + "sector_4": "mdi:circle-slice-8" } }, "toothbrush_state": { diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index a15ea81e9067..7bc928e46e5b 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -13,5 +13,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["oralb_ble"], - "requirements": ["oralb-ble==1.1.0"] + "requirements": ["oralb-ble==1.1.1"] } diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 286defcf35f8..28fb2be64c74 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -3,13 +3,7 @@ from typing import override from oralb_ble import OralBSensor, SensorUpdate -from oralb_ble.parser import ( - IO_SERIES_MODES, - PRESSURE, - SECTOR_MAP, - SMART_SERIES_MODES, - STATES, -) +from oralb_ble.parser import IO_SERIES_MODES, PRESSURE, SMART_SERIES_MODES, STATES from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, @@ -46,7 +40,7 @@ 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"}], + options=["no_sector", *(f"sector_{sector}" for sector in range(1, 8))], device_class=SensorDeviceClass.ENUM, ), OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription( diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index 2aa29d12f13e..de7e62978a48 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -60,7 +60,9 @@ "sector_2": "Sector 2", "sector_3": "Sector 3", "sector_4": "Sector 4", - "success": "Success" + "sector_5": "Sector 5", + "sector_6": "Sector 6", + "sector_7": "Sector 7" } }, "sector_timer": { diff --git a/homeassistant/components/overkiz/application_credentials.py b/homeassistant/components/overkiz/application_credentials.py index a3c01abc4a7b..c537edcf1b4c 100644 --- a/homeassistant/components/overkiz/application_credentials.py +++ b/homeassistant/components/overkiz/application_credentials.py @@ -43,6 +43,6 @@ class OverkizOAuth2Implementation(LocalOAuth2ImplementationWithPkce): otherwise drop it. """ return super().extra_authorize_data | { - "scope": REXEL_OAUTH_SCOPE, + "scope": f"{REXEL_OAUTH_SCOPE} offline_access", "p": REXEL_OAUTH_POLICY, } diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index f614d9555670..957767709dd8 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast, override -from pyoverkiz.enums import OverkizCommandParam, OverkizState +from pyoverkiz.enums import OverkizCommandParam, OverkizState, UIClass, UIWidget from pyoverkiz.types import StateType as OverkizStateType from homeassistant.components.binary_sensor import ( @@ -26,6 +26,10 @@ class OverkizBinarySensorDescription(BinarySensorEntityDescription): value_fn: Callable[[OverkizStateType], bool] + # Restrict this entity to the listed device types (UIWidget/UIClass). + # When omitted, the sensor applies to any device exposing the state. + device_types: list[UIWidget | UIClass] | None = None + BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ # RainSensor/RainSensor @@ -135,6 +139,23 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ ) ), ), + # ContactSensor/IntrusionEventSensor + # (io:SomfyWindowStateSensor, io:SomfySlidingWindowStateSensor) + OverkizBinarySensorDescription( + key=OverkizState.CORE_OPEN_CLOSED, + device_class=BinarySensorDeviceClass.WINDOW, + value_fn=lambda state: state == OverkizCommandParam.OPEN, + # core:OpenClosedState is also exposed by all cover devices, + # restrict this to ContactSensor devices (e.g. the Somfy IntelliTAG) + device_types=[UIClass.CONTACT_SENSOR], + ), + # ContactSensor/IntrusionEventSensor (io:SomfyWindowStateSensor) + OverkizBinarySensorDescription( + key=OverkizState.CORE_TILTED, + name="Tilt", + icon="mdi:angle-acute", + value_fn=bool, + ), ] SUPPORTED_STATES = { @@ -166,6 +187,11 @@ async def async_setup_entry( ) for state in device.definition.states if (description := SUPPORTED_STATES.get(state)) + and ( + description.device_types is None + or device.widget in description.device_types + or device.ui_class in description.device_types + ) ) async_add_entities(entities) diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 4f56034d03c0..e68c9d95b68a 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -57,6 +57,9 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.EVO_HOME_CONTROLLER: EvoHomeController, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, + UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: ( + ValveHeatingTemperatureInterface + ), UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, UIWidget.ATLANTIC_PASS_APC_HEAT_PUMP: AtlanticPassAPCHeatPumpMainComponent, } 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 099eb00ba0bb..8b813372fd35 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 @@ -105,9 +105,11 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" states = self.device.states - if (state := states[OverkizState.CORE_OPERATING_MODE]) and state.value_as_str: + if ( + state := states.get(OverkizState.CORE_OPERATING_MODE) + ) and state.value_as_str: return OVERKIZ_TO_HVAC_MODE[state.value_as_str] - if (state := states[OverkizState.CORE_ON_OFF]) and state.value_as_str: + if (state := states.get(OverkizState.CORE_ON_OFF)) and state.value_as_str: return OVERKIZ_TO_HVAC_MODE[state.value_as_str] return HVACMode.OFF @@ -123,7 +125,9 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( def hvac_action(self) -> HVACAction: """Return the current running hvac operation ie. heating, idle, off.""" states = self.device.states - if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str: + if ( + state := states.get(OverkizState.CORE_REGULATION_MODE) + ) and state.value_as_str: return OVERKIZ_TO_HVAC_ACTION[state.value_as_str] return HVACAction.OFF @@ -135,12 +139,12 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( states = self.device.states if ( - state := states[OverkizState.IO_TARGET_HEATING_LEVEL] + state := states.get(OverkizState.IO_TARGET_HEATING_LEVEL) ) and state.value_as_str: return OVERKIZ_TO_PRESET_MODE[state.value_as_str] if ( - operating_mode := states[OverkizState.CORE_OPERATING_MODE] + operating_mode := states.get(OverkizState.CORE_OPERATING_MODE) ) and operating_mode.value_as_str == OverkizCommandParam.EXTERNAL: return PRESET_EXTERNAL return None diff --git a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py index c7069fe4ba08..21a4db0e12dc 100644 --- a/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py +++ b/homeassistant/components/overkiz/climate/hitachi_air_to_water_heating_zone.py @@ -47,6 +47,14 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + # Each zone is its own device; zone 1 is the default, zone 2 overrides below. + _auto_manu_mode_state = OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1 + _room_temperature_state = OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1 + _thermostat_setting_state = OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1 + _set_thermostat_setting_command = ( + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1 + ) + def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator ) -> None: @@ -56,12 +64,24 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): if self._attr_device_info: self._attr_device_info["manufacturer"] = "Hitachi" + if "Zone2" in self.device.controllable_name: + self._auto_manu_mode_state = OverkizState.MODBUS_AUTO_MANU_MODE_ZONE2 + self._room_temperature_state = ( + OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE2 + ) + self._thermostat_setting_state = ( + OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE2 + ) + self._set_thermostat_setting_command = ( + OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_2 + ) + @property @override def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" if ( - state := self.device.states.get(OverkizState.MODBUS_AUTO_MANU_MODE_ZONE_1) + state := self.device.states.get(self._auto_manu_mode_state) ) and state.value_as_str: return OVERKIZ_TO_HVAC_MODE[state.value_as_str] @@ -96,9 +116,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): @override def current_temperature(self) -> float | None: """Return the current temperature.""" - current_temperature = self.device.states.get( - OverkizState.MODBUS_ROOM_AMBIENT_TEMPERATURE_STATUS_ZONE_1 - ) + current_temperature = self.device.states.get(self._room_temperature_state) if current_temperature: return current_temperature.value_as_float @@ -109,9 +127,7 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): @override def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - target_temperature = self.device.states.get( - OverkizState.MODBUS_THERMOSTAT_SETTING_CONTROL_ZONE_1 - ) + target_temperature = self.device.states.get(self._thermostat_setting_state) if target_temperature: return target_temperature.value_as_float @@ -124,5 +140,5 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity): temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) await self.executor.async_execute_command( - OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature) + self._set_thermostat_setting_command, float(temperature) ) diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index ec3fcd218b8f..5793364ef955 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -11,7 +11,11 @@ from pyoverkiz.auth.credentials import ( UsernamePasswordCredentials, ) from pyoverkiz.client import GatewayCandidate, OverkizClient -from pyoverkiz.const import SERVERS_WITH_LOCAL_API, SUPPORTED_SERVERS +from pyoverkiz.const import ( + REXEL_OAUTH_CLIENT_ID, + SERVERS_WITH_LOCAL_API, + SUPPORTED_SERVERS, +) from pyoverkiz.enums import APIType, Server from pyoverkiz.exceptions import ( ApplicationNotAllowedError, @@ -28,6 +32,10 @@ from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.utils import create_local_server_config, is_overkiz_gateway import voluptuous as vol +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import ( CONF_HOST, @@ -179,6 +187,18 @@ class OverkizConfigFlow( description_placeholders={"local_api_docs": LOCAL_API_DOCS_URL}, ) + @override + async def async_step_pick_implementation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Start the Rexel OAuth2 flow, re-importing the credential if removed.""" + await async_import_client_credential( + self.hass, + DOMAIN, + ClientCredential(REXEL_OAUTH_CLIENT_ID, "", name="Rexel"), + ) + return await super().async_step_pick_implementation(user_input) + async def async_step_cloud( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index b0cbe6f9c8a8..6748b59fa545 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -119,6 +119,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, + UIWidget.THERMOSTAT_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, UIWidget.TSK_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, } diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 818baaada0fb..34397ff794e2 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -88,6 +88,17 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [ invert_position=False, is_closed_state=OverkizState.CORE_OPEN_CLOSED, ), + # Needs override to omit is_closed_state, since OpenClosedState is unreliable + # uiClass is RollerShutter + OverkizCoverDescription( + key=UIWidget.POSITIONABLE_ROLLER_SHUTTER_UNO, + device_class=CoverDeviceClass.SHUTTER, + current_position_state=OverkizState.CORE_CLOSURE, + set_position_command=OverkizCommand.SET_CLOSURE, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + stop_command=OverkizCommand.STOP, + ), # Needs override to support lower/upper position control # uiClass is RollerShutter OverkizCoverDescription( @@ -343,8 +354,8 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [ # uiClass is Generic (not mapped to cover as this is a Generic device class) OverkizCoverDescription( key=UIWidget.RTS_GENERIC, - open_command=OverkizCommand.OPEN, - close_command=OverkizCommand.CLOSE, + open_command=OverkizCommand.UP, + close_command=OverkizCommand.DOWN, stop_command=OverkizCommand.STOP, ), ## diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index a4167e6a9f5d..0468340432a1 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -14,7 +14,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz[nexity]==2.0.3"], + "requirements": ["pyoverkiz[nexity]==2.1.0"], "zeroconf": [ { "name": "gateway*", diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 72198368f18b..81e7de6ae857 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import cast, override from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState @@ -11,6 +11,7 @@ from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, + NumberMode, ) from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant @@ -19,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES from .coordinator import OverkizDataUpdateCoordinator +from .cover import SUPPORTED_DEVICES as SUPPORTED_COVER_DEVICES from .entity import OverkizDescriptiveEntity BOOST_MODE_DURATION_DELAY = 1 @@ -185,6 +187,36 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ device_class=NumberDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.DAYS, ), + # AtlanticElectricalTowelDryer - boost mode duration in minutes + OverkizNumberDescription( + key=OverkizState.IO_BOOST_DURATION_USER_PARAMETER, + name="Boost mode duration", + icon="mdi:radiator", + command=OverkizCommand.SET_TOWEL_DRYER_BOOST_MODE_DURATION, + native_min_value=0, + native_max_value=60, + native_step=1, + mode=NumberMode.BOX, + max_value_state_name=OverkizState.IO_BOOST_DURATION_MAX, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), + # AtlanticElectricalTowelDryer - drying duration in minutes (0 - 120) + OverkizNumberDescription( + key=OverkizState.IO_DRYING_DURATION_USER_PARAMETER, + name="Drying duration", + icon="mdi:tumble-dryer", + command=OverkizCommand.SET_DRYING_DURATION, + native_min_value=0, + native_max_value=120, + native_step=1, + mode=NumberMode.BOX, + max_value_state_name=OverkizState.IO_DRYING_DURATION_MAX, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), ] SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCRIPTIONS} @@ -206,15 +238,31 @@ async def async_setup_entry( ): continue - entities.extend( - OverkizNumber( - device.device_url, - data.coordinator, - description, + for state in device.definition.states: + if not (description := SUPPORTED_STATES.get(state)): + continue + + if not device.supports_command(description.command): + continue + + # Mirror the cover's position inversion. + if description.key == OverkizState.CORE_MEMORIZED_1_POSITION and ( + cover_description := ( + SUPPORTED_COVER_DEVICES.get(device.widget) + or SUPPORTED_COVER_DEVICES.get(device.ui_class) + ) + ): + description = replace( + description, inverted=cover_description.invert_position + ) + + entities.append( + OverkizNumber( + device.device_url, + data.coordinator, + description, + ) ) - for state in device.definition.states - if (description := SUPPORTED_STATES.get(state)) - ) async_add_entities(entities) diff --git a/homeassistant/components/overkiz/quality_scale.yaml b/homeassistant/components/overkiz/quality_scale.yaml new file mode 100644 index 000000000000..607b2ee9373c --- /dev/null +++ b/homeassistant/components/overkiz/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + config-flow: done + brands: done + dependency-transparency: done + common-modules: done + has-entity-name: done + action-setup: done + appropriate-polling: done + test-before-configure: done + entity-event-setup: done + unique-config-entry: done + entity-unique-id: done + docs-installation-instructions: done + docs-removal-instructions: done + test-before-setup: done + docs-high-level-description: done + config-flow-test-coverage: todo + docs-actions: done + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + runtime-data: done + + # Silver + log-when-unavailable: done + config-entry-unloading: done + reauthentication-flow: done + action-exceptions: done + docs-installation-parameters: done + integration-owner: done + parallel-updates: todo + test-coverage: todo + docs-configuration-parameters: + status: exempt + comment: The integration does not provide configuration parameters. + entity-unavailable: done + + # Gold + docs-examples: todo + discovery-update-info: todo + entity-device-class: done + entity-translations: todo + docs-data-update: done + entity-disabled-by-default: done + discovery: done + exception-translations: todo + devices: done + docs-supported-devices: done + icon-translations: todo + docs-known-limitations: done + stale-devices: todo + docs-supported-functions: todo + repair-issues: todo + reconfiguration-flow: todo + entity-category: done + dynamic-devices: todo + docs-troubleshooting: todo + diagnostics: done + docs-use-cases: todo + + # Platinum + async-dependency: done + strict-typing: done + inject-websession: done diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index b955d2a50a40..a48ac7669b41 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -9,9 +9,14 @@ LOGGER = logging.getLogger(__package__) REQUESTS = "requests" +ATTR_MEDIA_TYPE = "media_type" +ATTR_QUERY = "query" +ATTR_REQUESTED_BY = "requested_by" +ATTR_SEASONS = "seasons" ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" -ATTR_REQUESTED_BY = "requested_by" +ATTR_MEDIA_ID = "media_id" + EVENT_KEY = f"{DOMAIN}_event" diff --git a/homeassistant/components/overseerr/icons.json b/homeassistant/components/overseerr/icons.json index 9b63943f8989..290aa0a976dc 100644 --- a/homeassistant/components/overseerr/icons.json +++ b/homeassistant/components/overseerr/icons.json @@ -32,6 +32,12 @@ "services": { "get_requests": { "service": "mdi:multimedia" + }, + "request_media": { + "service": "mdi:download" + }, + "search_media": { + "service": "mdi:magnify" } } } diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 5354102472ca..9405b21ea6dd 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -1,7 +1,8 @@ """Define services for the Overseerr integration.""" +import ast from dataclasses import asdict -from typing import Any, cast +from typing import Any, Literal, cast from python_overseerr import OverseerrClient, OverseerrConnectionError import voluptuous as vol @@ -18,10 +19,23 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import service from homeassistant.util.json import JsonValueType -from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER +from .const import ( + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_QUERY, + ATTR_REQUESTED_BY, + ATTR_SEASONS, + ATTR_SORT_ORDER, + ATTR_STATUS, + DOMAIN, + LOGGER, +) from .coordinator import OverseerrConfigEntry SERVICE_GET_REQUESTS = "get_requests" +SERVICE_SEARCH_MEDIA = "search_media" +SERVICE_REQUEST_MEDIA = "request_media" + SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY_ID): str, @@ -33,6 +47,29 @@ SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( } ) +SERVICE_SEARCH_MEDIA_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_QUERY): str, + } +) + +SERVICE_REQUEST_MEDIA_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEDIA_TYPE): vol.In(["movie", "tv"]), + vol.Required(ATTR_MEDIA_ID): vol.All( + vol.Coerce(int), + vol.Range(min=1), + ), + vol.Optional(ATTR_SEASONS): vol.Any( + vol.Coerce(int), + [vol.Coerce(int)], + str, + ), + } +) + async def _get_media( client: OverseerrClient, media_type: str, identifier: int @@ -52,7 +89,7 @@ async def _get_media( async def _async_get_requests(call: ServiceCall) -> ServiceResponse: - """Get requests made to Overseerr.""" + """Get requests made to Seerr.""" entry: OverseerrConfigEntry = service.async_get_config_entry( call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] ) @@ -92,9 +129,79 @@ async def _async_get_requests(call: ServiceCall) -> ServiceResponse: return {"requests": cast(list[JsonValueType], result)} +async def _async_search_media(call: ServiceCall) -> ServiceResponse: + """Search for media in Seerr.""" + entry: OverseerrConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + query = call.data[ATTR_QUERY] + + LOGGER.debug("Searching for '%s'", query) + try: + search_results = await client.search(query) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + + return { + "results": cast( + list[JsonValueType], [asdict(result) for result in search_results] + ) + } + + +async def _async_request_media(call: ServiceCall) -> ServiceResponse: + """Request media in Seerr.""" + entry: OverseerrConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + client = entry.runtime_data.client + media_type = call.data[ATTR_MEDIA_TYPE] + media_id = call.data[ATTR_MEDIA_ID] + seasons = parse_seasons_input(call.data.get(ATTR_SEASONS)) + + LOGGER.debug( + "Requesting %s with media ID %s (seasons: %s)", + media_type, + media_id, + seasons or "none", + ) + try: + # We can always pass in the seasons, they will be ignored if the media type isn't TV + request = await client.create_request(media_type, media_id, seasons) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + + return {"request": cast(JsonValueType, asdict(request))} + + +def parse_seasons_input(seasons_input: Any | None) -> Literal["all"] | list[int]: + """Parse all possible inputs to "all" or a list of integers.""" + seasons_str = str(seasons_input).strip() + if seasons_input is None or seasons_str in ("", "all"): + return "all" + + try: + parsed = ast.literal_eval(seasons_str) + if isinstance(parsed, int): + return [parsed] + return [int(season) for season in parsed] + except ValueError, SyntaxError, TypeError: + LOGGER.error("Unable to cast input to a list '%s'", seasons_input) + return "all" + + @callback def async_setup_services(hass: HomeAssistant) -> None: - """Set up the services for the Overseerr integration.""" + """Set up the services for the Seerr integration.""" hass.services.async_register( DOMAIN, @@ -103,3 +210,19 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_GET_REQUESTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SEARCH_MEDIA, + _async_search_media, + schema=SERVICE_SEARCH_MEDIA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REQUEST_MEDIA, + _async_request_media, + schema=SERVICE_REQUEST_MEDIA_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/overseerr/services.yaml b/homeassistant/components/overseerr/services.yaml index c7593fc5aee1..3fcf49ba8f5d 100644 --- a/homeassistant/components/overseerr/services.yaml +++ b/homeassistant/components/overseerr/services.yaml @@ -28,3 +28,40 @@ get_requests: number: min: 0 mode: box + +search_media: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + query: + required: true + selector: + text: + +request_media: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: overseerr + media_type: + required: true + selector: + select: + options: + - movie + - tv + translation_key: request_media_type + media_id: + required: true + selector: + number: + min: 1 + mode: box + seasons: + selector: + text: diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 9ddfc6929f6d..aa139f6cf919 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -118,6 +118,12 @@ } }, "selector": { + "request_media_type": { + "options": { + "movie": "Movie", + "tv": "TV" + } + }, "request_sort_order": { "options": { "added": "Added", @@ -157,6 +163,42 @@ } }, "name": "Get requests" + }, + "request_media": { + "description": "Creates a media request in Seerr.", + "fields": { + "config_entry_id": { + "description": "The Seerr instance to create the request on.", + "name": "Seerr instance" + }, + "media_id": { + "description": "The TMDB ID or TVDB ID of the media to request.", + "name": "Media ID" + }, + "media_type": { + "description": "Type of media to request.", + "name": "Media type" + }, + "seasons": { + "description": "For TV requests: seasons to request. Optional list of integers (e.g., [1, 2, 4]). If omitted, all seasons will be requested.", + "name": "Seasons" + } + }, + "name": "Request media" + }, + "search_media": { + "description": "Searches for media in Seerr.", + "fields": { + "config_entry_id": { + "description": "The Seerr instance to search.", + "name": "Seerr instance" + }, + "query": { + "description": "The search query.", + "name": "Query" + } + }, + "name": "Search media" } } } diff --git a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json index f2393ec1ade1..93feba804811 100644 --- a/homeassistant/components/ovhcloud_ai_endpoints/manifest.json +++ b/homeassistant/components/ovhcloud_ai_endpoints/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["openai==2.21.0"] + "requirements": ["openai==2.45.0"] } diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index d76d07a8b82e..6f3ba4b48511 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -10,14 +10,9 @@ from aiohttp import web import voluptuous as vol from homeassistant.components import cloud, mqtt, webhook +from homeassistant.components.device_tracker import TrackerEntityStateAttribute from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_WEBHOOK_ID, - Platform, -) +from homeassistant.const import CONF_WEBHOOK_ID, EntityStateAttribute, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -194,13 +189,14 @@ async def handle_webhook( response = [ { "_type": "location", - "lat": person.attributes["latitude"], - "lon": person.attributes["longitude"], + "lat": person.attributes[EntityStateAttribute.LATITUDE], + "lon": person.attributes[EntityStateAttribute.LONGITUDE], "tid": "".join(p[0] for p in person.name.split(" ")[:2]), "tst": int(person.last_updated.timestamp()), } for person in hass.states.async_all("person") - if "latitude" in person.attributes and "longitude" in person.attributes + if EntityStateAttribute.LATITUDE in person.attributes + and EntityStateAttribute.LONGITUDE in person.attributes ] if message["_type"] == "encrypted" and context.secret: @@ -297,9 +293,11 @@ class OwnTracksContext: device_tracker_state = hass.states.get(f"device_tracker.{dev_id}") if device_tracker_state is not None: - acc = device_tracker_state.attributes.get(ATTR_GPS_ACCURACY) - lat = device_tracker_state.attributes.get(ATTR_LATITUDE) - lon = device_tracker_state.attributes.get(ATTR_LONGITUDE) + acc = device_tracker_state.attributes.get( + TrackerEntityStateAttribute.GPS_ACCURACY + ) + lat = device_tracker_state.attributes.get(EntityStateAttribute.LATITUDE) + lon = device_tracker_state.attributes.get(EntityStateAttribute.LONGITUDE) if lat is not None and lon is not None: kwargs["gps"] = (lat, lon) diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 3711b60f9cfe..477bf74c4cfa 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -4,18 +4,14 @@ from typing import Any, override from homeassistant.components.device_tracker import ( - ATTR_SOURCE_TYPE, DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityStateAttribute, SourceType, TrackerEntity, + TrackerEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, -) +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -177,10 +173,13 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): self._data = { "host_name": state.name, - "gps": (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)), - "gps_accuracy": attr.get(ATTR_GPS_ACCURACY), + "gps": ( + attr.get(EntityStateAttribute.LATITUDE), + attr.get(EntityStateAttribute.LONGITUDE), + ), + "gps_accuracy": attr.get(TrackerEntityStateAttribute.GPS_ACCURACY), "battery": attr.get(ATTR_BATTERY_LEVEL), - "source_type": attr.get(ATTR_SOURCE_TYPE), + "source_type": attr.get(DeviceTrackerEntityStateAttribute.SOURCE_TYPE), "attributes": attributes, } diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index b59ec84749d8..668b13515481 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -8,7 +8,8 @@ from nacl.secret import SecretBox from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import SourceType -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME +from homeassistant.components.zone import ZoneEntityStateAttribute +from homeassistant.const import STATE_HOME, EntityStateAttribute from homeassistant.util import decorator, dt as dt_util, slugify from .const import ( @@ -108,10 +109,10 @@ def _set_gps_from_zone(kwargs, location, zone): """ if zone is not None: kwargs["gps"] = ( - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], ) - kwargs["gps_accuracy"] = zone.attributes["radius"] + kwargs["gps_accuracy"] = zone.attributes[ZoneEntityStateAttribute.RADIUS] kwargs["location_name"] = location return kwargs diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index e88e3f885cc4..4fe461f27e5b 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -131,8 +131,8 @@ class PegelOnlineSensor(PegelOnlineEntity, SensorEntity): if self.station.latitude and self.station.longitude: self._attr_extra_state_attributes.update( { - ATTR_LATITUDE: self.station.latitude, - ATTR_LONGITUDE: self.station.longitude, + EntityStateAttribute.LATITUDE: self.station.latitude, + EntityStateAttribute.LONGITUDE: self.station.longitude, } ) diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index ff3127d75a8e..898f43277400 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -1,59 +1,37 @@ """The MyPermobil integration.""" -import logging - -from mypermobil import MyPermobil, MyPermobilClientException - -from homeassistant.const import ( - CONF_CODE, - CONF_EMAIL, - CONF_REGION, - CONF_TOKEN, - CONF_TTL, - Platform, -) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import issue_registry as ir -from .const import APPLICATION -from .coordinator import MyPermobilCoordinator, PermobilConfigEntry - -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] - -_LOGGER = logging.getLogger(__name__) +DOMAIN = "permobil" -async def async_setup_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool: - """Set up MyPermobil from a config entry.""" - - # create the API object from the config and save it in hass - session = async_get_clientsession(hass) - p_api = MyPermobil( - application=APPLICATION, - session=session, - email=entry.data[CONF_EMAIL], - region=entry.data[CONF_REGION], - code=entry.data[CONF_CODE], - token=entry.data[CONF_TOKEN], - expiration_date=entry.data[CONF_TTL], +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: + """Set up 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/permobil", + }, ) - try: - p_api.self_authenticate() - except MyPermobilClientException as err: - _LOGGER.error("Error authenticating %s", err) - raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err - - # create the coordinator with the API object - coordinator = MyPermobilCoordinator(hass, entry, p_api) - 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: PermobilConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + 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)) diff --git a/homeassistant/components/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py deleted file mode 100644 index 2f9f04251047..000000000000 --- a/homeassistant/components/permobil/binary_sensor.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Platform for binary sensor integration.""" - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any, override - -from mypermobil import BATTERY_CHARGING - -from homeassistant.components.binary_sensor import ( - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .coordinator import PermobilConfigEntry -from .entity import PermobilEntity - - -@dataclass(frozen=True, kw_only=True) -class PermobilBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describes Permobil binary sensor entity.""" - - is_on_fn: Callable[[Any], bool] - available_fn: Callable[[Any], bool] - - -BINARY_SENSOR_DESCRIPTIONS: tuple[PermobilBinarySensorEntityDescription, ...] = ( - PermobilBinarySensorEntityDescription( - is_on_fn=lambda data: data.battery[BATTERY_CHARGING[0]], - available_fn=lambda data: BATTERY_CHARGING[0] in data.battery, - key="is_charging", - translation_key="is_charging", - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: PermobilConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Create and setup the binary sensor.""" - - coordinator = config_entry.runtime_data - - async_add_entities( - PermobilbinarySensor(coordinator=coordinator, description=description) - for description in BINARY_SENSOR_DESCRIPTIONS - ) - - -class PermobilbinarySensor(PermobilEntity, BinarySensorEntity): - """Representation of a Binary Sensor.""" - - entity_description: PermobilBinarySensorEntityDescription - - @property - @override - def is_on(self) -> bool: - """Return True if the wheelchair is charging.""" - return self.entity_description.is_on_fn(self.coordinator.data) - - @property - @override - def available(self) -> bool: - """Return True if the sensor has value.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data - ) diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 5b05a8eabbe2..b1711b894697 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -1,184 +1,11 @@ -"""Config flow for MyPermobil integration.""" +"""Config flow to configure Permobil integration.""" -from collections.abc import Mapping -import logging -from typing import Any, override +from homeassistant.config_entries import ConfigFlow -from mypermobil import ( - MyPermobil, - MyPermobilAPIException, - MyPermobilClientException, - MyPermobilEulaException, -) -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL -from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import ( - TextSelector, - TextSelectorConfig, - TextSelectorType, -) - -from .const import APPLICATION, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -GET_EMAIL_SCHEMA = vol.Schema( - { - vol.Required(CONF_EMAIL): TextSelector( - TextSelectorConfig(type=TextSelectorType.EMAIL) - ), - } -) - -GET_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_CODE): cv.string}) +from . import DOMAIN class PermobilConfigFlow(ConfigFlow, domain=DOMAIN): - """Permobil config flow.""" + """Permobil integration config flow.""" VERSION = 1 - region_names: dict[str, str] = {} - data: dict[str, str] = {} - - def __init__(self) -> None: - """Initialize flow.""" - hass: HomeAssistant = async_get_hass() - session = async_get_clientsession(hass) - self.p_api = MyPermobil(APPLICATION, session=session) - - @override - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Invoke when a user initiates a flow via the user interface.""" - errors: dict[str, str] = {} - - if user_input: - try: - self.p_api.set_email(user_input[CONF_EMAIL]) - except MyPermobilClientException: - _LOGGER.exception("Error validating email") - errors["base"] = "invalid_email" - - self.data.update(user_input) - - await self.async_set_unique_id(self.data[CONF_EMAIL]) - self._abort_if_unique_id_configured() - - if errors or not user_input: - return self.async_show_form( - step_id="user", data_schema=GET_EMAIL_SCHEMA, errors=errors - ) - return await self.async_step_region() - - async def async_step_region( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Invoke when a user initiates a flow via the user interface.""" - errors: dict[str, str] = {} - if not user_input: - # fetch the list of regions names and urls from the api - # for the user to select from. - try: - self.region_names = await self.p_api.request_region_names() - _LOGGER.debug( - "region names %s", - ",".join(list(self.region_names.keys())), - ) - except MyPermobilAPIException: - _LOGGER.exception("Error requesting regions") - errors["base"] = "region_fetch_error" - - else: - region_url = self.region_names[user_input[CONF_REGION]] - - self.data[CONF_REGION] = region_url - self.p_api.set_region(region_url) - _LOGGER.debug("region %s", self.p_api.region) - try: - # tell backend to send code to the users email - await self.p_api.request_application_code() - except MyPermobilAPIException: - _LOGGER.exception("Error requesting code") - errors["base"] = "code_request_error" - - if errors or not user_input: - # the error could either be that the fetch region did not pass - # or that the request application code failed - schema = vol.Schema( - { - vol.Required(CONF_REGION): selector.SelectSelector( - selector.SelectSelectorConfig( - options=list(self.region_names.keys()), - mode=selector.SelectSelectorMode.DROPDOWN, - ) - ), - } - ) - return self.async_show_form( - step_id="region", data_schema=schema, errors=errors - ) - - return await self.async_step_email_code() - - async def async_step_email_code( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Second step in config flow to enter the email code.""" - errors: dict[str, str] = {} - - if user_input: - try: - self.p_api.set_code(user_input[CONF_CODE]) - self.data.update(user_input) - token, ttl = await self.p_api.request_application_token() - self.data[CONF_TOKEN] = token - self.data[CONF_TTL] = ttl - except MyPermobilAPIException, MyPermobilClientException: - # the code did not pass validation by the api client - # or the backend returned an error when trying to validate the code - _LOGGER.exception("Error verifying code") - errors["base"] = "invalid_code" - except MyPermobilEulaException: - # The user has not accepted the EULA - errors["base"] = "unsigned_eula" - - if errors or not user_input: - return self.async_show_form( - step_id="email_code", - data_schema=GET_TOKEN_SCHEMA, - errors=errors, - description_placeholders={"app_name": "MyPermobil"}, - ) - - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), title=self.data[CONF_EMAIL], data=self.data - ) - - return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - try: - email: str = entry_data[CONF_EMAIL] - region: str = entry_data[CONF_REGION] - self.p_api.set_email(email) - self.p_api.set_region(region) - self.data = { - CONF_EMAIL: email, - CONF_REGION: region, - } - await self.p_api.request_application_code() - except MyPermobilAPIException: - _LOGGER.exception("Error requesting code for reauth") - return self.async_abort(reason="unknown") - - return await self.async_step_email_code() diff --git a/homeassistant/components/permobil/const.py b/homeassistant/components/permobil/const.py deleted file mode 100644 index fd5fe673f2a7..000000000000 --- a/homeassistant/components/permobil/const.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Constants for the MyPermobil integration.""" - -DOMAIN = "permobil" - -APPLICATION = "Home Assistant" - - -BATTERY_ASSUMED_VOLTAGE = 25.0 # This is the average voltage over all states of charge -REGIONS = "regions" -KM = "kilometers" -MILES = "miles" diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py deleted file mode 100644 index 13273949e964..000000000000 --- a/homeassistant/components/permobil/coordinator.py +++ /dev/null @@ -1,66 +0,0 @@ -"""DataUpdateCoordinator for permobil integration.""" - -import asyncio -from dataclasses import dataclass -from datetime import timedelta -import logging -from typing import override - -from mypermobil import MyPermobil, MyPermobilAPIException - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -_LOGGER = logging.getLogger(__name__) - -type PermobilConfigEntry = ConfigEntry[MyPermobilCoordinator] - - -@dataclass -class MyPermobilData: - """MyPermobil data stored in the DataUpdateCoordinator.""" - - battery: dict[str, str | float | int | bool | list | dict] - daily_usage: dict[str, str | float | int | list | dict] - records: dict[str, str | float | int | list | dict] - - -class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): - """MyPermobil coordinator.""" - - config_entry: PermobilConfigEntry - - def __init__( - self, hass: HomeAssistant, config_entry: PermobilConfigEntry, p_api: MyPermobil - ) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name="permobil", - update_interval=timedelta(minutes=5), - ) - self.p_api = p_api - - @override - async def _async_update_data(self) -> MyPermobilData: - """Fetch data from the 3 API endpoints.""" - try: - async with asyncio.timeout(10): - battery = await self.p_api.get_battery_info() - daily_usage = await self.p_api.get_daily_usage() - records = await self.p_api.get_usage_records() - return MyPermobilData( - battery=battery, - daily_usage=daily_usage, - records=records, - ) - - except MyPermobilAPIException as err: - _LOGGER.exception( - "Error fetching data from MyPermobil API for account %s", - self.p_api.email, - ) - raise UpdateFailed from err diff --git a/homeassistant/components/permobil/entity.py b/homeassistant/components/permobil/entity.py deleted file mode 100644 index 702781aa361e..000000000000 --- a/homeassistant/components/permobil/entity.py +++ /dev/null @@ -1,29 +0,0 @@ -"""PermobilEntity class.""" - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import MyPermobilCoordinator - - -class PermobilEntity(CoordinatorEntity[MyPermobilCoordinator]): - """Representation of a permobil Entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MyPermobilCoordinator, - description: EntityDescription, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.p_api.email}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.p_api.email)}, - manufacturer="Permobil", - name="Permobil Wheelchair", - ) diff --git a/homeassistant/components/permobil/icons.json b/homeassistant/components/permobil/icons.json deleted file mode 100644 index 53bddcc00a97..000000000000 --- a/homeassistant/components/permobil/icons.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "entity": { - "sensor": { - "charge_time_left": { - "default": "mdi:battery-clock" - }, - "distance_left": { - "default": "mdi:map-marker-distance" - }, - "max_distance_left": { - "default": "mdi:map-marker-distance" - }, - "max_watt_hours": { - "default": "mdi:lightning-bolt" - }, - "record_adjustments": { - "default": "mdi:seat-recline-extra" - }, - "record_distance": { - "default": "mdi:map-marker-distance" - }, - "state_of_health": { - "default": "mdi:battery-heart-variant" - }, - "usage_adjustments": { - "default": "mdi:seat-recline-extra" - }, - "usage_distance": { - "default": "mdi:map-marker-distance" - }, - "watt_hours_left": { - "default": "mdi:lightning-bolt" - } - } - } -} diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json index 7bba8182c04a..ab37dd5d0ed6 100644 --- a/homeassistant/components/permobil/manifest.json +++ b/homeassistant/components/permobil/manifest.json @@ -1,10 +1,10 @@ { "domain": "permobil", "name": "MyPermobil", - "codeowners": ["@IsakNyberg"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/permobil", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["mypermobil==0.1.8"] + "quality_scale": "legacy", + "requirements": [] } diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py deleted file mode 100644 index a1c35e034023..000000000000 --- a/homeassistant/components/permobil/sensor.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Platform for sensor integration.""" - -from collections.abc import Callable -from dataclasses import dataclass -import logging -from typing import Any, override - -from mypermobil import ( - BATTERY_AMPERE_HOURS_LEFT, - BATTERY_CHARGE_TIME_LEFT, - BATTERY_DISTANCE_LEFT, - BATTERY_INDOOR_DRIVE_TIME, - BATTERY_MAX_AMPERE_HOURS, - BATTERY_MAX_DISTANCE_LEFT, - BATTERY_STATE_OF_CHARGE, - BATTERY_STATE_OF_HEALTH, - RECORDS_DISTANCE, - RECORDS_DISTANCE_UNIT, - RECORDS_SEATING, - USAGE_ADJUSTMENTS, - USAGE_DISTANCE, -) - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import BATTERY_ASSUMED_VOLTAGE, KM, MILES -from .coordinator import PermobilConfigEntry -from .entity import PermobilEntity - -_LOGGER = logging.getLogger(__name__) - - -@dataclass(frozen=True, kw_only=True) -class PermobilSensorEntityDescription(SensorEntityDescription): - """Describes Permobil sensor entity.""" - - value_fn: Callable[[Any], float | int] - available_fn: Callable[[Any], bool] - - -SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( - PermobilSensorEntityDescription( - # Current battery as a percentage - value_fn=lambda data: data.battery[BATTERY_STATE_OF_CHARGE[0]], - available_fn=lambda data: BATTERY_STATE_OF_CHARGE[0] in data.battery, - key="state_of_charge", - translation_key="state_of_charge", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Current battery health as a percentage of original capacity - value_fn=lambda data: data.battery[BATTERY_STATE_OF_HEALTH[0]], - available_fn=lambda data: BATTERY_STATE_OF_HEALTH[0] in data.battery, - key="state_of_health", - translation_key="state_of_health", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Time until fully charged (displays 0 if not charging) - value_fn=lambda data: data.battery[BATTERY_CHARGE_TIME_LEFT[0]], - available_fn=lambda data: BATTERY_CHARGE_TIME_LEFT[0] in data.battery, - key="charge_time_left", - translation_key="charge_time_left", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - ), - PermobilSensorEntityDescription( - # Distance possible on current change (km) - value_fn=lambda data: data.battery[BATTERY_DISTANCE_LEFT[0]], - available_fn=lambda data: BATTERY_DISTANCE_LEFT[0] in data.battery, - key="distance_left", - translation_key="distance_left", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - ), - PermobilSensorEntityDescription( - # Drive time possible on current charge - value_fn=lambda data: data.battery[BATTERY_INDOOR_DRIVE_TIME[0]], - available_fn=lambda data: BATTERY_INDOOR_DRIVE_TIME[0] in data.battery, - key="indoor_drive_time", - translation_key="indoor_drive_time", - native_unit_of_measurement=UnitOfTime.HOURS, - device_class=SensorDeviceClass.DURATION, - ), - PermobilSensorEntityDescription( - # Watt hours the battery can store given battery health - value_fn=lambda data: ( - data.battery[BATTERY_MAX_AMPERE_HOURS[0]] * BATTERY_ASSUMED_VOLTAGE - ), - available_fn=lambda data: BATTERY_MAX_AMPERE_HOURS[0] in data.battery, - key="max_watt_hours", - translation_key="max_watt_hours", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY_STORAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Current amount of watt hours in battery - value_fn=lambda data: ( - data.battery[BATTERY_AMPERE_HOURS_LEFT[0]] * BATTERY_ASSUMED_VOLTAGE - ), - available_fn=lambda data: BATTERY_AMPERE_HOURS_LEFT[0] in data.battery, - key="watt_hours_left", - translation_key="watt_hours_left", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY_STORAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - PermobilSensorEntityDescription( - # Distance that can be traveled with full charge given battery health (km) - value_fn=lambda data: data.battery[BATTERY_MAX_DISTANCE_LEFT[0]], - available_fn=lambda data: BATTERY_MAX_DISTANCE_LEFT[0] in data.battery, - key="max_distance_left", - translation_key="max_distance_left", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - ), - PermobilSensorEntityDescription( - # Distance traveled today monotonically increasing, resets every 24h (km) - value_fn=lambda data: data.daily_usage[USAGE_DISTANCE[0]], - available_fn=lambda data: USAGE_DISTANCE[0] in data.daily_usage, - key="usage_distance", - translation_key="usage_distance", - native_unit_of_measurement=UnitOfLength.KILOMETERS, - device_class=SensorDeviceClass.DISTANCE, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Number of adjustments monotonically increasing, resets every 24h - value_fn=lambda data: data.daily_usage[USAGE_ADJUSTMENTS[0]], - available_fn=lambda data: USAGE_ADJUSTMENTS[0] in data.daily_usage, - key="usage_adjustments", - translation_key="usage_adjustments", - native_unit_of_measurement="adjustments", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Largest number of adjustments in a single 24h period, - # monotonically increasing, never resets - value_fn=lambda data: data.records[RECORDS_SEATING[0]], - available_fn=lambda data: RECORDS_SEATING[0] in data.records, - key="record_adjustments", - translation_key="record_adjustments", - native_unit_of_measurement="adjustments", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - PermobilSensorEntityDescription( - # Record of largest distance travelled in a day, - # monotonically increasing, never resets - value_fn=lambda data: data.records[RECORDS_DISTANCE[0]], - available_fn=lambda data: RECORDS_DISTANCE[0] in data.records, - key="record_distance", - translation_key="record_distance", - device_class=SensorDeviceClass.DISTANCE, - state_class=SensorStateClass.TOTAL_INCREASING, - ), -) - -DISTANCE_UNITS: dict[Any, UnitOfLength] = { - KM: UnitOfLength.KILOMETERS, - MILES: UnitOfLength.MILES, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: PermobilConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Create sensors from a config entry created in the integrations UI.""" - - coordinator = config_entry.runtime_data - - async_add_entities( - PermobilSensor(coordinator=coordinator, description=description) - for description in SENSOR_DESCRIPTIONS - ) - - -class PermobilSensor(PermobilEntity, SensorEntity): - """Representation of a Sensor. - - This implements the common functions of all sensors. - """ - - _attr_suggested_display_precision = 0 - entity_description: PermobilSensorEntityDescription - - @property - @override - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor.""" - if self.entity_description.key == "record_distance": - return DISTANCE_UNITS.get( - self.coordinator.data.records[RECORDS_DISTANCE_UNIT[0]] - ) - return self.entity_description.native_unit_of_measurement - - @property - @override - def available(self) -> bool: - """Return True if the sensor has value.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data - ) - - @property - @override - def native_value(self) -> float | int: - """Return the value of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json index 12adf6dff5a7..5bbfe044bf5e 100644 --- a/homeassistant/components/permobil/strings.json +++ b/homeassistant/components/permobil/strings.json @@ -1,81 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "error": { - "code_request_error": "Error requesting application code", - "invalid_code": "The code you gave is incorrect", - "invalid_email": "Invalid email", - "region_fetch_error": "Error fetching regions", - "unknown": "Unexpected error, more information in the logs", - "unsigned_eula": "Please sign the EULA in the {app_name} app" - }, - "step": { - "email_code": { - "data": { - "code": "Email code" - }, - "description": "Enter the code that was sent to your email." - }, - "region": { - "data": { - "region": "Region" - }, - "description": "Select the region of your account." - }, - "user": { - "data": { - "email": "Enter your permobil email" - } - } - } - }, - "entity": { - "binary_sensor": { - "is_charging": { - "name": "Is charging" - } - }, - "sensor": { - "charge_time_left": { - "name": "Charge time left" - }, - "distance_left": { - "name": "Distance left" - }, - "indoor_drive_time": { - "name": "Indoor drive time" - }, - "max_distance_left": { - "name": "Full charge distance" - }, - "max_watt_hours": { - "name": "Battery max watt hours" - }, - "record_adjustments": { - "name": "Record number of adjustments" - }, - "record_distance": { - "name": "Record distance" - }, - "state_of_charge": { - "name": "Battery charge" - }, - "state_of_health": { - "name": "Battery health" - }, - "usage_adjustments": { - "name": "Number of adjustments" - }, - "usage_distance": { - "name": "Distance traveled" - }, - "watt_hours_left": { - "name": "Watt hours left" - } + "issues": { + "integration_removed": { + "description": "The Permobil 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 Permobil integration entries]({entries}).", + "title": "The Permobil integration has been removed" } } } diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index f2cae74394bf..1ab6c95f5607 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -9,15 +9,15 @@ import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED from homeassistant.components import persistent_notification, websocket_api from homeassistant.components.device_tracker import ( - ATTR_IN_ZONES, - ATTR_SOURCE_TYPE, - ATTR_TRACKING_TYPE, DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityCapabilityAttribute, + DeviceTrackerEntityStateAttribute, SourceType, + TrackerEntityStateAttribute, TrackingType, ) from homeassistant.components.zone import ENTITY_ID_HOME -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_GPS_ACCURACY, ATTR_ID, @@ -31,6 +31,7 @@ from homeassistant.const import ( STATE_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -53,7 +54,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -from .const import DOMAIN +from .const import DOMAIN, PersonEntityStateAttribute _LOGGER = logging.getLogger(__name__) @@ -425,7 +426,9 @@ class Person( ): """Represent a tracked person.""" - _entity_component_unrecorded_attributes = frozenset({ATTR_DEVICE_TRACKERS}) + _entity_component_unrecorded_attributes = frozenset( + {PersonEntityStateAttribute.DEVICE_TRACKERS} + ) _attr_should_poll = False editable: bool @@ -472,7 +475,15 @@ 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, + latitude=state.attributes.get(EntityStateAttribute.LATITUDE), + longitude=state.attributes.get(EntityStateAttribute.LONGITUDE), + gps_accuracy=state.attributes.get( + PersonEntityStateAttribute.GPS_ACCURACY + ), + in_zones=state.attributes.get(PersonEntityStateAttribute.IN_ZONES), + ) if self.hass.is_running: # Update person now if hass is already running. @@ -531,10 +542,15 @@ class Person( continue if state.attributes.get( - ATTR_TRACKING_TYPE - ) == TrackingType.CONNECTION and state.attributes.get(ATTR_IN_ZONES): + DeviceTrackerEntityCapabilityAttribute.TRACKING_TYPE + ) == TrackingType.CONNECTION and state.attributes.get( + DeviceTrackerEntityStateAttribute.IN_ZONES + ): latest_connected = _get_latest(latest_connected, state) - elif state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS: + elif ( + state.attributes.get(DeviceTrackerEntityStateAttribute.SOURCE_TYPE) + == SourceType.GPS + ): latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: # Legacy scanner without tracking type @@ -548,7 +564,17 @@ class Person( latest = latest_connected or latest_legacy_home or latest_gps or latest_not_home if latest: - self._parse_source_state(latest) + self._parse_source_state( + latest, + latitude=latest.attributes.get(EntityStateAttribute.LATITUDE), + longitude=latest.attributes.get(EntityStateAttribute.LONGITUDE), + gps_accuracy=latest.attributes.get( + TrackerEntityStateAttribute.GPS_ACCURACY + ), + in_zones=latest.attributes.get( + DeviceTrackerEntityStateAttribute.IN_ZONES + ), + ) else: self._attr_state = None self._source = None @@ -561,17 +587,28 @@ class Person( self.async_write_ha_state() @callback - def _parse_source_state(self, state: State) -> None: - """Parse source state and set person attributes. + def _parse_source_state( + self, + state: State, + *, + latitude: float | None, + longitude: float | None, + gps_accuracy: int | None, + in_zones: list[str] | None, + ) -> None: + """Set person attributes from a source state. - This is a device tracker state or the restored person state. + The coordinates are read by the caller using the enum matching the + source, which is either a device tracker or the restored person state. + An absent ``in_zones`` (``None``) means the source does not report zone + membership. """ 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._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY) - self._in_zones = state.attributes.get(ATTR_IN_ZONES, []) + self._latitude = latitude + self._longitude = longitude + self._gps_accuracy = gps_accuracy + self._in_zones = in_zones or [] # A legacy scanner (one that doesn't report in_zones) reports "home" # without coordinates. Use the home zone's coordinates for backwards @@ -579,35 +616,35 @@ class Person( # trackers report in_zones and keep their own (possibly absent) # coordinates. if ( - ATTR_IN_ZONES not in state.attributes + in_zones is None and state.state == STATE_HOME and self._latitude is None and self._longitude is None and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) is not None ): - self._latitude = home_zone.attributes.get(ATTR_LATITUDE) - self._longitude = home_zone.attributes.get(ATTR_LONGITUDE) + self._latitude = home_zone.attributes.get(EntityStateAttribute.LATITUDE) + self._longitude = home_zone.attributes.get(EntityStateAttribute.LONGITUDE) @callback def _update_extra_state_attributes(self) -> None: """Update extra state attributes.""" data: dict[str, Any] = { - ATTR_EDITABLE: self.editable, - ATTR_ID: self.unique_id, - ATTR_DEVICE_TRACKERS: self.device_trackers, - ATTR_IN_ZONES: self._in_zones, + PersonEntityStateAttribute.EDITABLE: self.editable, + PersonEntityStateAttribute.ID: self.unique_id, + PersonEntityStateAttribute.DEVICE_TRACKERS: self.device_trackers, + PersonEntityStateAttribute.IN_ZONES: self._in_zones, } if self._latitude is not None: - data[ATTR_LATITUDE] = self._latitude + data[EntityStateAttribute.LATITUDE] = self._latitude if self._longitude is not None: - data[ATTR_LONGITUDE] = self._longitude + data[EntityStateAttribute.LONGITUDE] = self._longitude if self._gps_accuracy is not None: - data[ATTR_GPS_ACCURACY] = self._gps_accuracy + data[PersonEntityStateAttribute.GPS_ACCURACY] = self._gps_accuracy if self._source is not None: - data[ATTR_SOURCE] = self._source + data[PersonEntityStateAttribute.SOURCE] = self._source if (user_id := self._config.get(CONF_USER_ID)) is not None: - data[ATTR_USER_ID] = user_id + data[PersonEntityStateAttribute.USER_ID] = user_id self._attr_extra_state_attributes = data diff --git a/homeassistant/components/person/const.py b/homeassistant/components/person/const.py index dbd228b333ee..1945aafaeea6 100644 --- a/homeassistant/components/person/const.py +++ b/homeassistant/components/person/const.py @@ -1,3 +1,17 @@ """Constants for the person entity platform.""" +from enum import StrEnum + DOMAIN = "person" + + +class PersonEntityStateAttribute(StrEnum): + """State attributes for person entities.""" + + EDITABLE = "editable" + ID = "id" + DEVICE_TRACKERS = "device_trackers" + IN_ZONES = "in_zones" + GPS_ACCURACY = "gps_accuracy" + SOURCE = "source" + USER_ID = "user_id" diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index e80a925094ce..0d5bfe995397 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.2.4"], + "requirements": ["ha-philipsjs==3.2.5"], "zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."] } diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 0c7336263d34..98330e34a0b2 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,5 +1,7 @@ """Constants for the Picnic integration.""" +from datetime import timedelta + DOMAIN = "picnic" SERVICE_ADD_PRODUCT_TO_CART = "add_product" @@ -18,6 +20,11 @@ SLOT_DATA = "slot_data" NEXT_DELIVERY_DATA = "next_delivery_data" LAST_ORDER_DATA = "last_order_data" +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30) +DELIVERY_UPDATE_INTERVAL = timedelta(minutes=1) +DELIVERY_WINDOW_LEAD_TIME = timedelta(minutes=30) +DELIVERY_WINDOW_LAG_TIME = timedelta(hours=2) + SENSOR_CART_ITEMS_COUNT = "cart_items_count" SENSOR_CART_TOTAL_PRICE = "cart_total_price" SENSOR_SELECTED_SLOT_START = "selected_slot_start" @@ -32,5 +39,6 @@ SENSOR_LAST_ORDER_DELIVERY_TIME = "last_order_delivery_time" SENSOR_LAST_ORDER_TOTAL_PRICE = "last_order_total_price" SENSOR_NEXT_DELIVERY_ETA_START = "next_delivery_eta_start" SENSOR_NEXT_DELIVERY_ETA_END = "next_delivery_eta_end" +SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL = "next_delivery_estimated_arrival" SENSOR_NEXT_DELIVERY_SLOT_START = "next_delivery_slot_start" SENSOR_NEXT_DELIVERY_SLOT_END = "next_delivery_slot_end" diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 0a731c51b3fd..8cc2b21a5be5 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -15,8 +15,19 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util -from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT_DATA +from .const import ( + ADDRESS, + CART_DATA, + DEFAULT_UPDATE_INTERVAL, + DELIVERY_UPDATE_INTERVAL, + DELIVERY_WINDOW_LAG_TIME, + DELIVERY_WINDOW_LEAD_TIME, + LAST_ORDER_DATA, + NEXT_DELIVERY_DATA, + SLOT_DATA, +) type PicnicConfigEntry = ConfigEntry[PicnicUpdateCoordinator] @@ -42,12 +53,18 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): logger, config_entry=config_entry, name="Picnic coordinator", - update_interval=timedelta(minutes=30), + update_interval=DEFAULT_UPDATE_INTERVAL, ) @override async def _async_update_data(self) -> dict: """Fetch data from API endpoint.""" + # Recompute up front so failed refreshes also relax the cadence + if self.data: + self.update_interval = self._get_update_interval( + self.data.get(NEXT_DELIVERY_DATA) + ) + try: async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) @@ -63,9 +80,45 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): "Timeout while connecting to the Picnic API", retry_after=120 ) from error + self.update_interval = self._get_update_interval(data.get(NEXT_DELIVERY_DATA)) + # Return the fetched data return data + @staticmethod + def _get_update_interval(next_delivery: dict | None) -> timedelta: + """Poll faster around the delivery so the live ETA is picked up in time.""" + if not next_delivery: + return DEFAULT_UPDATE_INTERVAL + + eta = next_delivery.get("eta") + slot = next_delivery.get("slot") + + start = end = None + if eta: + start = dt_util.parse_datetime(str(eta.get("start"))) + end = dt_util.parse_datetime(str(eta.get("end"))) + if (start is None or end is None) and slot: + start = dt_util.parse_datetime(str(slot.get("window_start"))) + end = dt_util.parse_datetime(str(slot.get("window_end"))) + + if start is None or end is None: + return DEFAULT_UPDATE_INTERVAL + + now = dt_util.utcnow() + window_start = start - DELIVERY_WINDOW_LEAD_TIME + + if window_start <= now <= end + DELIVERY_WINDOW_LAG_TIME: + return DELIVERY_UPDATE_INTERVAL + + if now < window_start: + return max( + DELIVERY_UPDATE_INTERVAL, + min(DEFAULT_UPDATE_INTERVAL, window_start - now), + ) + + return DEFAULT_UPDATE_INTERVAL + def fetch_data(self): """Fetch data from the Picnic API. @@ -153,6 +206,11 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): if "eta2" in next_delivery: del next_delivery["eta2"] + # The position response's eta (unix timestamp in milliseconds) feeds + # the estimated arrival sensor; the API only serves it shortly before + # the delivery, so that sensor is unknown outside that window + next_delivery["estimated_arrival"] = delivery_position.get("eta") + # Determine the total price by adding up the total price of all sub-orders total_price = 0 for order in last_order.get("orders", []): diff --git a/homeassistant/components/picnic/icons.json b/homeassistant/components/picnic/icons.json index 9a28ea8e55f9..86b612ff97cf 100644 --- a/homeassistant/components/picnic/icons.json +++ b/homeassistant/components/picnic/icons.json @@ -25,6 +25,9 @@ "last_order_total_price": { "default": "mdi:cash-marker" }, + "next_delivery_estimated_arrival": { + "default": "mdi:clock-fast" + }, "next_delivery_eta_end": { "default": "mdi:clock-end" }, diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index b9a2c152cab7..0deb382a31cc 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -29,6 +29,7 @@ from .const import ( SENSOR_LAST_ORDER_SLOT_START, SENSOR_LAST_ORDER_STATUS, SENSOR_LAST_ORDER_TOTAL_PRICE, + SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL, SENSOR_NEXT_DELIVERY_ETA_END, SENSOR_NEXT_DELIVERY_ETA_START, SENSOR_NEXT_DELIVERY_SLOT_END, @@ -166,6 +167,17 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( str(next_delivery.get("eta", {}).get("end")) ), ), + PicnicSensorEntityDescription( + key=SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL, + translation_key=SENSOR_NEXT_DELIVERY_ESTIMATED_ARRIVAL, + device_class=SensorDeviceClass.TIMESTAMP, + data_type="next_delivery_data", + value_fn=lambda next_delivery: ( + dt_util.utc_from_timestamp(next_delivery["estimated_arrival"] / 1000) + if next_delivery.get("estimated_arrival") + else None + ), + ), PicnicSensorEntityDescription( key=SENSOR_NEXT_DELIVERY_SLOT_START, translation_key=SENSOR_NEXT_DELIVERY_SLOT_START, diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index e2cea9b4d4d2..8f597574362c 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -61,6 +61,9 @@ "last_order_total_price": { "name": "Total price of last order" }, + "next_delivery_estimated_arrival": { + "name": "Estimated arrival of next delivery" + }, "next_delivery_eta_end": { "name": "Expected end of next delivery" }, diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index 5d4196537880..6b49adbfcf6b 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -4,6 +4,7 @@ from typing import Any, override import voluptuous as vol +from homeassistant.components.light import LightEntityStateAttribute from homeassistant.const import ( CONF_ID, CONF_NAME, @@ -97,7 +98,9 @@ class PilightBaseDevice(RestoreEntity): await super().async_added_to_hass() if state := await self.async_get_last_state(): self._attr_is_on = state.state == STATE_ON - self._brightness = state.attributes.get("brightness") + self._brightness = state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ) def _handle_code(self, call): """Check if received code by the pilight-daemon. diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 0e4e772485a8..cb8e3e3e3c4d 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -1,4 +1,5 @@ """Support to embed Plex.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from functools import partial import logging diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index c4d453c83483..79a52f858efd 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -1,8 +1,9 @@ """The Portainer integration.""" +from datetime import timedelta import logging -from pyportainer import Portainer +from pyportainer import Portainer, PortainerImageWatcher from pyportainer.exceptions import PortainerError from homeassistant.config_entries import ConfigEntry @@ -12,9 +13,10 @@ from homeassistant.const import ( CONF_HOST, CONF_URL, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -32,6 +34,7 @@ _PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -55,8 +58,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> request_timeout=10, max_retries=API_MAX_RETRIES, ) + watcher = PortainerImageWatcher(client, interval=timedelta(hours=24)) coordinator = PortainerCoordinator(hass, entry, client) + coordinator.watcher = watcher await coordinator.async_config_entry_first_refresh() docker_system_df_client = Portainer( @@ -86,6 +91,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + @callback + def _start_watcher(_hass: HomeAssistant) -> None: + """Start the image watcher in the event loop.""" + watcher.start() + + @callback + def _stop_watcher(_event: Event) -> None: + """Stop the image watcher in the event loop.""" + watcher.stop() + + entry.async_on_unload(async_at_started(hass, _start_watcher)) + entry.async_on_unload(watcher.stop) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_watcher) + ) + return True diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index 8d4a2fd3ba5f..b2a4dd8b7d73 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -37,8 +37,18 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class PortainerButtonDescription(ButtonEntityDescription): - """Class to describe a Portainer button entity.""" +class PortainerEndpointButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer endpoint button entity.""" + + press_action: Callable[ + [Portainer, int], + Coroutine[Any, Any, None | DockerContainer], + ] + + +@dataclass(frozen=True, kw_only=True) +class PortainerContainerButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer container button entity.""" press_action: Callable[ [Portainer, int, str], @@ -46,30 +56,30 @@ class PortainerButtonDescription(ButtonEntityDescription): ] -ENDPOINT_BUTTONS: tuple[PortainerButtonDescription, ...] = ( - PortainerButtonDescription( +ENDPOINT_BUTTONS: tuple[PortainerEndpointButtonDescription, ...] = ( + PortainerEndpointButtonDescription( key="images_prune", translation_key="images_prune", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=( - lambda portainer, endpoint_id, _: portainer.images_prune( + lambda portainer, endpoint_id: portainer.images_prune( endpoint_id=endpoint_id, dangling=False, until=timedelta(days=0) ) ), ), - PortainerButtonDescription( + PortainerEndpointButtonDescription( key="volumes_prune", translation_key="volumes_prune", entity_category=EntityCategory.CONFIG, press_action=( - lambda portainer, endpoint_id, _: portainer.prune_volumes(endpoint_id) + lambda portainer, endpoint_id: portainer.prune_volumes(endpoint_id) ), ), ) -CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( - PortainerButtonDescription( +CONTAINER_BUTTONS: tuple[PortainerContainerButtonDescription, ...] = ( + PortainerContainerButtonDescription( key="restart", translation_key="restart_container", device_class=ButtonDeviceClass.RESTART, @@ -80,7 +90,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="pause", translation_key="pause_container", entity_category=EntityCategory.CONFIG, @@ -90,7 +100,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="resume", translation_key="resume_container", entity_category=EntityCategory.CONFIG, @@ -100,7 +110,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="recreate", translation_key="recreate_container", entity_category=EntityCategory.CONFIG, @@ -113,7 +123,7 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), - PortainerButtonDescription( + PortainerContainerButtonDescription( key="kill", translation_key="kill_container", entity_category=EntityCategory.CONFIG, @@ -186,7 +196,6 @@ class PortainerBaseButton(ButtonEntity): Ensures the async_press logic isn't duplicated. """ - entity_description: PortainerButtonDescription coordinator: PortainerCoordinator @abstractmethod @@ -201,17 +210,17 @@ class PortainerBaseButton(ButtonEntity): except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err await self.coordinator.async_request_refresh() @@ -220,20 +229,20 @@ class PortainerBaseButton(ButtonEntity): class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton): """Defines a Portainer endpoint button.""" - entity_description: PortainerButtonDescription + entity_description: PortainerEndpointButtonDescription @override async def _async_press_call(self) -> None: """Call the endpoint button press action.""" await self.entity_description.press_action( - self.coordinator.portainer, self.device_id, "" + self.coordinator.portainer, self.device_id ) class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton): """Defines a Portainer button.""" - entity_description: PortainerButtonDescription + entity_description: PortainerContainerButtonDescription @override async def _async_press_call(self) -> None: diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py index 3ec635cca45c..8aa037d7b386 100644 --- a/homeassistant/components/portainer/config_flow.py +++ b/homeassistant/components/portainer/config_flow.py @@ -16,17 +16,26 @@ 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 homeassistant.helpers.selector import ( + BooleanSelector, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) 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, + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_API_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_VERIFY_SSL, default=True): BooleanSelector(), } ) @@ -126,7 +135,13 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + data_schema=vol.Schema( + { + vol.Required(CONF_API_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ) + } + ), errors=errors, ) @@ -182,13 +197,13 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" -class PortainerTimeout(HomeAssistantError): +class PortainerTimeout(Exception): """Error to indicate a timeout occurred.""" diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index afa8af0a16b6..80d3d37e3273 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging +import time from typing import override from pyportainer import ( @@ -22,10 +23,13 @@ from pyportainer.models.docker import ( DockerSystemDF, DockerVolume, DockerVolumeUsageData, + LocalImageInformation, + PortainerImageUpdateStatus, ) -from pyportainer.models.docker_inspect import DockerInfo, DockerVersion +from pyportainer.models.docker_inspect import DockerInfo, DockerInspect, DockerVersion from pyportainer.models.portainer import Endpoint from pyportainer.models.stacks import Stack +from pyportainer.watcher import PortainerImageWatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL @@ -63,9 +67,12 @@ class PortainerContainerData: """Container data held by the Portainer coordinator.""" container: DockerContainer + container_inspect: DockerInspect + local_image: LocalImageInformation + stack: Stack | None stats: DockerContainerStats | None stats_pre: DockerContainerStats | None - stack: Stack | None + image_status: PortainerImageUpdateStatus | None = None @dataclass(slots=True) @@ -129,24 +136,21 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): async def _async_setup(self) -> None: """Set up the Portainer Data Update Coordinator.""" try: - await self.portainer.get_endpoints() + await self.portainer.portainer_system_status() 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 UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err except PortainerTimeoutError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err @abstractmethod @@ -162,19 +166,16 @@ class PortainerBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): 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 except PortainerTimeoutError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err @@ -185,8 +186,21 @@ class PortainerCoordinator( config_entry: PortainerConfigEntry docker_disk_space: PortainerDockerDiskSpaceCoordinator | None = None + watcher: PortainerImageWatcher | None = None _update_interval = DEFAULT_SCAN_INTERVAL + def __init__( + self, + hass: HomeAssistant, + config_entry: PortainerConfigEntry, + portainer: Portainer, + ) -> None: + """Initialize.""" + super().__init__(hass, config_entry, portainer) + self._image_cache: dict[ + tuple[int, str], tuple[float, DockerInspect, LocalImageInformation] + ] = {} + @override async def update_data(self) -> dict[int, PortainerCoordinatorData]: """Fetch data from Portainer API.""" @@ -201,13 +215,11 @@ class PortainerCoordinator( 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 mapped_endpoints: dict[int, PortainerCoordinatorData] = {} @@ -271,6 +283,25 @@ class PortainerCoordinator( else None ) + ( + container_inspect, + local_image, + ) = await self._get_inspect_local_image(endpoint.id, container.id) + + image_status = ( + ( + result.status + if ( + result := self.watcher.results.get( + (endpoint.id, container.id) + ) + ) + else None + ) + if self.watcher + else None + ) + # Check if container belongs to a stack via docker compose label stack_name: str | None = ( container.labels.get("com.docker.compose.project") @@ -283,8 +314,11 @@ class PortainerCoordinator( container_map[container_name] = PortainerContainerData( container=container, + container_inspect=container_inspect, + local_image=local_image, stats=None, stats_pre=prev_container.stats if prev_container else None, + image_status=image_status, stack=stack_map[stack_name].stack if stack_name and stack_name in stack_map else None, @@ -432,6 +466,37 @@ class PortainerCoordinator( for stack_callback in self.new_stacks_callbacks: stack_callback(new_stack_data) + async def _get_inspect_local_image( + self, endpoint_id: int, container_id: str + ) -> tuple[DockerInspect, LocalImageInformation]: + """Fetch or retrieve cached container inspect and local image data.""" + if cached := self._image_cache.get((endpoint_id, container_id)): + cached_at, container_inspect, local_image = cached + if ( + self.watcher is None + or self.watcher.last_check is None + or cached_at >= self.watcher.last_check + ): + _LOGGER.debug( + "Using cached inspect and local image for endpoint %d, container %s", + endpoint_id, + container_id, + ) + return container_inspect, local_image + + container_inspect = await self.portainer.inspect_container( + endpoint_id, container_id + ) + local_image = await self.portainer.get_image( + endpoint_id, str(container_inspect.image) + ) + self._image_cache[(endpoint_id, container_id)] = ( + time.monotonic(), + container_inspect, + local_image, + ) + return container_inspect, local_image + class PortainerDockerDiskSpaceCoordinator( PortainerBaseCoordinator[dict[int, DockerSystemDF]] diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index f60fe1e30700..395fe0b96413 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/portainer", "integration_type": "service", "iot_class": "local_polling", + "loggers": ["pyportainer"], "quality_scale": "platinum", - "requirements": ["pyportainer==1.0.38"] + "requirements": ["pyportainer==1.0.42"] } diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py index b1dd01675c0c..bbd910bd8cd0 100644 --- a/homeassistant/components/portainer/services.py +++ b/homeassistant/components/portainer/services.py @@ -10,10 +10,13 @@ from pyportainer import ( import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.service import async_extract_config_entry_ids +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + service, +) from .const import DOMAIN from .coordinator import PortainerConfigEntry @@ -47,39 +50,39 @@ SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema( ) -async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry: - """Extract config entry from the service call.""" - target_entry_ids = await async_extract_config_entry_ids(service_call) - target_entries: list[PortainerConfigEntry] = [ - loaded_entry - 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: +@callback +def _async_get_device(call: ServiceCall, device_id: str) -> dr.DeviceEntry: + """Get a device entry from a device ID.""" + device_reg = dr.async_get(call.hass) + if (device := device_reg.async_get(device_id)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_target", ) - return target_entries[0] + return device -async def _get_endpoint_id( - call: ServiceCall, +@callback +def _async_get_entry_from_device( + call: ServiceCall, device: dr.DeviceEntry +) -> PortainerConfigEntry: + """Resolve and validate the Portainer config entry for a device.""" + for entry in call.hass.config_entries.async_entries(DOMAIN): + if entry.entry_id in device.config_entries: + return service.async_get_config_entry(call.hass, DOMAIN, entry.entry_id) + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_target", + ) + + +@callback +def _async_get_endpoint_id( + device: dr.DeviceEntry, config_entry: PortainerConfigEntry, ) -> int: - """Get endpoint data from device ID.""" - device_reg = dr.async_get(call.hass) - device_id = call.data[ATTR_DEVICE_ID] - device = device_reg.async_get(device_id) - - if device is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_target", - ) - + """Get the endpoint ID from a device entry.""" coordinator = config_entry.runtime_data for data in coordinator.data.values(): @@ -95,39 +98,21 @@ async def _get_endpoint_id( ) -async def _get_container_and_endpoint_ids( - call: ServiceCall, -) -> tuple[PortainerConfigEntry, int, str]: - """Get config entry, endpoint ID and container ID from the container device ID.""" - device_reg = dr.async_get(call.hass) - device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID]) - - if device is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_target", - ) - - config_entry: PortainerConfigEntry | None = None - for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN): - if loaded_entry.entry_id in device.config_entries: - config_entry = loaded_entry - break - - if config_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_target", - ) - +@callback +def _async_get_container_and_endpoint_ids( + device: dr.DeviceEntry, + config_entry: PortainerConfigEntry, +) -> tuple[int, str]: + """Get the endpoint ID and container ID from a container device entry.""" coordinator = config_entry.runtime_data + for data in coordinator.data.values(): for container_name, container_data in data.containers.items(): if ( DOMAIN, f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}", ) in device.identifiers: - return config_entry, data.endpoint.id, container_data.container.id + return data.endpoint.id, container_data.container.id raise ServiceValidationError( translation_domain=DOMAIN, @@ -137,9 +122,10 @@ async def _get_container_and_endpoint_ids( async def prune_images(call: ServiceCall) -> None: """Prune unused images in Portainer, with more controls.""" - config_entry = await _extract_config_entry(call) + device = _async_get_device(call, call.data[ATTR_DEVICE_ID]) + config_entry = _async_get_entry_from_device(call, device) coordinator = config_entry.runtime_data - endpoint_id = await _get_endpoint_id(call, config_entry) + endpoint_id = _async_get_endpoint_id(device, config_entry) try: await coordinator.portainer.images_prune( @@ -150,26 +136,28 @@ async def prune_images(call: ServiceCall) -> None: except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err async def recreate_container(call: ServiceCall) -> None: """Recreate a container in Portainer, with more controls.""" - config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids( - call - ) + device = _async_get_device(call, call.data[ATTR_CONTAINER_DEVICE_ID]) + config_entry = _async_get_entry_from_device(call, device) coordinator = config_entry.runtime_data + endpoint_id, container_id = _async_get_container_and_endpoint_ids( + device, config_entry + ) timeout: timedelta | None = call.data.get(ATTR_TIMEOUT) try: @@ -182,17 +170,17 @@ async def recreate_container(call: ServiceCall) -> None: except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err await coordinator.async_request_refresh() diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index d39cddc8c6c4..d32ebe40ce42 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -192,28 +192,24 @@ "stack": { "name": "Stack" } + }, + "update": { + "container_image_update": { + "name": "Image update available" + } } }, "exceptions": { "cannot_connect": { - "message": "An error occurred while trying to connect to the Portainer instance: {error}" - }, - "cannot_connect_no_details": { "message": "An error occurred while trying to connect to the Portainer instance." }, "invalid_auth": { - "message": "An error occurred while trying to authenticate: {error}" - }, - "invalid_auth_no_details": { "message": "An error occurred while trying to authenticate." }, "invalid_target": { "message": "Invalid device targeted." }, "timeout_connect": { - "message": "A timeout occurred while trying to connect to the Portainer instance: {error}" - }, - "timeout_connect_no_details": { "message": "A timeout occurred while trying to connect to the Portainer instance." } }, diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index e95274a0103c..7c87e056d01c 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -65,17 +65,17 @@ async def _perform_action( except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err else: await coordinator.async_request_refresh() diff --git a/homeassistant/components/portainer/update.py b/homeassistant/components/portainer/update.py new file mode 100644 index 000000000000..da3d3f529e86 --- /dev/null +++ b/homeassistant/components/portainer/update.py @@ -0,0 +1,187 @@ +"""Support for Portainer container updates.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, override + +from pyportainer import Portainer +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, +) +from pyportainer.models.docker import ( + DockerContainer, + LocalImageInformation, + PortainerImageUpdateStatus, +) + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + PortainerConfigEntry, + PortainerContainerData, + PortainerCoordinator, + PortainerCoordinatorData, +) +from .entity import PortainerContainerEntity + + +@dataclass(frozen=True, kw_only=True) +class PortainerContainerUpdateEntityDescription(UpdateEntityDescription): + """Describes Portainer container update entity.""" + + installed_version: Callable[[LocalImageInformation], str | None] + latest_version: Callable[[PortainerImageUpdateStatus | None], str | None] + update_func: Callable[ + [Portainer, int, str], + Awaitable[DockerContainer], + ] + + +PARALLEL_UPDATES = 1 +DEFAULT_RECREATE_TIMEOUT = timedelta(minutes=10) + + +CONTAINER_IMAGE: tuple[PortainerContainerUpdateEntityDescription] = ( + PortainerContainerUpdateEntityDescription( + key="container_image_update", + translation_key="container_image_update", + entity_category=EntityCategory.CONFIG, + installed_version=lambda data: ( + data.repo_digests[0].split("@")[1] + if data.repo_digests and isinstance(data.repo_digests[0], str) + else None + ), + latest_version=lambda data: data.registry_digest if data is not None else None, + update_func=( + lambda portainer, endpoint_id, container_id: portainer.container_recreate( + endpoint_id=endpoint_id, + container_id=container_id, + timeout=DEFAULT_RECREATE_TIMEOUT, + pull_image=True, + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PortainerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Portainer update entities based on a config entry.""" + coordinator = entry.runtime_data + + def _async_add_new_containers( + containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]], + ) -> None: + """Add new container update entities.""" + + async_add_entities( + PortainerContainerImageUpdateEntity( + coordinator, + entity_description, + container, + endpoint, + ) + for (endpoint, container) in containers + for entity_description in CONTAINER_IMAGE + ) + + coordinator.new_containers_callbacks.append(_async_add_new_containers) + _async_add_new_containers( + [ + (endpoint, container) + for endpoint in coordinator.data.values() + for container in endpoint.containers.values() + ] + ) + + +class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity): + """Representation of a Portainer container update.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + entity_description: PortainerContainerUpdateEntityDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerContainerUpdateEntityDescription, + device_info: PortainerContainerData, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer update entity.""" + self.entity_description = entity_description + super().__init__(coordinator, entity_description, device_info, via_device) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" + self._in_progress_old_version: str | None = None + + @override + @property + def title(self) -> str | None: + """Return title.""" + return self.device_name + + @override + @property + def installed_version(self) -> str | None: + """Return installed version.""" + return self.entity_description.installed_version( + self.container_data.local_image + ) + + @override + @property + def latest_version(self) -> str | None: + """Return latest version.""" + return self.entity_description.latest_version(self.container_data.image_status) + + @override + @property + def in_progress(self) -> bool: + """Return if an update is in progress.""" + return self._in_progress_old_version == self.installed_version + + @override + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install update.""" + self._in_progress_old_version = self.installed_version + try: + await self.entity_description.update_func( + self.coordinator.portainer, + self.endpoint_id, + self.container_data.container.id, + ) + except PortainerAuthenticationError as ex: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from ex + except PortainerConnectionError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from ex + else: + await self.coordinator.async_request_refresh() + finally: + self._in_progress_old_version = None diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py index 95d20d7ff2b6..6dd60337e26d 100644 --- a/homeassistant/components/private_ble_device/coordinator.py +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -1,4 +1,5 @@ """Central manager for tracking devices with random but resolvable MAC addresses.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections.abc import Callable import logging diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 39463745a0f3..1f61fb0a95a5 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,631 +1,31 @@ """The profiler integration.""" -import asyncio -from collections.abc import Generator -import contextlib -from contextlib import suppress -from datetime import timedelta -from functools import _lru_cache_wrapper -import logging -import reprlib -import sys -import threading -import time -import traceback -from typing import Any, cast - -from lru import LRU -import voluptuous as vol - -from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import LOG_INTERVAL_SUB, async_setup_services -SERVICE_START = "start" -SERVICE_MEMORY = "memory" -SERVICE_START_LOG_OBJECTS = "start_log_objects" -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" -SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug" -SERVICE_LOG_CURRENT_TASKS = "log_current_tasks" - -_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ -_SQLALCHEMY_LRU_OBJECT = "LRUCache" - -_KNOWN_LRU_CLASSES = ( - "EventDataManager", - "EventTypeManager", - "StatesMetaManager", - "StateAttributesManager", - "StatisticsMetaManager", -) - -SERVICES = ( - SERVICE_START, - SERVICE_MEMORY, - SERVICE_START_LOG_OBJECTS, - SERVICE_STOP_LOG_OBJECTS, - SERVICE_DUMP_LOG_OBJECTS, - SERVICE_LRU_STATS, - SERVICE_LOG_THREAD_FRAMES, - SERVICE_LOG_EVENT_LOOP_SCHEDULED, - SERVICE_SET_ASYNCIO_DEBUG, - SERVICE_LOG_CURRENT_TASKS, -) - -DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) - -DEFAULT_MAX_OBJECTS = 5 - -CONF_SECONDS = "seconds" -CONF_MAX_OBJECTS = "max_objects" - -LOG_INTERVAL_SUB = "log_interval_subscription" +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -_LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Profiler.""" + async_setup_services(hass) + return True -async def async_setup_entry( # noqa: C901 - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Profiler from a config entry.""" - lock = asyncio.Lock() - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - domain_data = hass.data[DOMAIN] = {} - - async def _async_run_profile(call: ServiceCall) -> None: - async with lock: - await _async_generate_profile(hass, call) - - async def _async_run_memory_profile(call: ServiceCall) -> None: - async with lock: - await _async_generate_memory_profile(hass, call) - - async def _async_start_log_objects(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB in domain_data: - raise HomeAssistantError("Object logging already started") - - persistent_notification.async_create( - hass, - ( - "Object growth logging has started. See [the logs](/config/logs) to" - " track the growth of new objects." - ), - title="Object growth logging started", - notification_id="profile_object_logging", - ) - await hass.async_add_executor_job(_log_objects) - domain_data[LOG_INTERVAL_SUB] = async_track_time_interval( - hass, _log_objects, call.data[CONF_SCAN_INTERVAL] - ) - - async def _async_stop_log_objects(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB not in domain_data: - raise HomeAssistantError("Object logging not running") - - persistent_notification.async_dismiss(hass, "profile_object_logging") - domain_data.pop(LOG_INTERVAL_SUB)() - - async def _async_start_object_sources(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB in domain_data: - raise HomeAssistantError("Object logging already started") - - persistent_notification.async_create( - hass, - ( - "Object source logging has started. See [the logs](/config/logs) to" - " track the growth of new objects." - ), - title="Object source logging started", - notification_id="profile_object_source_logging", - ) - - last_ids: set[int] = set() - last_stats: dict[str, int] = {} - - async def _log_object_sources_with_max(*_: Any) -> None: - await hass.async_add_executor_job( - _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats - ) - - await _log_object_sources_with_max() - cancel_track = async_track_time_interval( - hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] - ) - - @callback - def _cancel(): - cancel_track() - last_ids.clear() - last_stats.clear() - - domain_data[LOG_INTERVAL_SUB] = _cancel - - @callback - def _async_stop_object_sources(call: ServiceCall) -> None: - if LOG_INTERVAL_SUB not in domain_data: - raise HomeAssistantError("Object logging not running") - - persistent_notification.async_dismiss(hass, "profile_object_source_logging") - domain_data.pop(LOG_INTERVAL_SUB)() - - def _dump_log_objects(call: ServiceCall) -> None: - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - obj_type = call.data[CONF_TYPE] - - for obj in objgraph.by_type(obj_type): - _LOGGER.critical( - "%s object in memory: %s", - obj_type, - _safe_repr(obj), - ) - - persistent_notification.create( - hass, - ( - f"Objects with type {obj_type} have been dumped to the log. See [the" - " logs](/config/logs) to review the repr of the objects." - ), - title="Object dump completed", - notification_id="profile_object_dump", - ) - - def _lru_stats(call: ServiceCall) -> None: - """Log the stats of all lru caches.""" - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): - lru = cast(_lru_cache_wrapper, lru) - _LOGGER.critical( - "Cache stats for lru_cache %s at %s: %s", - lru.__wrapped__, - _get_function_absfile(lru.__wrapped__) or "unknown", - lru.cache_info(), - ) - - for _class in _KNOWN_LRU_CLASSES: - for class_with_lru_attr in objgraph.by_type(_class): - for maybe_lru in class_with_lru_attr.__dict__.values(): - if isinstance(maybe_lru, LRU): - _LOGGER.critical( - "Cache stats for LRU %s at %s: %s", - type(class_with_lru_attr), - _get_function_absfile(class_with_lru_attr) or "unknown", - maybe_lru.get_stats(), - ) - - for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): - if (data := getattr(lru, "_data", None)) and isinstance(data, dict): - for key, value in dict(data).items(): - _LOGGER.critical( - "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value - ) - - persistent_notification.create( - hass, - ( - "LRU cache states have been dumped to the log. See [the" - " logs](/config/logs) to review the stats." - ), - title="LRU stats completed", - 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 - main_thread = threading.main_thread() - for thread in threading.enumerate(): - if thread == main_thread: - continue - ident = cast(int, thread.ident) - _LOGGER.critical( - "Thread [%s]: %s", - thread.name, - "".join(traceback.format_stack(frames.get(ident))).strip(), - ) - - async def _async_dump_current_tasks(call: ServiceCall) -> None: - """Log all current tasks in the event loop.""" - with _increase_repr_limit(): - for task in asyncio.all_tasks(): - if not task.cancelled(): - _LOGGER.critical("Task: %s", _safe_repr(task)) - - async def _async_dump_scheduled(call: ServiceCall) -> None: - """Log all scheduled in the event loop.""" - with _increase_repr_limit(): - handle: asyncio.Handle - for handle in getattr(hass.loop, "_scheduled"): # noqa: B009 - if not handle.cancelled(): - _LOGGER.critical("Scheduled: %s", handle) - - async def _async_asyncio_debug(call: ServiceCall) -> None: - """Enable or disable asyncio debug.""" - enabled = call.data[CONF_ENABLED] - # Always log this at critical level so we know when - # it's been changed when reviewing logs - _LOGGER.critical("Setting asyncio debug to %s", enabled) - # Make sure the logger is set to at least INFO or - # we won't see the messages - base_logger = logging.getLogger() - if enabled and base_logger.getEffectiveLevel() > logging.INFO: - base_logger.setLevel(logging.INFO) - hass.loop.set_debug(enabled) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START, - _async_run_profile, - schema=vol.Schema( - {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_MEMORY, - _async_run_memory_profile, - schema=vol.Schema( - {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START_LOG_OBJECTS, - _async_start_log_objects, - schema=vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period - } - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_STOP_LOG_OBJECTS, - _async_stop_log_objects, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_START_LOG_OBJECT_SOURCES, - _async_start_object_sources, - schema=vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( - min=1, max=1024 - ), - } - ), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_STOP_LOG_OBJECT_SOURCES, - _async_stop_object_sources, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_DUMP_LOG_OBJECTS, - _dump_log_objects, - schema=vol.Schema({vol.Required(CONF_TYPE): str}), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_DUMP_SOCKETS, - _dump_sockets, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LRU_STATS, - _lru_stats, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_THREAD_FRAMES, - _async_dump_thread_frames, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_EVENT_LOOP_SCHEDULED, - _async_dump_scheduled, - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ASYNCIO_DEBUG, - _async_asyncio_debug, - schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}), - ) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service( - hass, - DOMAIN, - SERVICE_LOG_CURRENT_TASKS, - _async_dump_current_tasks, - ) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - for service in SERVICES: - hass.services.async_remove(domain=DOMAIN, service=service) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data if LOG_INTERVAL_SUB in hass.data[DOMAIN]: hass.data[DOMAIN][LOG_INTERVAL_SUB]() - hass.data.pop(DOMAIN) return True - - -async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import cProfile # noqa: PLC0415 - - start_time = int(time.time() * 1000000) - persistent_notification.async_create( - hass, - ( - "The profile has started. This notification will be updated when it is" - " complete." - ), - title="Profile Started", - notification_id=f"profiler_{start_time}", - ) - profiler = cProfile.Profile() - profiler.enable() - await asyncio.sleep(float(call.data[CONF_SECONDS])) - profiler.disable() - - cprofile_path = hass.config.path(f"profile.{start_time}.cprof") - callgrind_path = hass.config.path(f"callgrind.out.{start_time}") - await hass.async_add_executor_job( - _write_profile, profiler, cprofile_path, callgrind_path - ) - persistent_notification.async_create( - hass, - ( - f"Wrote cProfile data to {cprofile_path} and callgrind data to" - f" {callgrind_path}" - ), - title="Profile Complete", - notification_id=f"profiler_{start_time}", - ) - - -async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - from guppy import hpy # noqa: PLC0415 - - start_time = int(time.time() * 1000000) - persistent_notification.async_create( - hass, - ( - "The memory profile has started. This notification will be updated when it" - " is complete." - ), - title="Profile Started", - notification_id=f"memory_profiler_{start_time}", - ) - heap_profiler = hpy() - heap_profiler.setref() - await asyncio.sleep(float(call.data[CONF_SECONDS])) - heap = heap_profiler.heap() - - heap_path = hass.config.path(f"heap_profile.{start_time}.hpy") - await hass.async_add_executor_job(_write_memory_profile, heap, heap_path) - persistent_notification.async_create( - hass, - f"Wrote heapy memory profile to {heap_path}", - title="Profile Complete", - notification_id=f"memory_profiler_{start_time}", - ) - - -def _write_profile(profiler, cprofile_path, callgrind_path): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - from pyprof2calltree import convert # noqa: PLC0415 - - profiler.create_stats() - profiler.dump_stats(cprofile_path) - convert(profiler.getstats(), callgrind_path) - - -def _write_memory_profile(heap, heap_path): - heap.byrcs.dump(heap_path) - - -def _log_objects(*_): - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import objgraph # noqa: PLC0415 - - _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) - - -def _get_function_absfile(func: Any) -> str | None: - """Get the absolute file path of a function.""" - import inspect # noqa: PLC0415 - - abs_file: str | None = None - with suppress(Exception): - abs_file = inspect.getabsfile(func) - return abs_file - - -def _safe_repr(obj: Any) -> str: - """Get the repr of an object but keep going if there is an exception. - - We wrap repr to ensure if one object cannot be serialized, we can - still get the rest. - """ - try: - return repr(obj) - except Exception: # noqa: BLE001 - return f"Failed to serialize {type(obj)}" - - -def _find_backrefs_not_to_self(_object: Any) -> list[str]: - import objgraph # noqa: PLC0415 - - return [ - _safe_repr(backref) - for backref in objgraph.find_backref_chain( - _object, lambda obj: obj is not _object - ) - ] - - -def _log_object_sources( - max_objects: int, last_ids: set[int], last_stats: dict[str, int] -) -> None: - # Imports deferred to avoid loading modules - # in memory since usually only one part of this - # integration is used at a time - import gc # noqa: PLC0415 - - gc.collect() - - objects = gc.get_objects() - new_objects: list[object] = [] - new_objects_overflow: dict[str, int] = {} - current_ids = set() - new_stats: dict[str, int] = {} - had_new_object_growth = False - try: - for _object in objects: - object_type = type(_object).__name__ - new_stats[object_type] = new_stats.get(object_type, 0) + 1 - - for _object in objects: - id_ = id(_object) - current_ids.add(id_) - if id_ in last_ids: - continue - object_type = type(_object).__name__ - if last_stats.get(object_type, 0) < new_stats[object_type]: - if len(new_objects) < max_objects: - new_objects.append(_object) - else: - new_objects_overflow.setdefault(object_type, 0) - new_objects_overflow[object_type] += 1 - - for _object in new_objects: - had_new_object_growth = True - object_type = type(_object).__name__ - _LOGGER.critical( - "New object %s (%s/%s) at %s: %s", - object_type, - last_stats.get(object_type, 0), - new_stats[object_type], - _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), - _safe_repr(_object), - ) - - for object_type, count in last_stats.items(): - new_stats[object_type] = max(new_stats.get(object_type, 0), count) - finally: - # Break reference cycles - del objects - del new_objects - last_ids.clear() - last_ids.update(current_ids) - last_stats.clear() - last_stats.update(new_stats) - del new_stats - del current_ids - - if new_objects_overflow: - _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) - elif not had_new_object_growth: - _LOGGER.critical("No new object growth found") - - -@contextlib.contextmanager -def _increase_repr_limit() -> Generator[None]: - """Increase the repr limit.""" - arepr = reprlib.aRepr - original_maxstring = arepr.maxstring - original_maxother = arepr.maxother - arepr.maxstring = 300 - arepr.maxother = 300 - try: - yield - finally: - arepr.maxstring = original_maxstring - arepr.maxother = original_maxother diff --git a/homeassistant/components/profiler/services.py b/homeassistant/components/profiler/services.py new file mode 100644 index 000000000000..34c217d4f4fd --- /dev/null +++ b/homeassistant/components/profiler/services.py @@ -0,0 +1,591 @@ +"""Support for the profiler services.""" + +import asyncio +from collections.abc import Generator +import contextlib +from contextlib import suppress +from datetime import timedelta +from functools import _lru_cache_wrapper +import logging +import reprlib +import sys +import threading +import time +import traceback +from typing import Any, cast + +from lru import LRU +import voluptuous as vol + +from homeassistant.components import persistent_notification +from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service import async_register_admin_service + +from .const import DOMAIN + +SERVICE_START = "start" +SERVICE_MEMORY = "memory" +SERVICE_START_LOG_OBJECTS = "start_log_objects" +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" +SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug" +SERVICE_LOG_CURRENT_TASKS = "log_current_tasks" + +_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ +_SQLALCHEMY_LRU_OBJECT = "LRUCache" + +_KNOWN_LRU_CLASSES = ( + "EventDataManager", + "EventTypeManager", + "StatesMetaManager", + "StateAttributesManager", + "StatisticsMetaManager", +) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +DEFAULT_MAX_OBJECTS = 5 + +CONF_SECONDS = "seconds" +CONF_MAX_OBJECTS = "max_objects" + +LOG_INTERVAL_SUB = "log_interval_subscription" + + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 + """Register the profiler services.""" + lock = asyncio.Lock() + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=home-assistant-use-runtime-data + domain_data = hass.data[DOMAIN] = {} + + async def _async_run_profile(call: ServiceCall) -> None: + async with lock: + await _async_generate_profile(hass, call) + + async def _async_run_memory_profile(call: ServiceCall) -> None: + async with lock: + await _async_generate_memory_profile(hass, call) + + async def _async_start_log_objects(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") + + persistent_notification.async_create( + hass, + ( + "Object growth logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object growth logging started", + notification_id="profile_object_logging", + ) + await hass.async_add_executor_job(_log_objects) + domain_data[LOG_INTERVAL_SUB] = async_track_time_interval( + hass, _log_objects, call.data[CONF_SCAN_INTERVAL] + ) + + async def _async_stop_log_objects(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_logging") + domain_data.pop(LOG_INTERVAL_SUB)() + + async def _async_start_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") + + persistent_notification.async_create( + hass, + ( + "Object source logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object source logging started", + notification_id="profile_object_source_logging", + ) + + last_ids: set[int] = set() + last_stats: dict[str, int] = {} + + async def _log_object_sources_with_max(*_: Any) -> None: + await hass.async_add_executor_job( + _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats + ) + + await _log_object_sources_with_max() + cancel_track = async_track_time_interval( + hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] + ) + + @callback + def _cancel(): + cancel_track() + last_ids.clear() + last_stats.clear() + + domain_data[LOG_INTERVAL_SUB] = _cancel + + @callback + def _async_stop_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_source_logging") + domain_data.pop(LOG_INTERVAL_SUB)() + + def _dump_log_objects(call: ServiceCall) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + obj_type = call.data[CONF_TYPE] + + for obj in objgraph.by_type(obj_type): + _LOGGER.critical( + "%s object in memory: %s", + obj_type, + _safe_repr(obj), + ) + + persistent_notification.create( + hass, + ( + f"Objects with type {obj_type} have been dumped to the log. See [the" + " logs](/config/logs) to review the repr of the objects." + ), + title="Object dump completed", + notification_id="profile_object_dump", + ) + + def _lru_stats(call: ServiceCall) -> None: + """Log the stats of all lru caches.""" + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): + lru = cast(_lru_cache_wrapper, lru) + _LOGGER.critical( + "Cache stats for lru_cache %s at %s: %s", + lru.__wrapped__, + _get_function_absfile(lru.__wrapped__) or "unknown", + lru.cache_info(), + ) + + for _class in _KNOWN_LRU_CLASSES: + for class_with_lru_attr in objgraph.by_type(_class): + for maybe_lru in class_with_lru_attr.__dict__.values(): + if isinstance(maybe_lru, LRU): + _LOGGER.critical( + "Cache stats for LRU %s at %s: %s", + type(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr) or "unknown", + maybe_lru.get_stats(), + ) + + for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): + if (data := getattr(lru, "_data", None)) and isinstance(data, dict): + for key, value in dict(data).items(): + _LOGGER.critical( + "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value + ) + + persistent_notification.create( + hass, + ( + "LRU cache states have been dumped to the log. See [the" + " logs](/config/logs) to review the stats." + ), + title="LRU stats completed", + 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 + main_thread = threading.main_thread() + for thread in threading.enumerate(): + if thread == main_thread: + continue + ident = cast(int, thread.ident) + _LOGGER.critical( + "Thread [%s]: %s", + thread.name, + "".join(traceback.format_stack(frames.get(ident))).strip(), + ) + + async def _async_dump_current_tasks(call: ServiceCall) -> None: + """Log all current tasks in the event loop.""" + with _increase_repr_limit(): + for task in asyncio.all_tasks(): + if not task.cancelled(): + _LOGGER.critical("Task: %s", _safe_repr(task)) + + async def _async_dump_scheduled(call: ServiceCall) -> None: + """Log all scheduled in the event loop.""" + with _increase_repr_limit(): + handle: asyncio.Handle + for handle in getattr(hass.loop, "_scheduled"): # noqa: B009 + if not handle.cancelled(): + _LOGGER.critical("Scheduled: %s", handle) + + async def _async_asyncio_debug(call: ServiceCall) -> None: + """Enable or disable asyncio debug.""" + enabled = call.data[CONF_ENABLED] + # Always log this at critical level so we know when + # it's been changed when reviewing logs + _LOGGER.critical("Setting asyncio debug to %s", enabled) + # Make sure the logger is set to at least INFO or + # we won't see the messages + base_logger = logging.getLogger() + if enabled and base_logger.getEffectiveLevel() > logging.INFO: + base_logger.setLevel(logging.INFO) + hass.loop.set_debug(enabled) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START, + _async_run_profile, + schema=vol.Schema( + {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_MEMORY, + _async_run_memory_profile, + schema=vol.Schema( + {vol.Optional(CONF_SECONDS, default=60.0): vol.Coerce(float)} + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECTS, + _async_start_log_objects, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECTS, + _async_stop_log_objects, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECT_SOURCES, + _async_start_object_sources, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( + min=1, max=1024 + ), + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECT_SOURCES, + _async_stop_object_sources, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_DUMP_LOG_OBJECTS, + _dump_log_objects, + 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, + SERVICE_LRU_STATS, + _lru_stats, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_THREAD_FRAMES, + _async_dump_thread_frames, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_EVENT_LOOP_SCHEDULED, + _async_dump_scheduled, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ASYNCIO_DEBUG, + _async_asyncio_debug, + schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_CURRENT_TASKS, + _async_dump_current_tasks, + ) + + +async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import cProfile # noqa: PLC0415 + + start_time = int(time.time() * 1000000) + persistent_notification.async_create( + hass, + ( + "The profile has started. This notification will be updated when it is" + " complete." + ), + title="Profile Started", + notification_id=f"profiler_{start_time}", + ) + profiler = cProfile.Profile() + profiler.enable() + await asyncio.sleep(float(call.data[CONF_SECONDS])) + profiler.disable() + + cprofile_path = hass.config.path(f"profile.{start_time}.cprof") + callgrind_path = hass.config.path(f"callgrind.out.{start_time}") + await hass.async_add_executor_job( + _write_profile, profiler, cprofile_path, callgrind_path + ) + persistent_notification.async_create( + hass, + ( + f"Wrote cProfile data to {cprofile_path} and callgrind data to" + f" {callgrind_path}" + ), + title="Profile Complete", + notification_id=f"profiler_{start_time}", + ) + + +async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + from guppy import hpy # noqa: PLC0415 + + start_time = int(time.time() * 1000000) + persistent_notification.async_create( + hass, + ( + "The memory profile has started. This notification will be updated when it" + " is complete." + ), + title="Profile Started", + notification_id=f"memory_profiler_{start_time}", + ) + heap_profiler = hpy() + heap_profiler.setref() + await asyncio.sleep(float(call.data[CONF_SECONDS])) + heap = heap_profiler.heap() + + heap_path = hass.config.path(f"heap_profile.{start_time}.hpy") + await hass.async_add_executor_job(_write_memory_profile, heap, heap_path) + persistent_notification.async_create( + hass, + f"Wrote heapy memory profile to {heap_path}", + title="Profile Complete", + notification_id=f"memory_profiler_{start_time}", + ) + + +def _write_profile(profiler, cprofile_path, callgrind_path): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + from pyprof2calltree import convert # noqa: PLC0415 + + profiler.create_stats() + profiler.dump_stats(cprofile_path) + convert(profiler.getstats(), callgrind_path) + + +def _write_memory_profile(heap, heap_path): + heap.byrcs.dump(heap_path) + + +def _log_objects(*_): + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # noqa: PLC0415 + + _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) + + +def _get_function_absfile(func: Any) -> str | None: + """Get the absolute file path of a function.""" + import inspect # noqa: PLC0415 + + abs_file: str | None = None + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + +def _safe_repr(obj: Any) -> str: + """Get the repr of an object but keep going if there is an exception. + + We wrap repr to ensure if one object cannot be serialized, we can + still get the rest. + """ + try: + return repr(obj) + except Exception: # noqa: BLE001 + return f"Failed to serialize {type(obj)}" + + +def _find_backrefs_not_to_self(_object: Any) -> list[str]: + import objgraph # noqa: PLC0415 + + return [ + _safe_repr(backref) + for backref in objgraph.find_backref_chain( + _object, lambda obj: obj is not _object + ) + ] + + +def _log_object_sources( + max_objects: int, last_ids: set[int], last_stats: dict[str, int] +) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import gc # noqa: PLC0415 + + gc.collect() + + objects = gc.get_objects() + new_objects: list[object] = [] + new_objects_overflow: dict[str, int] = {} + current_ids = set() + new_stats: dict[str, int] = {} + had_new_object_growth = False + try: + for _object in objects: + object_type = type(_object).__name__ + new_stats[object_type] = new_stats.get(object_type, 0) + 1 + + for _object in objects: + id_ = id(_object) + current_ids.add(id_) + if id_ in last_ids: + continue + object_type = type(_object).__name__ + if last_stats.get(object_type, 0) < new_stats[object_type]: + if len(new_objects) < max_objects: + new_objects.append(_object) + else: + new_objects_overflow.setdefault(object_type, 0) + new_objects_overflow[object_type] += 1 + + for _object in new_objects: + had_new_object_growth = True + object_type = type(_object).__name__ + _LOGGER.critical( + "New object %s (%s/%s) at %s: %s", + object_type, + last_stats.get(object_type, 0), + new_stats[object_type], + _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), + _safe_repr(_object), + ) + + for object_type, count in last_stats.items(): + new_stats[object_type] = max(new_stats.get(object_type, 0), count) + finally: + # Break reference cycles + del objects + del new_objects + last_ids.clear() + last_ids.update(current_ids) + last_stats.clear() + last_stats.update(new_stats) + del new_stats + del current_ids + + if new_objects_overflow: + _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) + elif not had_new_object_growth: + _LOGGER.critical("No new object growth found") + + +@contextlib.contextmanager +def _increase_repr_limit() -> Generator[None]: + """Increase the repr limit.""" + arepr = reprlib.aRepr + original_maxstring = arepr.maxstring + original_maxother = arepr.maxother + arepr.maxstring = 300 + arepr.maxother = 300 + try: + yield + finally: + arepr.maxstring = original_maxstring + arepr.maxother = original_maxother diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 2904d9320f10..9ed5cfcea4de 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -15,54 +15,30 @@ import voluptuous as vol from homeassistant import core as hacore from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, - ATTR_FAN_MODE, - ATTR_FAN_MODES, - ATTR_HVAC_ACTION, - ATTR_HVAC_MODES, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, + ClimateEntityCapabilityAttribute, + ClimateEntityStateAttribute, HVACAction, ) -from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, -) +from homeassistant.components.cover import CoverEntityStateAttribute from homeassistant.components.fan import ( - ATTR_DIRECTION, - ATTR_OSCILLATING, - ATTR_PERCENTAGE, - ATTR_PRESET_MODE, - ATTR_PRESET_MODES, DIRECTION_FORWARD, DIRECTION_REVERSE, + FanEntityCapabilityAttribute, + FanEntityStateAttribute, ) from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY -from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.humidifier import ( + HumidifierEntityCapabilityAttribute, + HumidifierEntityStateAttribute, +) +from homeassistant.components.light import LightEntityStateAttribute from homeassistant.components.sensor import SensorDeviceClass - -# Alias water_heater constants to avoid name clashes with -# similarly named climate constants from homeassistant.components.water_heater import ( - ATTR_AWAY_MODE as WATER_HEATER_ATTR_AWAY_MODE, - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, - ATTR_MAX_TEMP as WATER_HEATER_ATTR_MAX_TEMP, - ATTR_MIN_TEMP as WATER_HEATER_ATTR_MIN_TEMP, - ATTR_OPERATION_LIST as WATER_HEATER_ATTR_OPERATION_LIST, - ATTR_OPERATION_MODE as WATER_HEATER_ATTR_OPERATION_MODE, - ATTR_TARGET_TEMP_HIGH as WATER_HEATER_ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW as WATER_HEATER_ATTR_TARGET_TEMP_LOW, + WaterHeaterCapabilityAttribute, + WaterHeaterStateAttribute, ) from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_LATITUDE, - ATTR_LONGITUDE, - ATTR_MODE, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, CONTENT_TYPE_TEXT_PLAIN, EVENT_STATE_CHANGED, PERCENTAGE, @@ -73,6 +49,7 @@ from homeassistant.const import ( STATE_OPENING, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfLength, UnitOfTemperature, ) @@ -293,8 +270,8 @@ class PrometheusMetrics: if ( old_state := event.data.get("old_state") ) is not None and old_state.attributes.get( - ATTR_FRIENDLY_NAME - ) != state.attributes.get(ATTR_FRIENDLY_NAME): + EntityStateAttribute.FRIENDLY_NAME + ) != state.attributes.get(EntityStateAttribute.FRIENDLY_NAME): self._remove_labelsets(old_state.entity_id) self.handle_state(state) @@ -569,7 +546,10 @@ class PrometheusMetrics: def state_as_number(state: State) -> float | None: """Return state as a float, or None if state cannot be converted.""" try: - if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: + if ( + state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + == SensorDeviceClass.TIMESTAMP + ): value = as_timestamp(state.state) else: value = state_helper.state_as_number(state) @@ -588,7 +568,7 @@ class PrometheusMetrics: labels = { "entity": state.entity_id, "domain": state.domain, - "friendly_name": state.attributes.get(ATTR_FRIENDLY_NAME), + "friendly_name": state.attributes.get(EntityStateAttribute.FRIENDLY_NAME), } if not labels.keys().isdisjoint(extra_labels.keys()): conflicting_keys = labels.keys() & extra_labels.keys() @@ -731,7 +711,9 @@ class PrometheusMetrics: if (value := self.state_as_number(state)) is None: return - if unit := self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): + if unit := self._unit_string( + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ): metric = self._metric( f"{domain}_state_{unit}", prometheus_client.Gauge, @@ -747,7 +729,7 @@ class PrometheusMetrics: ) if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT ): value = TemperatureConverter.convert( @@ -777,7 +759,7 @@ class PrometheusMetrics: def _handle_geo_location(self, state: State) -> None: labels = self._labels(state, {"source": state.attributes.get("source", "")}) if (value := self.state_as_number(state)) is not None: - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if unit is not None: value = DistanceConverter.convert(value, unit, UnitOfLength.METERS) self._metric( @@ -786,14 +768,18 @@ class PrometheusMetrics: "Distance of the geo location event from home in meters", labels, ).set(value) - if (latitude := state.attributes.get(ATTR_LATITUDE)) is not None: + if ( + latitude := state.attributes.get(EntityStateAttribute.LATITUDE) + ) is not None: self._metric( "geo_location_latitude_degrees", prometheus_client.Gauge, "Latitude of the geo location event in degrees", labels, ).set(latitude) - if (longitude := state.attributes.get(ATTR_LONGITUDE)) is not None: + if ( + longitude := state.attributes.get(EntityStateAttribute.LONGITUDE) + ) is not None: self._metric( "geo_location_longitude_degrees", prometheus_client.Gauge, @@ -815,13 +801,13 @@ class PrometheusMetrics: ) self._float_metric( state, - ATTR_CURRENT_POSITION, + CoverEntityStateAttribute.CURRENT_POSITION, "cover_position", "Position of the cover (0-100)", ) self._float_metric( state, - ATTR_CURRENT_TILT_POSITION, + CoverEntityStateAttribute.CURRENT_TILT_POSITION, "cover_tilt_position", "Tilt Position of the cover (0-100)", ) @@ -830,7 +816,7 @@ class PrometheusMetrics: if (value := self.state_as_number(state)) is None: return - brightness = state.attributes.get(ATTR_BRIGHTNESS) + brightness = state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) if state.state == STATE_ON and brightness is not None: value = float(brightness) / 255.0 value = value * 100 @@ -845,25 +831,25 @@ class PrometheusMetrics: def _handle_climate(self, state: State) -> None: self._temperature_metric( state, - ATTR_TEMPERATURE, + ClimateEntityStateAttribute.TEMPERATURE, "climate_target_temperature_celsius", "Target temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_TARGET_TEMP_HIGH, + ClimateEntityStateAttribute.TARGET_TEMP_HIGH, "climate_target_temperature_high_celsius", "Target high temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_TARGET_TEMP_LOW, + ClimateEntityStateAttribute.TARGET_TEMP_LOW, "climate_target_temperature_low_celsius", "Target low temperature in degrees Celsius", ) self._temperature_metric( state, - ATTR_CURRENT_TEMPERATURE, + ClimateEntityStateAttribute.CURRENT_TEMPERATURE, "climate_current_temperature_celsius", "Current temperature in degrees Celsius", ) @@ -871,7 +857,7 @@ class PrometheusMetrics: self._enum_metric( state, ( - (attr := state.attributes.get(ATTR_HVAC_ACTION)) + (attr := state.attributes.get(ClimateEntityStateAttribute.HVAC_ACTION)) and getattr(attr, "value", attr) ), [action.value for action in HVACAction], @@ -882,23 +868,23 @@ class PrometheusMetrics: self._enum_metric( state, state.state, - state.attributes.get(ATTR_HVAC_MODES), + state.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES), "climate_mode", "HVAC mode", "mode", ) self._enum_metric( state, - state.attributes.get(ATTR_PRESET_MODE), - state.attributes.get(ATTR_PRESET_MODES), + state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE), + state.attributes.get(ClimateEntityCapabilityAttribute.PRESET_MODES), "climate_preset_mode", "Preset mode enum", "mode", ) self._enum_metric( state, - state.attributes.get(ATTR_FAN_MODE), - state.attributes.get(ATTR_FAN_MODES), + state.attributes.get(ClimateEntityStateAttribute.FAN_MODE), + state.attributes.get(ClimateEntityCapabilityAttribute.FAN_MODES), "climate_fan_mode", "Fan mode enum", "mode", @@ -909,15 +895,15 @@ class PrometheusMetrics: self._float_metric( state, - ATTR_HUMIDITY, + HumidifierEntityStateAttribute.HUMIDITY, "humidifier_target_humidity_percent", "Target Relative Humidity", ) self._enum_metric( state, - state.attributes.get(ATTR_MODE), - state.attributes.get(ATTR_AVAILABLE_MODES), + state.attributes.get(HumidifierEntityStateAttribute.MODE), + state.attributes.get(HumidifierEntityCapabilityAttribute.AVAILABLE_MODES), "humidifier_mode", "Humidifier Mode", "mode", @@ -927,44 +913,45 @@ class PrometheusMetrics: # Temperatures self._temperature_metric( state, - ATTR_TEMPERATURE, + WaterHeaterStateAttribute.TEMPERATURE, "water_heater_temperature_celsius", "Target temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + WaterHeaterStateAttribute.CURRENT_TEMPERATURE, "water_heater_current_temperature_celsius", "Current temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_TARGET_TEMP_HIGH, + WaterHeaterStateAttribute.TARGET_TEMP_HIGH, "water_heater_target_temperature_high_celsius", "Target high temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_TARGET_TEMP_LOW, + WaterHeaterStateAttribute.TARGET_TEMP_LOW, "water_heater_target_temperature_low_celsius", "Target low temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_MIN_TEMP, + WaterHeaterCapabilityAttribute.MIN_TEMP, "water_heater_min_temperature_celsius", "Minimum allowed temperature in degrees Celsius", ) self._temperature_metric( state, - WATER_HEATER_ATTR_MAX_TEMP, + WaterHeaterCapabilityAttribute.MAX_TEMP, "water_heater_max_temperature_celsius", "Maximum allowed temperature in degrees Celsius", ) self._enum_metric( state, - state.attributes.get(WATER_HEATER_ATTR_OPERATION_MODE) or state.state, - state.attributes.get(WATER_HEATER_ATTR_OPERATION_LIST), + state.attributes.get(WaterHeaterStateAttribute.OPERATION_MODE) + or state.state, + state.attributes.get(WaterHeaterCapabilityAttribute.OPERATION_LIST), "water_heater_operation_mode", "Water heater operation mode", "mode", @@ -973,7 +960,7 @@ class PrometheusMetrics: # Away mode bool self._bool_metric( state, - WATER_HEATER_ATTR_AWAY_MODE, + WaterHeaterStateAttribute.AWAY_MODE, "water_heater_away_mode", "Whether away mode is on (0/1)", {STATE_ON}, @@ -986,29 +973,32 @@ class PrometheusMetrics: def _handle_fan(self, state: State) -> None: self._numeric_metric(state, "fan", "fan") self._float_metric( - state, ATTR_PERCENTAGE, "fan_speed_percent", "Fan speed percent (0-100)" + state, + FanEntityStateAttribute.PERCENTAGE, + "fan_speed_percent", + "Fan speed percent (0-100)", ) self._bool_metric( state, - ATTR_OSCILLATING, + FanEntityStateAttribute.OSCILLATING, "fan_is_oscillating", "Whether the fan is oscillating (0/1)", ) self._enum_metric( state, - state.attributes.get(ATTR_PRESET_MODE), - state.attributes.get(ATTR_PRESET_MODES), + state.attributes.get(FanEntityStateAttribute.PRESET_MODE), + state.attributes.get(FanEntityCapabilityAttribute.PRESET_MODES), "fan_preset_mode", "Fan preset mode enum", "mode", ) - fan_direction = state.attributes.get(ATTR_DIRECTION) + fan_direction = state.attributes.get(FanEntityStateAttribute.DIRECTION) if fan_direction in {DIRECTION_FORWARD, DIRECTION_REVERSE}: self._bool_metric( state, - ATTR_DIRECTION, + FanEntityStateAttribute.DIRECTION, "fan_direction_reversed", "Fan direction reversed (bool)", {DIRECTION_REVERSE}, @@ -1050,7 +1040,9 @@ class PrometheusMetrics: ) def _handle_sensor(self, state: State) -> None: - unit = self._unit_string(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + unit = self._unit_string( + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ) for metric_handler in self._sensor_metric_handlers: metric = metric_handler(state, unit) @@ -1063,7 +1055,7 @@ class PrometheusMetrics: documentation = f"Sensor data measured in {unit}" if ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT ): value = TemperatureConverter.convert( @@ -1085,7 +1077,7 @@ class PrometheusMetrics: @staticmethod def _sensor_attribute_metric(state: State, unit: str | None) -> str | None: """Get metric based on device class attribute.""" - metric = state.attributes.get(ATTR_DEVICE_CLASS) + metric = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if metric is not None: return f"sensor_{metric}_{unit}" return None @@ -1096,7 +1088,7 @@ class PrometheusMetrics: These have no unit of measurement attribute. """ - metric = state.attributes.get(ATTR_DEVICE_CLASS) + metric = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if metric == SensorDeviceClass.TIMESTAMP: return f"sensor_{metric}_seconds" return None diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 6be9b1db7156..bd409a0889a6 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -5,17 +5,26 @@ from dataclasses import dataclass import logging from typing import cast, override -from homeassistant.components.device_tracker import ATTR_IN_ZONES -from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN, ENTITY_ID_HOME +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityStateAttribute, +) +from homeassistant.components.person import ( + DOMAIN as PERSON_DOMAIN, + PersonEntityStateAttribute, +) +from homeassistant.components.zone import ( + DOMAIN as ZONE_DOMAIN, + ENTITY_ID_HOME, + ZoneEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, STATE_HOME, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -48,6 +57,19 @@ _LOGGER = logging.getLogger(__name__) type ProximityConfigEntry = ConfigEntry[ProximityDataUpdateCoordinator] +def _tracked_in_zones(state: State) -> list[str] | None: + """Return the zone membership of a tracked entity state. + + Only person and device_tracker entities report zone membership; each + exposes it under its own platform enum. Any other domain returns None. + """ + if state.domain == PERSON_DOMAIN: + return state.attributes.get(PersonEntityStateAttribute.IN_ZONES) + if state.domain == DEVICE_TRACKER_DOMAIN: + return state.attributes.get(DeviceTrackerEntityStateAttribute.IN_ZONES) + return None + + @dataclass class StateChangedData: """StateChangedData class.""" @@ -143,7 +165,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): }, ) - def _device_in_zone(self, zone: State, device: State) -> bool: + def _tracked_entity_in_zone(self, zone: State, tracked_entity_state: State) -> bool: """Return whether the tracked entity is currently in the proximity zone.""" # Modern entity-based trackers and person entities always report zone @@ -160,35 +182,38 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): # (deprecated, removed in HA Core 2027.7). # For both, an empty or absent ``in_zones`` does not imply "in no zone", so we - # fall back to matching the device state against the zone's friendly name - # (what a tracker's state is set to for non-home zones), plus an explicit + # fall back to matching the tracked entity state against the zone's friendly name + # (what a tracked entity's state is set to for non-home zones), plus an explicit # home-zone check. Once both deprecations are gone, ``in_zones`` is - # authoritative for every tracker and this method should reduce to the + # authoritative for every tracked entity and this method should reduce to the # membership check alone; the fallback must be removed, as second-guessing an # empty list would then be incorrect. - if in_zones := device.attributes.get(ATTR_IN_ZONES): + if in_zones := _tracked_in_zones(tracked_entity_state): return zone.entity_id in in_zones # Remove once legacy device trackers (2027.5) and location_name (2027.7) # are gone, see detailed comment above - zone_friendly_name = zone.attributes.get(ATTR_FRIENDLY_NAME) + zone_friendly_name = zone.attributes.get(EntityStateAttribute.FRIENDLY_NAME) return ( zone_friendly_name is not None - and device.state.lower() == zone_friendly_name.lower() - ) or (device.state == STATE_HOME and zone.entity_id == ENTITY_ID_HOME) + and tracked_entity_state.state.lower() == zone_friendly_name.lower() + ) or ( + tracked_entity_state.state == STATE_HOME + and zone.entity_id == ENTITY_ID_HOME + ) def _calc_distance_to_zone( self, zone: State, - device: State, + tracked_entity_state: State, latitude: float | None, longitude: float | None, ) -> int | None: - if self._device_in_zone(zone, device): + if self._tracked_entity_in_zone(zone, tracked_entity_state): _LOGGER.debug( "%s: %s in zone -> distance=0", self.name, - device.entity_id, + tracked_entity_state.entity_id, ) return 0 @@ -196,13 +221,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): _LOGGER.debug( "%s: %s has no coordinates -> distance=None", self.name, - device.entity_id, + tracked_entity_state.entity_id, ) return None distance_to_centre = distance( - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], latitude, longitude, ) @@ -211,7 +236,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): # since zones must have lat/lon coordinates assert distance_to_centre is not None - zone_radius: float = zone.attributes["radius"] + zone_radius: float = zone.attributes[ZoneEntityStateAttribute.RADIUS] if zone_radius > distance_to_centre: # we've arrived the zone return 0 @@ -220,17 +245,17 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): def _calc_direction_of_travel( self, zone: State, - device: State, + tracked_entity_state: State, old_latitude: float | None, old_longitude: float | None, new_latitude: float | None, new_longitude: float | None, ) -> str | None: - if self._device_in_zone(zone, device): + if self._tracked_entity_in_zone(zone, tracked_entity_state): _LOGGER.debug( "%s: %s in zone -> direction_of_travel=arrived", self.name, - device.entity_id, + tracked_entity_state.entity_id, ) return "arrived" @@ -243,14 +268,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): return None old_distance = distance( - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], old_latitude, old_longitude, ) new_distance = distance( - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], new_latitude, new_longitude, ) @@ -306,8 +331,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): entities_data[entity_id][ATTR_DIST_TO] = self._calc_distance_to_zone( zone_state, tracked_entity_state, - tracked_entity_state.attributes.get(ATTR_LATITUDE), - tracked_entity_state.attributes.get(ATTR_LONGITUDE), + tracked_entity_state.attributes.get(EntityStateAttribute.LATITUDE), + tracked_entity_state.attributes.get(EntityStateAttribute.LONGITUDE), ) if entities_data[entity_id][ATTR_DIST_TO] is None: _LOGGER.debug( @@ -328,8 +353,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) if (old_state := state_change_data.old_state) is not None: - old_lat = old_state.attributes.get(ATTR_LATITUDE) - old_lon = old_state.attributes.get(ATTR_LONGITUDE) + old_lat = old_state.attributes.get(EntityStateAttribute.LATITUDE) + old_lon = old_state.attributes.get(EntityStateAttribute.LONGITUDE) else: old_lat = None old_lon = None @@ -340,8 +365,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): new_state, old_lat, old_lon, - new_state.attributes.get(ATTR_LATITUDE), - new_state.attributes.get(ATTR_LONGITUDE), + new_state.attributes.get(EntityStateAttribute.LATITUDE), + new_state.attributes.get(EntityStateAttribute.LONGITUDE), ) ) diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py index c304b4822f37..a5e4d179bcec 100644 --- a/homeassistant/components/proximity/diagnostics.py +++ b/homeassistant/components/proximity/diagnostics.py @@ -7,12 +7,11 @@ from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.components.person import ATTR_USER_ID from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant @@ -21,8 +20,8 @@ from .coordinator import ProximityConfigEntry TO_REDACT = { ATTR_GPS, ATTR_IP, - ATTR_LATITUDE, - ATTR_LONGITUDE, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, ATTR_MAC, ATTR_USER_ID, "context", diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 2f969fae5bed..291a6c298e31 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,43 +2,17 @@ import logging -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_TOKEN, - CONF_USERNAME, - CONF_VERIFY_SSL, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import ( - config_validation as cv, - entity_registry as er, - issue_registry as ir, -) -from homeassistant.helpers.typing import ConfigType +from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .const import ( AUTH_OTHER, AUTH_PAM, AUTH_PVE, CONF_AUTH_METHOD, - CONF_CONTAINERS, - CONF_NODE, - CONF_NODES, CONF_REALM, - CONF_TOKEN_ID, - CONF_TOKEN_SECRET, - CONF_VMS, - DEFAULT_PORT, DEFAULT_REALM, - DEFAULT_VERIFY_SSL, - DOMAIN, ) from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator @@ -49,107 +23,9 @@ PLATFORMS = [ ] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required( - CONF_AUTH_METHOD, default=DEFAULT_REALM - ): cv.string, - vol.Optional(CONF_REALM, default=DEFAULT_REALM): cv.string, - vol.Optional(CONF_TOKEN, default=False): cv.boolean, - vol.Optional(CONF_TOKEN_ID): cv.string, - vol.Optional(CONF_TOKEN_SECRET): cv.string, - vol.Optional( - CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL - ): cv.boolean, - vol.Required(CONF_NODES): vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_NODE): cv.string, - vol.Optional(CONF_VMS, default=[]): [ - cv.positive_int - ], - vol.Optional(CONF_CONTAINERS, default=[]): [ - cv.positive_int - ], - } - ) - ], - ), - } - ) - ], - ) - }, - extra=vol.ALLOW_EXTRA, -) - _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import the Proxmox configuration from YAML.""" - if DOMAIN not in config: - return True - - hass.async_create_task(_async_setup(hass, config)) - - return True - - -async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None: - for entry_config in config[DOMAIN]: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=entry_config, - ) - if ( - result.get("type") is FlowResultType.ABORT - and result.get("reason") != "already_configured" - ): - ir.async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result.get('reason')}", - breaks_in_ha_version="2026.8.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Proxmox VE", - }, - ) - return - - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2026.8.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Proxmox VE", - }, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool: """Set up a ProxmoxVE from a config entry.""" coordinator = ProxmoxCoordinator(hass, entry) diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 1dba1d6985ed..69f814a97c11 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -20,6 +20,7 @@ from .const import ( STORAGE_ENABLED, STORAGE_SHARED, VM_CONTAINER_RUNNING, + ProxmoxPermission, ) from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData from .entity import ( @@ -28,6 +29,7 @@ from .entity import ( ProxmoxStorageEntity, ProxmoxVMEntity, ) +from .helpers import is_granted PARALLEL_UPDATES = 0 @@ -51,6 +53,8 @@ class ProxmoxNodeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class to hold Proxmox node binary sensor description.""" state_fn: Callable[[ProxmoxNodeData], bool | None] + permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -67,6 +71,8 @@ NODE_SENSORS: tuple[ProxmoxNodeBinarySensorEntityDescription, ...] = ( state_fn=lambda data: data.node["status"] == NODE_ONLINE, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, + permission=ProxmoxPermission.VMAUDIT, # PVEVMUsers are allowed this node, through "/vms" + permission_target="vms", ), ProxmoxNodeBinarySensorEntityDescription( key="node_backup_status", @@ -132,10 +138,17 @@ async def async_setup_entry( def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None: """Add new node binary sensors.""" + async_add_entities( ProxmoxNodeBinarySensor(coordinator, entity_description, node) for node in nodes for entity_description in NODE_SENSORS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index e18a9922685d..b93e455dccab 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -17,7 +17,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -28,8 +28,6 @@ from .helpers import is_granted PARALLEL_UPDATES = 1 -NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power" - @dataclass(frozen=True, kw_only=True) class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): @@ -37,7 +35,6 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str], None] permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER - permission_raise: str = "no_permission_node_power" permission_target: str = "nodes" @@ -47,7 +44,6 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = NO_PERM_VM_LXC_POWER permission_target: str = "vms" @@ -57,7 +53,6 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = NO_PERM_VM_LXC_POWER permission_target: str = "vms" @@ -82,7 +77,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="start_all", translation_key="start_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -93,7 +87,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="stop_all", translation_key="stop_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -104,7 +97,6 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( key="suspend_all", translation_key="suspend_all", permission=ProxmoxPermission.POWER, - permission_raise=NO_PERM_VM_LXC_POWER, permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node @@ -185,7 +177,6 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = ( ) ), permission=ProxmoxPermission.SNAPSHOT, - permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -230,7 +221,6 @@ CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = ( ) ), permission=ProxmoxPermission.SNAPSHOT, - permission_raise="no_permission_snapshot", entity_category=EntityCategory.CONFIG, ), ) @@ -250,6 +240,12 @@ async def async_setup_entry( ProxmoxNodeButtonEntity(coordinator, entity_description, node) for node in nodes for entity_description in NODE_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( @@ -260,6 +256,12 @@ async def async_setup_entry( ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data) for (node_data, vm) in vms for entity_description in VM_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=vm["vmid"], + permission=entity_description.permission, + ) ) def _async_add_new_containers( @@ -272,6 +274,12 @@ async def async_setup_entry( ) for (node_data, container) in containers for entity_description in CONTAINER_BUTTONS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=container["vmid"], + permission=entity_description.permission, + ) ) coordinator.new_nodes_callbacks.append(_async_add_new_nodes) @@ -324,22 +332,22 @@ class ProxmoxBaseButton(ButtonEntity): except AuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect_no_details", + translation_key="cannot_connect", ) from err except SSLError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth_no_details", + translation_key="invalid_auth", ) from err except ConnectTimeout as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect_no_details", + translation_key="timeout_connect", ) from err except (ResourceException, requests.exceptions.ConnectionError) as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="api_error_no_details", + translation_key="api_error_details", ) from err @@ -351,21 +359,10 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the node button action via executor.""" - node_id = self._node_data.node["node"] - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=node_id, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, - node_id, + self._node_data.node["node"], ) @@ -377,22 +374,11 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the VM button action via executor.""" - vmid = self.vm_data["vmid"] - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=vmid, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, self._node_name, - vmid, + self.vm_data["vmid"], ) @@ -404,21 +390,9 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton): @override async def _async_press_call(self) -> None: """Execute the container button action via executor.""" - vmid = self.container_data["vmid"] - # Container power actions fall under vms - if not is_granted( - self.coordinator.permissions, - p_type=self.entity_description.permission_target, - p_id=vmid, - permission=self.entity_description.permission, - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key=self.entity_description.permission_raise, - ) await self.hass.async_add_executor_job( self.entity_description.press_action, self.coordinator, self._node_name, - vmid, + self.container_data["vmid"], ) diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index 565c37300fe5..14c4086f6a61 100644 --- a/homeassistant/components/proxmoxve/config_flow.py +++ b/homeassistant/components/proxmoxve/config_flow.py @@ -356,30 +356,6 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN): return proxmox_nodes, errors - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle a flow initiated by configuration file.""" - self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) - - try: - proxmox_nodes = await self.hass.async_add_executor_job( - _get_nodes_data, import_data - ) - except ProxmoxConnectTimeout: - return self.async_abort(reason="connect_timeout") - except ProxmoxAuthenticationError: - return self.async_abort(reason="invalid_auth") - except ProxmoxSSLError: - return self.async_abort(reason="ssl_error") - except ProxmoxNoNodesFound: - return self.async_abort(reason="no_nodes_found") - except ProxmoxConnectionError: - return self.async_abort(reason="cannot_connect") - - return self.async_create_entry( - title=import_data[CONF_HOST], - data={**import_data, CONF_NODES: proxmox_nodes}, - ) - def _get_auth_schema( self, data: Mapping[str, Any], diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index bfd944612a0d..8985a2a77ec9 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -41,4 +41,6 @@ class ProxmoxPermission(StrEnum): POWER = "VM.PowerMgmt" SNAPSHOT = "VM.Snapshot" + SYSAUDIT = "Sys.Audit" SYSPOWER = "Sys.PowerMgmt" + VMAUDIT = "VM.Audit" diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index e9cfffc16f6b..09b04b21d7a1 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -110,25 +110,21 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except SSLError as err: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="ssl_error", - translation_placeholders={"error": repr(err)}, ) from err except ConnectTimeout as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err except ProxmoxServerError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_error_details", - translation_placeholders={"error": repr(err)}, ) from err except ProxmoxPermissionsError as err: raise ConfigEntryAuthFailed( @@ -144,7 +140,6 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise ConfigEntryError( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err @override @@ -157,19 +152,16 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, ) from err except SSLError as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="ssl_error", - translation_placeholders={"error": repr(err)}, ) from err except ConnectTimeout as err: raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, ) from err except ResourceException as err: raise UpdateFailed( @@ -180,7 +172,6 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, ) from err data: dict[str, ProxmoxNodeData] = {} @@ -363,9 +354,7 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): for identifier in device.identifiers ): _LOGGER.debug("Removing stale device: %s", device.identifiers) - registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + registry.async_remove_device(device.id) class ProxmoxSetupError(Exception): diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py index a886b5af24cc..d4140fc13d5e 100644 --- a/homeassistant/components/proxmoxve/sensor.py +++ b/homeassistant/components/proxmoxve/sensor.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from .const import ProxmoxPermission from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData from .entity import ( ProxmoxContainerEntity, @@ -25,6 +26,7 @@ from .entity import ( ProxmoxStorageEntity, ProxmoxVMEntity, ) +from .helpers import is_granted PARALLEL_UPDATES = 0 @@ -34,6 +36,8 @@ class ProxmoxNodeSensorEntityDescription(SensorEntityDescription): """Class to hold Proxmox node sensor description.""" value_fn: Callable[[ProxmoxNodeData], StateType | datetime] + permission: ProxmoxPermission = ProxmoxPermission.SYSAUDIT + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -147,6 +151,8 @@ NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = ( value_fn=lambda data: data.node["status"], device_class=SensorDeviceClass.ENUM, options=["online", "offline"], + permission=ProxmoxPermission.VMAUDIT, + permission_target="vms", ), ProxmoxNodeSensorEntityDescription( key="node_backup_last_backup", @@ -230,7 +236,7 @@ VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = ( ProxmoxVMSensorEntityDescription( key="vm_uptime", translation_key="vm_uptime", - value_fn=lambda data: data["uptime"], + value_fn=lambda data: data.get("uptime"), device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.HOURS, @@ -347,7 +353,7 @@ CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = ( ProxmoxContainerSensorEntityDescription( key="container_uptime", translation_key="container_uptime", - value_fn=lambda data: data["uptime"], + value_fn=lambda data: data.get("uptime"), device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, suggested_unit_of_measurement=UnitOfTime.HOURS, @@ -474,6 +480,12 @@ async def async_setup_entry( ProxmoxNodeSensor(coordinator, entity_description, node) for node in nodes for entity_description in NODE_SENSORS + if is_granted( + coordinator.permissions, + p_type=entity_description.permission_target, + p_id=node.node["node"], + permission=entity_description.permission, + ) ) def _async_add_new_vms( diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index a92e6ef4506f..904b88f894de 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -297,35 +297,17 @@ }, "exceptions": { "api_error_details": { - "message": "An error occurred while communicating with the Proxmox VE instance: {error}" - }, - "api_error_no_details": { "message": "An error occurred while communicating with the Proxmox VE instance." }, "cannot_connect": { - "message": "An error occurred while trying to connect to the Proxmox VE instance: {error}" - }, - "cannot_connect_no_details": { - "message": "Could not connect to the Proxmox VE instance." + "message": "An error occurred while trying to connect to the Proxmox VE instance." }, "invalid_auth": { - "message": "An error occurred while trying to authenticate: {error}" - }, - "invalid_auth_no_details": { - "message": "Authentication failed for the Proxmox VE instance." + "message": "An error occurred while trying to authenticate." }, "no_nodes_found": { "message": "No active nodes were found on the Proxmox VE server." }, - "no_permission_node_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again." - }, - "no_permission_snapshot": { - "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." - }, - "no_permission_vm_lxc_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again." - }, "no_vmlxc_found": { "message": "No LXC or VM were found on the Proxmox VE server." }, @@ -333,37 +315,12 @@ "message": "Failed to retrieve Proxmox VE permissions. Please check your credentials and try again." }, "ssl_error": { - "message": "An SSL error occurred: {error}" + "message": "An SSL error occurred." }, "timeout_connect": { - "message": "A timeout occurred while trying to connect to the Proxmox VE instance: {error}" - }, - "timeout_connect_no_details": { "message": "A timeout occurred while trying to connect to the Proxmox VE instance." } }, - "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" - }, - "deprecated_yaml_import_issue_connect_timeout": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "The {integration_title} YAML configuration is being removed" - }, - "deprecated_yaml_import_issue_invalid_auth": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" - }, - "deprecated_yaml_import_issue_no_nodes_found": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, no active nodes were found on the Proxmox VE server. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" - }, - "deprecated_yaml_import_issue_ssl_error": { - "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", - "title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]" - } - }, "selector": { "auth_method": { "options": { diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 4c89754f04f1..0bc3f6608cf3 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==12.2.0"] + "requirements": ["Pillow==12.3.0"] } diff --git a/homeassistant/components/ptdevices/__init__.py b/homeassistant/components/ptdevices/__init__.py index 9a557749494e..00f8c28d8a86 100644 --- a/homeassistant/components/ptdevices/__init__.py +++ b/homeassistant/components/ptdevices/__init__.py @@ -11,6 +11,7 @@ from .const import DEFAULT_URL from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator _PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] diff --git a/homeassistant/components/ptdevices/binary_sensor.py b/homeassistant/components/ptdevices/binary_sensor.py new file mode 100644 index 000000000000..b3858200c171 --- /dev/null +++ b/homeassistant/components/ptdevices/binary_sensor.py @@ -0,0 +1,121 @@ +"""PTDevices Binary Sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import override + +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 homeassistant.helpers.typing import StateType + +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator +from .entity import PTDevicesEntity + +PARALLEL_UPDATES = 0 + + +class PTDevicesBinarySensors(StrEnum): + """Store keys for PTDevices binary sensors.""" + + DEVICE_BATTERY_STATUS = "battery_status" + DEVICE_EXTERNAL_POWER = "external_power" + + +@dataclass(kw_only=True, frozen=True) +class PTDevicesBinarySensorEntityDescription(BinarySensorEntityDescription): + """Description for PTDevices binary sensor entities.""" + + is_on_fn: Callable[[dict[str, StateType]], bool | None] + + +BINARY_SENSOR_DESCRIPTIONS: tuple[PTDevicesBinarySensorEntityDescription, ...] = ( + PTDevicesBinarySensorEntityDescription( + key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS, + translation_key=PTDevicesBinarySensors.DEVICE_BATTERY_STATUS, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: ( + None + if data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) + in (None, "unknown") + else data.get(PTDevicesBinarySensors.DEVICE_BATTERY_STATUS) == "low" + ), + ), + PTDevicesBinarySensorEntityDescription( + key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER, + translation_key=PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER, + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda data: ( + bool(data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER)) + if data.get(PTDevicesBinarySensors.DEVICE_EXTERNAL_POWER) is not None + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + async_add_entity: AddConfigEntryEntitiesCallback, +) -> None: + """Setup PTDevices binary sensors based on config entry.""" + coordinator = config_entry.runtime_data + + known_sensors: set[tuple[str, str]] = set() + + def _check_device() -> None: + for device_id in sorted(coordinator.data): + device = coordinator.data[device_id] + new_sensors = [ + sensor + for sensor in BINARY_SENSOR_DESCRIPTIONS + if sensor.key in device and (device_id, sensor.key) not in known_sensors + ] + if not new_sensors: + continue + known_sensors.update((device_id, sensor.key) for sensor in new_sensors) + async_add_entity( + PTDevicesBinarySensorEntity( + config_entry.runtime_data, sensor, device_id + ) + for sensor in new_sensors + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + + +class PTDevicesBinarySensorEntity(PTDevicesEntity, BinarySensorEntity): + """Defines a PTDevices binary sensor.""" + + entity_description: PTDevicesBinarySensorEntityDescription + + def __init__( + self, + coordinator: PTDevicesCoordinator, + description: PTDevicesBinarySensorEntityDescription, + device_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__( + coordinator, + description.key, + device_id, + ) + + self.entity_description = description + + @property + @override + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + return self.entity_description.is_on_fn(self.device) diff --git a/homeassistant/components/ptdevices/coordinator.py b/homeassistant/components/ptdevices/coordinator.py index 828034d089bd..6bb1b141610a 100644 --- a/homeassistant/components/ptdevices/coordinator.py +++ b/homeassistant/components/ptdevices/coordinator.py @@ -82,8 +82,6 @@ class PTDevicesCoordinator(DataUpdateCoordinator[PTDevicesResponseData]): ): if not set(device.identifiers) & identifiers: _LOGGER.debug("Removing stale device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) return data["body"] diff --git a/homeassistant/components/ptdevices/strings.json b/homeassistant/components/ptdevices/strings.json index 318c4fd1266d..9c5def4c87be 100644 --- a/homeassistant/components/ptdevices/strings.json +++ b/homeassistant/components/ptdevices/strings.json @@ -23,6 +23,11 @@ } }, "entity": { + "binary_sensor": { + "external_power": { + "name": "External power" + } + }, "sensor": { "battery_voltage": { "name": "Battery voltage" diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py index 02ae40b645de..c6a7b198a63d 100644 --- a/homeassistant/components/purpleair/entity.py +++ b/homeassistant/components/purpleair/entity.py @@ -5,7 +5,7 @@ from typing import Any, override from aiopurpleair.models.sensors import SensorModel -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP +from homeassistant.const import CONF_SHOW_ON_MAP, EntityStateAttribute from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -43,15 +43,15 @@ class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): @override def extra_state_attributes(self) -> Mapping[str, Any]: """Return entity specific state attributes.""" - attrs = {} + attrs: dict[str, Any] = {} # Displaying the geography on the map relies upon putting the latitude/longitude # in the entity attributes with "latitude" and "longitude" as the keys. # Conversely, we can hide the location on the map by using other keys, like # "lati" and "long": if self._entry.options.get(CONF_SHOW_ON_MAP): - attrs[ATTR_LATITUDE] = self.sensor_data.latitude - attrs[ATTR_LONGITUDE] = self.sensor_data.longitude + attrs[EntityStateAttribute.LATITUDE] = self.sensor_data.latitude + attrs[EntityStateAttribute.LONGITUDE] = self.sensor_data.longitude else: attrs["lati"] = self.sensor_data.latitude attrs["long"] = self.sensor_data.longitude diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py index 16e850f5e889..75187a83e090 100644 --- a/homeassistant/components/pushover/__init__.py +++ b/homeassistant/components/pushover/__init__.py @@ -1,4 +1,5 @@ """The pushover component.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from pushover_complete import BadAPIRequestError, PushoverAPI from requests.exceptions import RequestException diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 7d3e750f441e..f2b95947f546 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==12.2.0", "pyzbar==0.1.9"] + "requirements": ["Pillow==12.3.0", "pyzbar==0.1.9"] } diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index ab0886096cc7..90de274fb938 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -10,9 +10,12 @@ from homeassistant.components import cloud from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS +from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN from .device import RachioConfigEntry, RachioPerson +from .services import async_setup_services from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, async_register_webhook, @@ -23,6 +26,14 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SWITCH] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Rachio integration.""" + async_setup_services(hass) + return True + async def async_unload_entry(hass: HomeAssistant, entry: RachioConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 919f323029aa..5bf10c08f23f 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -5,16 +5,13 @@ import logging from typing import Any, override from rachiopy import Rachio -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from .const import ( - DOMAIN, KEY_BASE_STATIONS, KEY_DEVICES, KEY_ENABLED, @@ -30,31 +27,14 @@ from .const import ( KEY_USERNAME, KEY_ZONES, LISTEN_EVENT_TYPES, - MODEL_GENERATION_1, - SERVICE_PAUSE_WATERING, - SERVICE_RESUME_WATERING, - SERVICE_STOP_WATERING, WEBHOOK_CONST_ID, ) from .coordinator import RachioScheduleUpdateCoordinator, RachioUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTR_DEVICES = "devices" -ATTR_DURATION = "duration" PERMISSION_ERROR = "7" -PAUSE_SERVICE_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_DEVICES): cv.string, - vol.Optional(ATTR_DURATION, default=60): cv.positive_int, - } -) - -RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) - -STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) - type RachioConfigEntry = ConfigEntry[RachioPerson] @@ -72,66 +52,8 @@ class RachioPerson: self._base_stations: list[RachioBaseStation] = [] async def async_setup(self, hass: HomeAssistant) -> None: - """Create rachio devices and services.""" + """Create rachio devices.""" await hass.async_add_executor_job(self._setup, hass) - can_pause = False - for rachio_iro in self._controllers: - # Generation 1 controllers don't support pause or resume - if rachio_iro.model.split("_")[0] != MODEL_GENERATION_1: - can_pause = True - break - - all_controllers = [rachio_iro.name for rachio_iro in self._controllers] - - def pause_water(service: ServiceCall) -> None: - """Service to pause watering on all or specific controllers.""" - duration = service.data[ATTR_DURATION] - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.pause_watering(duration) - - def resume_water(service: ServiceCall) -> None: - """Service to resume watering on all or specific controllers.""" - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.resume_watering() - - def stop_water(service: ServiceCall) -> None: - """Service to stop watering on all or specific controllers.""" - devices = service.data.get(ATTR_DEVICES, all_controllers) - for iro in self._controllers: - if iro.name in devices: - iro.stop_watering() - - # If only hose timers on account, none of these services apply - if not all_controllers: - return - - hass.services.async_register( - DOMAIN, - SERVICE_STOP_WATERING, - stop_water, - schema=STOP_SERVICE_SCHEMA, - ) - - if not can_pause: - return - - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_WATERING, - pause_water, - schema=PAUSE_SERVICE_SCHEMA, - ) - - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_WATERING, - resume_water, - schema=RESUME_SERVICE_SCHEMA, - ) def _setup(self, hass: HomeAssistant) -> None: """Rachio device setup.""" diff --git a/homeassistant/components/rachio/services.py b/homeassistant/components/rachio/services.py new file mode 100644 index 000000000000..1e30e85e4645 --- /dev/null +++ b/homeassistant/components/rachio/services.py @@ -0,0 +1,135 @@ +"""Services for the Rachio integration.""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID, Platform +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + service, +) + +from .const import ( + DOMAIN, + KEY_ID, + MODEL_GENERATION_1, + SERVICE_PAUSE_WATERING, + SERVICE_RESUME_WATERING, + SERVICE_START_MULTIPLE_ZONES, + SERVICE_STOP_WATERING, +) +from .device import RachioConfigEntry + +_LOGGER = logging.getLogger(__name__) + +ATTR_DEVICES = "devices" +ATTR_DURATION = "duration" +ATTR_SORT_ORDER = "sortOrder" + +PAUSE_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_DEVICES): cv.string, + vol.Optional(ATTR_DURATION, default=60): cv.positive_int, + } +) + +RESUME_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) + +START_MULTIPLE_ZONES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_DURATION): cv.ensure_list_csv, + } +) + +STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) + + +def _stop_water(call: ServiceCall) -> None: + """Stop watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices: + iro.stop_watering() + + +def _pause_water(call: ServiceCall) -> None: + """Pause watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices and iro.model.split("_")[0] != MODEL_GENERATION_1: + iro.pause_watering(call.data[ATTR_DURATION]) + + +def _resume_water(call: ServiceCall) -> None: + """Resume watering on all or specific controllers.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + devices = call.data.get(ATTR_DEVICES, [iro.name for iro in person.controllers]) + for iro in person.controllers: + if iro.name in devices and iro.model.split("_")[0] != MODEL_GENERATION_1: + iro.resume_watering() + + +def _start_multiple(call: ServiceCall) -> None: + """Start multiple zones in sequence.""" + entry: RachioConfigEntry = service.async_get_config_entry(call.hass, DOMAIN, None) + person = entry.runtime_data + entity_reg = er.async_get(call.hass) + duration = iter(call.data[ATTR_DURATION]) + default_time = call.data[ATTR_DURATION][0] + + entity_to_zone_id = { + entity_reg.async_get_entity_id( + Platform.SWITCH, + DOMAIN, + f"{controller.controller_id}-zone-{zone[KEY_ID]}", + ): zone[KEY_ID] + for controller in person.controllers + for zone in controller.list_zones() + } + + zones_list = [ + { + ATTR_ID: entity_to_zone_id[entity_id], + ATTR_DURATION: int(next(duration, default_time)) * 60, + ATTR_SORT_ORDER: count, + } + for count, entity_id in enumerate(call.data[ATTR_ENTITY_ID]) + if entity_id in entity_to_zone_id + ] + + if not zones_list: + raise HomeAssistantError("No matching zones found in given entity_ids") + + person.start_multiple_zones(zones_list) + _LOGGER.debug("Starting zone(s) %s", call.data[ATTR_ENTITY_ID]) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Rachio services.""" + + hass.services.async_register( + DOMAIN, SERVICE_STOP_WATERING, _stop_water, schema=STOP_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_PAUSE_WATERING, _pause_water, schema=PAUSE_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_RESUME_WATERING, _resume_water, schema=RESUME_SERVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_MULTIPLE_ZONES, + _start_multiple, + schema=START_MULTIPLE_ZONES_SCHEMA, + ) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 146618fad2be..83025aa03c85 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -9,9 +9,7 @@ from typing import Any, override import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -22,7 +20,6 @@ from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_ti from .const import ( CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, - DOMAIN, KEY_CURRENT_STATUS, KEY_CUSTOM_CROP, KEY_CUSTOM_SHADE, @@ -45,7 +42,6 @@ from .const import ( SCHEDULE_TYPE_FIXED, SCHEDULE_TYPE_FLEX, SERVICE_SET_ZONE_MOISTURE, - SERVICE_START_MULTIPLE_ZONES, SERVICE_START_WATERING, SIGNAL_RACHIO_CONTROLLER_UPDATE, SIGNAL_RACHIO_RAIN_DELAY_UPDATE, @@ -80,7 +76,6 @@ ATTR_SCHEDULE_SUMMARY = "Summary" ATTR_SCHEDULE_ENABLED = "Enabled" ATTR_SCHEDULE_DURATION = "Duration" ATTR_SCHEDULE_TYPE = "Type" -ATTR_SORT_ORDER = "sortOrder" ATTR_WATERING_DURATION = "Watering Duration seconds" ATTR_ZONE_NUMBER = "Zone number" ATTR_ZONE_SHADE = "Shade" @@ -88,13 +83,6 @@ ATTR_ZONE_SLOPE = "Slope" ATTR_ZONE_SUMMARY = "Summary" ATTR_ZONE_TYPE = "Type" -START_MULTIPLE_ZONES_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_DURATION): cv.ensure_list_csv, - } -) - async def async_setup_entry( hass: HomeAssistant, @@ -102,47 +90,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio switches.""" - zone_entities = [] has_flex_sched = False entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) for entity in entities: - if isinstance(entity, RachioZone): - zone_entities.append(entity) if isinstance(entity, RachioSchedule) and entity.type == SCHEDULE_TYPE_FLEX: has_flex_sched = True async_add_entities(entities) - def start_multiple(service: ServiceCall) -> None: - """Service to start multiple zones in sequence.""" - zones_list = [] - person = config_entry.runtime_data - entity_id = service.data[ATTR_ENTITY_ID] - duration = iter(service.data[ATTR_DURATION]) - default_time = service.data[ATTR_DURATION][0] - entity_to_zone_id = { - entity.entity_id: entity.zone_id for entity in zone_entities - } - - for count, data in enumerate(entity_id): - if data in entity_to_zone_id: - # Time can be passed as a list per zone, - # or one time for all zones - time = int(next(duration, default_time)) * 60 - zones_list.append( - { - ATTR_ID: entity_to_zone_id.get(data), - ATTR_DURATION: time, - ATTR_SORT_ORDER: count, - } - ) - - if len(zones_list) != 0: - person.start_multiple_zones(zones_list) - _LOGGER.debug("Starting zone(s) %s", entity_id) - else: - raise HomeAssistantError("No matching zones found in given entity_ids") - platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_START_WATERING, @@ -152,18 +107,6 @@ async def async_setup_entry( "turn_on", ) - # If only hose timers on account, none of these services apply - if not zone_entities: - return - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_START_MULTIPLE_ZONES, - start_multiple, - schema=START_MULTIPLE_ZONES_SCHEMA, - ) - if has_flex_sched: platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/radio_frequency/__init__.py b/homeassistant/components/radio_frequency/__init__.py index 087d9e890706..bf4ba5ca5fa6 100644 --- a/homeassistant/components/radio_frequency/__init__.py +++ b/homeassistant/components/radio_frequency/__init__.py @@ -6,9 +6,9 @@ import logging from rf_protocols import ModulationType, RadioFrequencyCommand from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -18,11 +18,14 @@ from .entity import ( RadioFrequencyTransmitterEntity, RadioFrequencyTransmitterEntityDescription, ) +from .helpers import RadioFrequencyTransmitterConsumerEntity, async_send_command __all__ = [ "DATA_COMPONENT", "DOMAIN", "ModulationType", + "RadioFrequencyCommand", + "RadioFrequencyTransmitterConsumerEntity", "RadioFrequencyTransmitterEntity", "RadioFrequencyTransmitterEntityDescription", "async_get_transmitters", @@ -95,60 +98,3 @@ def async_get_transmitters( if entity.supports_modulation(modulation) and entity.supports_frequency(frequency) ] - - -async def async_send_command( - hass: HomeAssistant, - entity_id_or_uuid: str, - command: RadioFrequencyCommand, - context: Context | None = None, -) -> None: - """Send an RF command to the specified radio_frequency entity. - - Raises: - vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity - registry UUID. - HomeAssistantError: If the radio_frequency component is not loaded or the - resolved entity is not found. - """ - component = hass.data.get(DATA_COMPONENT) - if component is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="component_not_loaded", - ) - - ent_reg = er.async_get(hass) - entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) - entity = component.get_entity(entity_id) - if entity is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="entity_not_found", - translation_placeholders={"entity_id": entity_id}, - ) - - if not entity.supports_frequency(command.frequency): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unsupported_frequency", - translation_placeholders={ - "entity_id": entity_id, - "frequency": str(command.frequency), - }, - ) - - if not entity.supports_modulation(command.modulation): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unsupported_modulation", - translation_placeholders={ - "entity_id": entity_id, - "modulation": command.modulation, - }, - ) - - if context is not None: - entity.async_set_context(context) - - await entity.async_send_command_internal(command) diff --git a/homeassistant/components/radio_frequency/helpers.py b/homeassistant/components/radio_frequency/helpers.py new file mode 100644 index 000000000000..1fbefbfcfd52 --- /dev/null +++ b/homeassistant/components/radio_frequency/helpers.py @@ -0,0 +1,171 @@ +"""Helper base entities for integrations that consume RF transmitters.""" + +import logging +from typing import override + +from rf_protocols import RadioFrequencyCommand + +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import ( + async_track_entity_registry_updated_event, + async_track_state_change_event, +) + +from .const import DATA_COMPONENT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_send_command( + hass: HomeAssistant, + entity_id_or_uuid: str, + command: RadioFrequencyCommand, + context: Context | None = None, +) -> None: + """Send an RF command to the specified radio_frequency entity. + + Raises: + vol.Invalid: If `entity_id_or_uuid` is not a valid entity ID or known entity + registry UUID. + HomeAssistantError: If the radio_frequency component is not loaded or the + resolved entity is not found. + """ + component = hass.data.get(DATA_COMPONENT) + if component is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="component_not_loaded", + ) + + ent_reg = er.async_get(hass) + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + entity = component.get_entity(entity_id) + if entity is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + if not entity.supports_frequency(command.frequency): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_frequency", + translation_placeholders={ + "entity_id": entity_id, + "frequency": str(command.frequency), + }, + ) + + if not entity.supports_modulation(command.modulation): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_modulation", + translation_placeholders={ + "entity_id": entity_id, + "modulation": command.modulation, + }, + ) + + if context is not None: + entity.async_set_context(context) + + await entity.async_send_command_internal(command) + + +class RadioFrequencyTransmitterConsumerEntity(Entity): + """Base entity for integrations that send commands via an RF transmitter. + + Tracks the availability of the underlying RF transmitter entity. + """ + + _attr_should_poll = False + _rf_transmitter_entity_id_or_uuid: str + _rf_unsubscribes: list[CALLBACK_TYPE] + + @override + async def async_added_to_hass(self) -> None: + """Subscribe to RF entity state and rename events.""" + await super().async_added_to_hass() + + self._rf_unsubscribes = [] + self.async_on_remove(self._async_unsubscribe_rf) + self._async_track_rf_entity( + er.async_validate_entity_id( + er.async_get(self.hass), self._rf_transmitter_entity_id_or_uuid + ) + ) + + @callback + def _async_unsubscribe_rf(self) -> None: + """Tear down the current transmitter subscriptions.""" + while self._rf_unsubscribes: + self._rf_unsubscribes.pop()() + + @callback + def _async_track_rf_entity(self, entity_id: str) -> None: + """Track state and rename events for the resolved transmitter entity_id.""" + self._async_unsubscribe_rf() + self._rf_unsubscribes.append( + async_track_state_change_event( + self.hass, [entity_id], self._async_rf_state_changed + ) + ) + self._rf_unsubscribes.append( + async_track_entity_registry_updated_event( + self.hass, entity_id, self._async_rf_registry_updated + ) + ) + rf_state = self.hass.states.get(entity_id) + self._attr_available = ( + rf_state is not None and rf_state.state != STATE_UNAVAILABLE + ) + + async def _send_command(self, command: RadioFrequencyCommand) -> None: + """Send an RF command through the RF transmitter entity.""" + await async_send_command( + self.hass, + self._rf_transmitter_entity_id_or_uuid, + command, + context=self._context, + ) + + @callback + def _async_rf_registry_updated( + self, event: Event[er.EventEntityRegistryUpdatedData] + ) -> None: + """Re-track the transmitter when it is renamed.""" + data = event.data + if data["action"] != "update": + return + if ATTR_ENTITY_ID not in data["changes"]: + return + self._async_track_rf_entity(data[ATTR_ENTITY_ID]) + self.async_write_ha_state() + + @callback + def _async_rf_state_changed(self, event: Event[EventStateChangedData]) -> None: + """Handle RF entity state changes.""" + new_state = event.data["new_state"] + rf_available = new_state is not None and new_state.state != STATE_UNAVAILABLE + if rf_available != self.available: + _LOGGER.info( + "Radio frequency entity %s used by %s is %s", + event.data["entity_id"], + self.entity_id, + "available" if rf_available else "unavailable", + ) + + self._attr_available = rf_available + self.async_write_ha_state() diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index a006f9ef6363..57f4f5f7e012 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -132,6 +132,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> entry.runtime_data = data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + return True @@ -273,3 +275,10 @@ def _async_fix_device_id( async def async_unload_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_listener( + hass: HomeAssistant, entry: RainbirdConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 3f2aaa86c03c..65a5c6e68f2d 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==6.3.1"] + "requirements": ["pyrainbird==6.5.0"] } diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index d44d1d836fee..33a0df382171 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType -from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States, StatesMeta +from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, StatesMeta DOMAIN = "history" HISTORY_FILTERS = "history_filters" @@ -205,19 +205,6 @@ class Filters: # - Otherwise: exclude return i_entities - def states_entity_filter(self) -> ColumnElement: - """Generate the States.entity_id filter query. - - This is no longer used except by the legacy queries. - """ - - def _encoder(data: Any) -> Any: - """Nothing to encode for states since there is no json.""" - return data - - # The type annotation should be improved so the type ignore can be removed - return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type] - def states_metadata_entity_filter(self) -> ColumnElement: """Generate the StatesMeta.entity_id filter query.""" diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index d3ce05fae688..132a77533e2e 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -159,6 +159,24 @@ class MutexPool(StaticPool): ) MutexPool.pool_lock.release() + @override + def dispose(self) -> None: + """Dispose of the shared connection under the pool lock. + + StaticPool.dispose() closes the single in-memory connection directly. + Without the lock it can close it while another thread has it checked + out and is mid-query, freeing the sqlite3 handle underneath a running + statement -> segfault. Holding pool_lock makes dispose wait for the + in-flight checkout to return first. + """ + # pylint: disable-next=consider-using-with + got_lock = MutexPool.pool_lock.acquire(timeout=10) + try: + super().dispose() + finally: + if got_lock: + MutexPool.pool_lock.release() + @override def _do_get(self) -> ConnectionPoolEntry: if DEBUG_MUTEX_POOL_TRACE: diff --git a/homeassistant/components/remote_calendar/__init__.py b/homeassistant/components/remote_calendar/__init__.py index 910eeae8268c..8137fb49eb4b 100644 --- a/homeassistant/components/remote_calendar/__init__.py +++ b/homeassistant/components/remote_calendar/__init__.py @@ -1,4 +1,5 @@ """The Remote Calendar integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 158c73ac4e32..7273bf345e8d 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -8,7 +8,7 @@ from ical.event import Event from ical.timeline import Timeline, materialize_timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -88,15 +88,13 @@ class RemoteCalendarEntity( return await self.hass.async_add_executor_job(events_in_range) - @override - async def async_update(self) -> None: - """Refresh the timeline. + async def _async_update_timeline(self) -> None: + """Refresh the timeline and write state. This is called when the coordinator updates. Creating the timeline may require walking through the entire calendar and handling recurring events, so it is done as a separate task without blocking the event loop. """ - await super().async_update() def _get_timeline() -> Timeline | None: """Return a materialized timeline with upcoming events.""" @@ -111,6 +109,28 @@ class RemoteCalendarEntity( self._timeline = await self.hass.async_add_executor_job(_get_timeline) + @override + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + await self._async_update_timeline() + self.async_write_ha_state() + + @callback + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.coordinator.config_entry.async_create_task( + self.hass, + self._async_handle_coordinator_update(), + name="remote calendar timeline update", + ) + + async def _async_handle_coordinator_update(self) -> None: + """Refresh the timeline and write state.""" + await self._async_update_timeline() + self.async_write_ha_state() + def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 094cc9194021..7888be0fffca 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -172,6 +172,12 @@ BUTTON_ENTITIES = ( supported=lambda api, ch: api.supported(ch, "reboot"), method=lambda api, ch: api.reboot(ch), ), + ReolinkButtonEntityDescription( + key="pre_siren", + translation_key="pre_siren", + supported=lambda api, ch: api.supported(ch, "pre_siren"), + method=lambda api, ch: api.baichuan.PreAlarm(ch), + ), ) HOST_BUTTON_ENTITIES = ( diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index a5716aa76d20..7f5822f8d2a6 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -141,6 +141,9 @@ "guard_set": { "default": "mdi:crosshairs-gps" }, + "pre_siren": { + "default": "mdi:alarm-light" + }, "ptz_auto": { "default": "mdi:infinity" }, @@ -584,6 +587,9 @@ "pre_record": { "default": "mdi:history" }, + "pre_siren_on_event": { + "default": "mdi:alarm-light" + }, "privacy_mask": { "default": "mdi:eye", "state": { diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index f53a51c0c328..89d9d0e2ab9e 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -20,5 +20,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.21.3"] + "requirements": ["reolink-aio==0.21.4"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 9982a3c21bc8..5df5df9c4136 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -203,6 +203,9 @@ "guard_set": { "name": "Guard set current position" }, + "pre_siren": { + "name": "Pre-siren" + }, "ptz_auto": { "name": "PTZ continuous rotation" }, @@ -543,7 +546,8 @@ "autoadaptive": "Auto adaptive", "off": "[%key:common::state::off%]", "onatnight": "On at night", - "schedule": "Schedule" + "schedule": "Schedule", + "scheduleplus": "Schedule plus" } }, "hdr": { @@ -846,6 +850,9 @@ "pre_record": { "name": "Pre-recording" }, + "pre_siren_on_event": { + "name": "Pre-siren on event" + }, "privacy_mask": { "name": "Privacy mask" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 69be215d546d..2d9dbf3a73b6 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -101,6 +101,16 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.audio_alarm_enabled(ch), method=lambda api, ch, value: api.set_audio_alarm(ch, value), ), + ReolinkSwitchEntityDescription( + key="pre_siren_on_event", + cmd_key="GetAudioCfg", + cmd_id=264, + translation_key="pre_siren_on_event", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "pre_siren"), + value=lambda api, ch: api.pre_alarm_enabled(ch), + method=lambda api, ch, value: api.set_pre_alarm(ch, value), + ), ReolinkSwitchEntityDescription( key="auto_tracking", cmd_key="GetAiCfg", diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 92846c85f094..7d6627d01ec4 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -117,12 +117,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: skip_url_encoding = command_config[CONF_SKIP_URL_ENCODING] auth = None - digest_middleware = None + digest_auth: tuple[str, str] | None = None if CONF_USERNAME in command_config: username = command_config[CONF_USERNAME] password = command_config.get(CONF_PASSWORD, "") if command_config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - digest_middleware = aiohttp.DigestAuthMiddleware(username, password) + digest_auth = (username, password) else: auth = aiohttp.BasicAuth(username, password=password) @@ -177,8 +177,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Add authentication if auth is not None: request_kwargs["auth"] = auth - elif digest_middleware is not None: - request_kwargs["middlewares"] = (digest_middleware,) + elif digest_auth is not None: + request_kwargs["middlewares"] = ( + aiohttp.DigestAuthMiddleware(*digest_auth), + ) async with getattr(websession, method)( URL(request_url, encoded=skip_url_encoding), diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 58dc85f1fc12..dd15a21e5bcc 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, ColorMode, LightEntity, + LightEntityStateAttribute, ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant @@ -215,10 +216,13 @@ class DimmableRflinkLight(SwitchableRflinkDevice, LightEntity): old_state = await self.async_get_last_state() if ( old_state is not None - and old_state.attributes.get(ATTR_BRIGHTNESS) is not None + and old_state.attributes.get(LightEntityStateAttribute.BRIGHTNESS) + is not None ): # restore also brightness in dimmables devices - self._brightness = int(old_state.attributes[ATTR_BRIGHTNESS]) + self._brightness = int( + old_state.attributes[LightEntityStateAttribute.BRIGHTNESS] + ) @override async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 90393589263c..a67dec515aeb 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -1,4 +1,5 @@ """Support for RFXtrx devices.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import binascii from collections.abc import Callable, Mapping @@ -7,7 +8,6 @@ import logging from typing import Any, NamedTuple, cast import RFXtrx as rfxtrxmod -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import EventDeviceRegistryUpdatedData @@ -30,9 +30,9 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_EVENT, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_PROTOCOLS, @@ -40,9 +40,9 @@ from .const import ( DEVICE_PACKET_TYPE_LIGHTING4, DOMAIN, EVENT_RFXTRX_EVENT, - SERVICE_SEND, SIGNAL_EVENT, ) +from .services import async_setup_services DEFAULT_OFF_DELAY = 2.0 @@ -59,18 +59,6 @@ class DeviceTuple(NamedTuple): id_string: str -def _bytearray_string(data: Any) -> bytearray: - val = cv.string(data) - try: - return bytearray.fromhex(val) - except ValueError as err: - raise vol.Invalid( - "Data must be a hex string with multiple of two characters" - ) from err - - -SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.COVER, @@ -81,6 +69,15 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up RFXtrx services.""" + hass.data.setdefault(DOMAIN, {}) + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the RFXtrx component.""" @@ -97,12 +94,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False - hass.services.async_remove(DOMAIN, SERVICE_SEND) - rfx_object = hass.data[DOMAIN][DATA_RFXOBJECT] await hass.async_add_executor_job(rfx_object.close_connection) - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(DATA_RFXOBJECT) return True @@ -284,13 +279,6 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) ) - def send(call: ServiceCall) -> None: - event = call.data[ATTR_EVENT] - rfx_object.transport.send(event) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) - async def async_setup_platform_entry( hass: HomeAssistant, diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 77316c8d1323..99af025f1c0a 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -5,7 +5,12 @@ from typing import Any, override import RFXtrx as rfxtrxmod -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback @@ -70,7 +75,9 @@ class RfxtrxLight(RfxtrxCommandEntity, LightEntity): old_state = await self.async_get_last_state() if old_state is not None: self._attr_is_on = old_state.state == STATE_ON - if brightness := old_state.attributes.get(ATTR_BRIGHTNESS): + if brightness := old_state.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ): self._attr_brightness = int(brightness) @override diff --git a/homeassistant/components/rfxtrx/services.py b/homeassistant/components/rfxtrx/services.py new file mode 100644 index 000000000000..896b6b8ffd81 --- /dev/null +++ b/homeassistant/components/rfxtrx/services.py @@ -0,0 +1,38 @@ +"""Support for RFXtrx services.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern + +from typing import Any + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_EVENT, DATA_RFXOBJECT, DOMAIN, SERVICE_SEND + + +def _bytearray_string(data: Any) -> bytearray: + val = cv.string(data) + try: + return bytearray.fromhex(val) + except ValueError as err: + raise vol.Invalid( + "Data must be a hex string with multiple of two characters" + ) from err + + +SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the RFXtrx services.""" + + def send(call: ServiceCall) -> None: + rfx_object = hass.data.get(DOMAIN, {}).get(DATA_RFXOBJECT) + if rfx_object is None: + raise HomeAssistantError("RFXtrx is not connected, cannot send event") + rfx_object.transport.send(call.data[ATTR_EVENT]) + + hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 899e54f45ed8..ada6df9a8469 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -183,10 +183,7 @@ def _remove_stale_devices( "Removing device: %s because it no longer exists in your account", device.name, ) - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device.id) async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: @@ -277,17 +274,22 @@ async def async_setup_device( _LOGGER.warning("Failed to close device %s: %s", device.duid, err) return + try: + await coordinator.async_refresh() + except RoborockException as err: + _LOGGER.error( + "Failed initial attempt to connect to device %s (%s): %s", + device.name, + device.duid, + err, + ) + entry.runtime_data.add(coordinator) async_dispatcher_send( hass, f"roborock_coordinator_added_{entry.entry_id}", coordinator, ) - entry.async_create_background_task( - hass, - coordinator.async_refresh(), - name=f"roborock_coordinator_refresh_{coordinator.duid}", - ) async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 3a4af80c2df2..f6e30aa93306 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -490,6 +490,26 @@ class RoborockDataUpdateCoordinatorA01[ return self._device +ZEO_REQUEST_PROTOCOLS = [ + RoborockZeoProtocol.STATE, + RoborockZeoProtocol.COUNTDOWN, + RoborockZeoProtocol.WASHING_LEFT, + RoborockZeoProtocol.ERROR, + RoborockZeoProtocol.TIMES_AFTER_CLEAN, + RoborockZeoProtocol.DETERGENT_EMPTY, + RoborockZeoProtocol.SOFTENER_EMPTY, + RoborockZeoProtocol.DETERGENT_TYPE, + RoborockZeoProtocol.SOFTENER_TYPE, + RoborockZeoProtocol.MODE, + RoborockZeoProtocol.PROGRAM, + RoborockZeoProtocol.TEMP, + RoborockZeoProtocol.RINSE_TIMES, + RoborockZeoProtocol.SPIN_LEVEL, + RoborockZeoProtocol.DRYING_MODE, + RoborockZeoProtocol.SOUND_SET, +] + + class RoborockWashingMachineUpdateCoordinator( RoborockDataUpdateCoordinatorA01[RoborockZeoProtocol] ): @@ -505,25 +525,11 @@ class RoborockWashingMachineUpdateCoordinator( """Initialize.""" super().__init__(hass, config_entry, device) self.api = api - self.request_protocols: list[RoborockZeoProtocol] = [] - # This currently only supports the washing machine protocols + supported_schema_ids = device.product.supported_schema_ids self.request_protocols = [ - RoborockZeoProtocol.STATE, - RoborockZeoProtocol.COUNTDOWN, - RoborockZeoProtocol.WASHING_LEFT, - RoborockZeoProtocol.ERROR, - RoborockZeoProtocol.TIMES_AFTER_CLEAN, - RoborockZeoProtocol.DETERGENT_EMPTY, - RoborockZeoProtocol.SOFTENER_EMPTY, - RoborockZeoProtocol.DETERGENT_TYPE, - RoborockZeoProtocol.SOFTENER_TYPE, - RoborockZeoProtocol.MODE, - RoborockZeoProtocol.PROGRAM, - RoborockZeoProtocol.TEMP, - RoborockZeoProtocol.RINSE_TIMES, - RoborockZeoProtocol.SPIN_LEVEL, - RoborockZeoProtocol.DRYING_MODE, - RoborockZeoProtocol.SOUND_SET, + protocol + for protocol in ZEO_REQUEST_PROTOCOLS + if not supported_schema_ids or protocol in supported_schema_ids ] @override @@ -540,6 +546,16 @@ class RoborockWashingMachineUpdateCoordinator( ) from ex +DYAD_REQUEST_PROTOCOLS = [ + RoborockDyadDataProtocol.STATUS, + RoborockDyadDataProtocol.POWER, + RoborockDyadDataProtocol.MESH_LEFT, + RoborockDyadDataProtocol.BRUSH_LEFT, + RoborockDyadDataProtocol.ERROR, + RoborockDyadDataProtocol.TOTAL_RUN_TIME, +] + + class RoborockWetDryVacUpdateCoordinator( RoborockDataUpdateCoordinatorA01[RoborockDyadDataProtocol] ): @@ -555,14 +571,11 @@ class RoborockWetDryVacUpdateCoordinator( """Initialize.""" super().__init__(hass, config_entry, device) self.api = api - # This currenltly only supports the WetDryVac protocols - self.request_protocols: list[RoborockDyadDataProtocol] = [ - RoborockDyadDataProtocol.STATUS, - RoborockDyadDataProtocol.POWER, - RoborockDyadDataProtocol.MESH_LEFT, - RoborockDyadDataProtocol.BRUSH_LEFT, - RoborockDyadDataProtocol.ERROR, - RoborockDyadDataProtocol.TOTAL_RUN_TIME, + supported_schema_ids = device.product.supported_schema_ids + self.request_protocols = [ + protocol + for protocol in DYAD_REQUEST_PROTOCOLS + if not supported_schema_ids or protocol in supported_schema_ids ] @override diff --git a/homeassistant/components/roborock/icons.json b/homeassistant/components/roborock/icons.json index 71018ee9e14e..bc018d23789b 100644 --- a/homeassistant/components/roborock/icons.json +++ b/homeassistant/components/roborock/icons.json @@ -40,6 +40,11 @@ "default": "mdi:brush" } }, + "image": { + "map": { + "default": "mdi:floor-plan" + } + }, "number": { "volume": { "default": "mdi:volume-source" @@ -123,12 +128,18 @@ } }, "switch": { + "button_light": { + "default": "mdi:lightbulb" + }, "child_lock": { "default": "mdi:account-lock" }, "dnd_switch": { "default": "mdi:bell-cancel" }, + "dust_collection": { + "default": "mdi:delete" + }, "off_peak_switch": { "default": "mdi:power-plug" }, diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 6c9e2327e4cd..913eab5c8b1c 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -14,13 +14,15 @@ 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 homeassistant.util import dt as dt_util from .coordinator import ( + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockCoordinatorType, RoborockDataUpdateCoordinator, ) -from .entity import RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1 _LOGGER = logging.getLogger(__name__) @@ -40,20 +42,22 @@ async def async_setup_entry( coordinator: RoborockCoordinatorType, ) -> None: """Add entities for a specific coordinator.""" - if not isinstance(coordinator, RoborockDataUpdateCoordinator): - return - entities = [ - RoborockMap( - config_entry, - coordinator, - coordinator.properties_api.home, - map_info.map_flag, - map_info.name, + entities: list[ImageEntity] = [] + if isinstance(coordinator, RoborockDataUpdateCoordinator): + entities.extend( + RoborockMap( + config_entry, + coordinator, + coordinator.properties_api.home, + map_info.map_flag, + map_info.name, + ) + for map_info in ( + coordinator.properties_api.home.home_map_info or {} + ).values() ) - for map_info in ( - coordinator.properties_api.home.home_map_info or {} - ).values() - ] + elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator): + entities.append(RoborockMapQ10(coordinator)) async_add_entities(entities) for coordinator in coordinators.values(): @@ -134,3 +138,50 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): if (map_content := self._map_content) is None: raise HomeAssistantError("Map flag not found in coordinator maps") return map_content.image_content + + +class RoborockMapQ10(RoborockCoordinatedEntityB01Q10, ImageEntity): + """A class to let you visualize the current map of a Q10 device. + + The Q10 pushes its current map over MQTT rather than serving it on + request, and the multi-map list is not reachable on this channel, so the + device exposes a single push-driven map entity. + """ + + _attr_content_type = "image/png" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_translation_key = "map" + + def __init__(self, coordinator: RoborockB01Q10UpdateCoordinator) -> None: + """Initialize a Roborock Q10 map.""" + RoborockCoordinatedEntityB01Q10.__init__( + self, f"map_{coordinator.duid_slug}", coordinator + ) + ImageEntity.__init__(self, coordinator.hass) + self._map_trait = coordinator.api.map + self._cached_map: bytes | None = None + + @override + async def async_added_to_hass(self) -> None: + """Register a trait listener for push-based map updates.""" + await super().async_added_to_hass() + self.async_on_remove( + self._map_trait.add_update_listener(self._handle_map_update) + ) + # Pick up a map that was pushed before the entity was added. + self._handle_map_update() + + @callback + def _handle_map_update(self) -> None: + """Cache the newly pushed map if its content changed.""" + image_content = self._map_trait.image_content + if image_content is None or image_content == self._cached_map: + return + self._cached_map = image_content + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() + + @override + async def async_image(self) -> bytes | None: + """Get the cached image.""" + return self._cached_map diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 522f24268277..b5d4e0614747 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==5.22.0", + "python-roborock==5.30.0", "vacuum-map-parser-roborock==0.1.5" ] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 67dff3ae6b7d..e05884d945b5 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -5,6 +5,8 @@ from dataclasses import dataclass import logging from typing import Any, override +from roborock.devices.traits.b01 import Q10PropertiesApi +from roborock.devices.traits.b01.q10 import SoundVolumeTrait from roborock.devices.traits.v1 import PropertiesApi from roborock.exceptions import RoborockException @@ -17,11 +19,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ( + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockCoordinatorType, RoborockDataUpdateCoordinator, ) -from .entity import RoborockEntityV1 +from .entity import RoborockCoordinatedEntityB01Q10, RoborockEntityV1 _LOGGER = logging.getLogger(__name__) @@ -59,6 +62,37 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ ] +@dataclass(frozen=True, kw_only=True) +class RoborockNumberDescriptionQ10(NumberEntityDescription): + """Class to describe a Roborock Q10 number entity.""" + + trait: Callable[[Q10PropertiesApi], SoundVolumeTrait | None] + """Function to get the trait backing the entity, if supported.""" + + get_value: Callable[[SoundVolumeTrait], float | None] + """Function to get the value from the trait.""" + + set_value: Callable[[SoundVolumeTrait, float], Coroutine[Any, Any, None]] + """Function to set the value on the trait.""" + + +Q10_NUMBER_DESCRIPTIONS: list[RoborockNumberDescriptionQ10] = [ + RoborockNumberDescriptionQ10( + key="volume", + translation_key="volume", + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + trait=lambda api: api.volume, + get_value=lambda trait: ( + float(trait.volume) if trait.volume is not None else None + ), + set_value=lambda trait, value: trait.set_volume(int(value)), + ) +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -72,18 +106,29 @@ async def async_setup_entry( coordinator: RoborockCoordinatorType, ) -> None: """Add entities for a specific coordinator.""" - if not isinstance(coordinator, RoborockDataUpdateCoordinator): - return - entities = [ - RoborockNumberEntity( - f"{description.key}_{coordinator.duid_slug}", - coordinator=coordinator, - entity_description=description, - trait=trait, + entities: list[NumberEntity] = [] + if isinstance(coordinator, RoborockDataUpdateCoordinator): + entities.extend( + RoborockNumberEntity( + f"{description.key}_{coordinator.duid_slug}", + coordinator=coordinator, + entity_description=description, + trait=trait, + ) + for description in NUMBER_DESCRIPTIONS + if (trait := description.trait(coordinator.properties_api)) is not None + ) + elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator): + entities.extend( + RoborockNumberEntityQ10( + f"{description.key}_{coordinator.duid_slug}", + coordinator=coordinator, + entity_description=description, + trait=q10_trait, + ) + for description in Q10_NUMBER_DESCRIPTIONS + if (q10_trait := description.trait(coordinator.api)) is not None ) - for description in NUMBER_DESCRIPTIONS - if (trait := description.trait(coordinator.properties_api)) is not None - ] async_add_entities(entities) for coordinator in coordinators.values(): @@ -133,3 +178,45 @@ class RoborockNumberEntity(RoborockEntityV1, NumberEntity): translation_domain=DOMAIN, translation_key="update_options_failed", ) from err + + +class RoborockNumberEntityQ10(RoborockCoordinatedEntityB01Q10, NumberEntity): + """A class to set a numeric setting on a Roborock Q10 device.""" + + entity_description: RoborockNumberDescriptionQ10 + coordinator: RoborockB01Q10UpdateCoordinator + + def __init__( + self, + unique_id: str, + coordinator: RoborockB01Q10UpdateCoordinator, + entity_description: RoborockNumberDescriptionQ10, + trait: SoundVolumeTrait, + ) -> None: + """Create a number entity.""" + self.entity_description = entity_description + self._trait = trait + super().__init__(unique_id, coordinator) + + @override + async def async_added_to_hass(self) -> None: + """Register a trait listener for push-based state updates.""" + await super().async_added_to_hass() + self.async_on_remove(self._trait.add_update_listener(self.async_write_ha_state)) + + @property + @override + def native_value(self) -> float | None: + """Get native value.""" + return self.entity_description.get_value(self._trait) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set number value.""" + try: + await self.entity_description.set_value(self._trait, value) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 3952d8d8bad8..7cb45220f160 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -96,7 +96,7 @@ class RoborockSensorDescriptionQ10(SensorEntityDescription): def _dock_error_value_fn(state: DeviceState) -> str | None: if ( status := state.status.dock_error_status - ) is not None and state.status.dock_type != RoborockDockTypeCode.no_dock: + ) is not None and state.status.dock_type != RoborockDockTypeCode.o0_dock: return status.name return None @@ -570,7 +570,6 @@ async def async_setup_entry( entities.extend( RoborockSensorEntityB01Q7(coordinator, description) for description in Q7_B01_SENSOR_DESCRIPTIONS - if description.value_fn(coordinator.data) is not None ) elif isinstance(coordinator, RoborockB01Q10UpdateCoordinator): entities.extend( diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index f301f3b50c40..3d496b9738bc 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -125,6 +125,11 @@ "name": "Start" } }, + "image": { + "map": { + "name": "Map" + } + }, "number": { "volume": { "name": "Volume" @@ -604,12 +609,18 @@ } }, "switch": { + "button_light": { + "name": "Indicator light" + }, "child_lock": { "name": "Child lock" }, "dnd_switch": { "name": "Do not disturb" }, + "dust_collection": { + "name": "Dust collection" + }, "off_peak_switch": { "name": "Off-peak charging" }, diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index c1823a763f0c..42c700bcc8cd 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -6,18 +6,24 @@ import logging from typing import Any, override from roborock.devices.traits.b01 import Q10PropertiesApi -from roborock.devices.traits.b01.q10 import DoNotDisturbTrait +from roborock.devices.traits.b01.q10 import ( + ButtonLightTrait, + ChildLockTrait, + DoNotDisturbTrait, + DustCollectionTrait, +) from roborock.devices.traits.v1 import PropertiesApi from roborock.devices.traits.v1.common import RoborockSwitchBase from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.const import EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory 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 homeassistant.helpers.restore_state import RestoreEntity from .const import DOMAIN from .coordinator import ( @@ -86,11 +92,14 @@ class RoborockSwitchDescriptionA01(SwitchEntityDescription): data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol +type Q10SwitchTrait = ChildLockTrait | DoNotDisturbTrait | DustCollectionTrait + + @dataclass(frozen=True, kw_only=True) class RoborockSwitchDescriptionQ10(SwitchEntityDescription): """Class to describe a Roborock Q10 switch entity.""" - trait: Callable[[Q10PropertiesApi], DoNotDisturbTrait | None] + trait: Callable[[Q10PropertiesApi], Q10SwitchTrait | None] A01_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionA01] = [ @@ -109,7 +118,19 @@ Q10_SWITCH_DESCRIPTIONS: list[RoborockSwitchDescriptionQ10] = [ translation_key="dnd_switch", entity_category=EntityCategory.CONFIG, trait=lambda traits: traits.do_not_disturb, - ) + ), + RoborockSwitchDescriptionQ10( + key="child_lock", + translation_key="child_lock", + entity_category=EntityCategory.CONFIG, + trait=lambda traits: traits.child_lock, + ), + RoborockSwitchDescriptionQ10( + key="dust_collection", + translation_key="dust_collection", + entity_category=EntityCategory.CONFIG, + trait=lambda traits: traits.dust_collection, + ), ] @@ -159,6 +180,13 @@ async def async_setup_entry( for description in Q10_SWITCH_DESCRIPTIONS if (q10_trait := description.trait(coordinator.api)) is not None ) + entities.append( + RoborockSwitchQ10ButtonLight( + f"button_light_{coordinator.duid_slug}", + coordinator, + coordinator.api.button_light, + ) + ) async_add_entities(entities) for coordinator in coordinators.values(): @@ -290,7 +318,7 @@ class RoborockSwitchQ10(RoborockCoordinatedEntityB01Q10, SwitchEntity): unique_id: str, coordinator: RoborockB01Q10UpdateCoordinator, description: RoborockSwitchDescriptionQ10, - trait: DoNotDisturbTrait, + trait: Q10SwitchTrait, ) -> None: """Initialize the entity.""" self.entity_description = description @@ -330,3 +358,63 @@ class RoborockSwitchQ10(RoborockCoordinatedEntityB01Q10, SwitchEntity): def is_on(self) -> bool | None: """Return True if entity is on.""" return self._trait.is_on + + +class RoborockSwitchQ10ButtonLight( + RoborockCoordinatedEntityB01Q10, SwitchEntity, RestoreEntity +): + """A class to toggle the indicator / button light of a Roborock Q10 device. + + The device does not report the light state, so the switch is write-only + and assumes the state of the last successful command, restored across + restarts. + """ + + _attr_assumed_state = True + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "button_light" + + def __init__( + self, + unique_id: str, + coordinator: RoborockB01Q10UpdateCoordinator, + trait: ButtonLightTrait, + ) -> None: + """Initialize the entity.""" + self._trait = trait + super().__init__(unique_id, coordinator) + + @override + async def async_added_to_hass(self) -> None: + """Restore the last assumed state.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None and ( + last_state.state in (STATE_ON, STATE_OFF) + ): + self._attr_is_on = last_state.state == STATE_ON + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + try: + await self._trait.disable() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err + self._attr_is_on = False + self.async_write_ha_state() + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + try: + await self._trait.enable() + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err + self._attr_is_on = True + self.async_write_ha_state() diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py index 4bf58d7473d8..1e5815b376a3 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -38,8 +38,7 @@ FAN_SPEEDS: list[str] = [ # Commonly supported features SUPPORT_ROMY_ROBOT = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.RETURN_HOME + VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.STATE | VacuumEntityFeature.START | VacuumEntityFeature.STOP @@ -76,7 +75,6 @@ class RomyVacuumEntity(RomyEntity, StateVacuumEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._attr_fan_speed = FAN_SPEEDS[self.romy.fan_speed] - self._attr_battery_level = self.romy.battery_level if (status := self.romy.status) is None: self._attr_activity = None self.async_write_ha_state() diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 8d56f3a55633..ccb3844b7d90 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioruckus"], - "requirements": ["aioruckus==0.42"] + "requirements": ["aioruckus==0.46.3"] } diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 64cf366ca6e9..1b6ea774f699 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -1,9 +1,9 @@ { "domain": "russound_rio", "name": "Russound RIO", - "after_dependencies": ["usb"], "codeowners": ["@noahhusby"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/russound_rio", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/ruuvi_gateway/__init__.py b/homeassistant/components/ruuvi_gateway/__init__.py index 94ebf6fbcf61..c5af0db31540 100644 --- a/homeassistant/components/ruuvi_gateway/__init__.py +++ b/homeassistant/components/ruuvi_gateway/__init__.py @@ -1,4 +1,5 @@ """The Ruuvi Gateway integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/ruuvitag_ble/__init__.py b/homeassistant/components/ruuvitag_ble/__init__.py index 01634bfce88c..65d096ca1b20 100644 --- a/homeassistant/components/ruuvitag_ble/__init__.py +++ b/homeassistant/components/ruuvitag_ble/__init__.py @@ -1,4 +1,5 @@ """The ruuvitag_ble integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index 69251608d09e..57564aeab24b 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -2,7 +2,7 @@ import logging -from pyrympro import CannotConnectError, RymPro, UnauthorizedError +from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant @@ -22,13 +22,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RymProConfigEntry) -> bo rympro.set_token(data[CONF_TOKEN]) try: await rympro.account_info() - except CannotConnectError as error: + except (CannotConnectError, OperationError) as error: raise ConfigEntryNotReady from error except UnauthorizedError: try: token = await rympro.login(data[CONF_EMAIL], data[CONF_PASSWORD], "ha") except UnauthorizedError as error: raise ConfigEntryAuthFailed from error + except CannotConnectError as error: + raise ConfigEntryNotReady from error hass.config_entries.async_update_entry( entry, data={**data, CONF_TOKEN: token}, diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index c1126c552f49..c07ca2fb8873 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -239,16 +239,12 @@ async def async_migrate_entry( version = config_entry.version minor_version = config_entry.minor_version - if version > 2: - # This means the user has downgraded from a future version - return False - LOGGER.debug("Migrating from version %s.%s", version, minor_version) # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: dev_reg = dr.async_get(hass) - dev_reg.async_clear_config_entry(config_entry.entry_id) + dev_reg.async_clear_config_entry(config_entry.entry_id, config_entry.domain) en_reg = er.async_get(hass) en_reg.async_clear_config_entry(config_entry.entry_id) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 09859bee30c2..064f14abc418 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -649,7 +649,6 @@ class SamsungTVWSBridge( ) self._remote = None except ConnectionFailure as err: - error_details = err.args[0] if "ms.channel.timeOut" in (error_details := repr(err)): # The websocket was connected, but the TV is probably asleep LOGGER.debug( diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index d1a3a01ded8c..6753818ccd05 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==3.0.5", "wakeonlan==3.3.0", - "async-upnp-client==0.46.2" + "async-upnp-client==0.47.0" ], "ssdp": [ { diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index 8dca2541ab14..7c9578ea9bec 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -39,8 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> try: client = await SaunumClient.create(host) except (SaunumConnectionError, SaunumTimeoutError) as exc: - # pylint: disable-next=home-assistant-exception-not-translated - raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": host}, + ) from exc entry.async_on_unload(client.async_close) diff --git a/homeassistant/components/saunum/manifest.json b/homeassistant/components/saunum/manifest.json index d65394d01ae6..f3458724d6eb 100644 --- a/homeassistant/components/saunum/manifest.json +++ b/homeassistant/components/saunum/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pysaunum"], "quality_scale": "platinum", - "requirements": ["pysaunum==0.6.0"] + "requirements": ["pysaunum==0.7.0"] } diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json index 4e3645b66991..9b480f6a1eb0 100644 --- a/homeassistant/components/saunum/strings.json +++ b/homeassistant/components/saunum/strings.json @@ -87,6 +87,9 @@ } }, "exceptions": { + "cannot_connect": { + "message": "Error connecting to {host}" + }, "communication_error": { "message": "Communication error with sauna control unit" }, diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index b79a22e5ff64..77a4a3aa5050 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -7,7 +7,7 @@ from typing import Any, Literal, override import voluptuous as vol -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, CONF_ICON, CONF_ID, @@ -40,7 +40,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 ( +from .const import ( # noqa: F401 ATTR_NEXT_EVENT, CONF_ALL_DAYS, CONF_DATA, @@ -50,6 +50,8 @@ from .const import ( LOGGER, SERVICE_GET, WEEKDAY_TO_CONF, + ScheduleEntityCapabilityAttribute, + ScheduleEntityStateAttribute, ) STORAGE_VERSION = 1 @@ -256,7 +258,10 @@ class Schedule(CollectionEntity): """Schedule entity.""" _entity_component_unrecorded_attributes = frozenset( - {ATTR_EDITABLE, ATTR_NEXT_EVENT} + { + ScheduleEntityCapabilityAttribute.EDITABLE, + ScheduleEntityStateAttribute.NEXT_EVENT, + } ) _attr_has_entity_name = True @@ -269,7 +274,9 @@ class Schedule(CollectionEntity): def __init__(self, config: ConfigType, editable: bool) -> None: """Initialize a schedule.""" self._config = ENTITY_SCHEMA(config) - self._attr_capability_attributes = {ATTR_EDITABLE: editable} + self._attr_capability_attributes = { + ScheduleEntityCapabilityAttribute.EDITABLE: editable + } self._attr_icon = self._config.get(CONF_ICON) self._attr_name = self._config[CONF_NAME] self._attr_unique_id = self._config[CONF_ID] @@ -380,7 +387,7 @@ class Schedule(CollectionEntity): break self._attr_extra_state_attributes = { - ATTR_NEXT_EVENT: next_event, + ScheduleEntityStateAttribute.NEXT_EVENT: next_event, } if current_data: diff --git a/homeassistant/components/schedule/const.py b/homeassistant/components/schedule/const.py index 410cd00c3a08..5bc026e179df 100644 --- a/homeassistant/components/schedule/const.py +++ b/homeassistant/components/schedule/const.py @@ -1,11 +1,25 @@ """Constants for the schedule integration.""" +from enum import StrEnum import logging from typing import Final DOMAIN: Final = "schedule" LOGGER = logging.getLogger(__package__) + +class ScheduleEntityCapabilityAttribute(StrEnum): + """Capability attributes for schedule entities.""" + + EDITABLE = "editable" + + +class ScheduleEntityStateAttribute(StrEnum): + """State attributes for schedule entities.""" + + NEXT_EVENT = "next_event" + + CONF_DATA: Final = "data" CONF_FRIDAY: Final = "friday" CONF_FROM: Final = "from" diff --git a/homeassistant/components/schedule/trigger.py b/homeassistant/components/schedule/trigger.py index dba6c29b99bd..f637b60433e2 100644 --- a/homeassistant/components/schedule/trigger.py +++ b/homeassistant/components/schedule/trigger.py @@ -11,7 +11,7 @@ from homeassistant.helpers.trigger import ( make_entity_target_state_trigger, ) -from .const import ATTR_NEXT_EVENT, DOMAIN +from .const import DOMAIN, ScheduleEntityStateAttribute class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): @@ -24,8 +24,10 @@ class ScheduleBackToBackTrigger(EntityTransitionTriggerBase): @override def is_valid_transition(self, from_state: State, to_state: State) -> bool: """Check that the origin matches and the next event changed.""" - from_next_event = from_state.attributes.get(ATTR_NEXT_EVENT) - to_next_event = to_state.attributes.get(ATTR_NEXT_EVENT) + from_next_event = from_state.attributes.get( + ScheduleEntityStateAttribute.NEXT_EVENT + ) + to_next_event = to_state.attributes.get(ScheduleEntityStateAttribute.NEXT_EVENT) return ( from_state.state in self._from_states and from_next_event != to_next_event diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index f77df0155468..06b9bbb4b383 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -116,9 +116,8 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): if removed_locks := previous_locks - current_locks: LOGGER.debug("Removed locks: %s", ", ".join(removed_locks)) for lock_id in removed_locks: - device_registry.async_update_device( - device_id=previous_locks_by_lock_id[lock_id].id, - remove_config_entry_id=self.config_entry.entry_id, + device_registry.async_remove_device( + previous_locks_by_lock_id[lock_id].id ) if new_lock_ids := current_locks - previous_locks: diff --git a/homeassistant/components/scorpiontrack/__init__.py b/homeassistant/components/scorpiontrack/__init__.py new file mode 100644 index 000000000000..ebb42ebe8ee1 --- /dev/null +++ b/homeassistant/components/scorpiontrack/__init__.py @@ -0,0 +1,32 @@ +"""The ScorpionTrack integration.""" + +from pyscorpiontrack import ScorpionTrackClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SHARE_TOKEN, PLATFORMS +from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ScorpionTrackConfigEntry +) -> bool: + """Set up ScorpionTrack from a config entry.""" + client = ScorpionTrackClient( + session=async_get_clientsession(hass), + token=entry.data[CONF_SHARE_TOKEN], + ) + coordinator = ScorpionTrackCoordinator(hass, client, 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: ScorpionTrackConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/scorpiontrack/config_flow.py b/homeassistant/components/scorpiontrack/config_flow.py new file mode 100644 index 000000000000..f680177219f3 --- /dev/null +++ b/homeassistant/components/scorpiontrack/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for ScorpionTrack.""" + +import logging +from typing import Any, override + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SHARE_TOKEN, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _async_validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> ScorpionTrackShare: + """Validate the provided share token or share URL.""" + try: + normalized_token = ScorpionTrackClient.extract_token( + user_input[CONF_SHARE_TOKEN] + ) + except (ScorpionTrackInvalidTokenError, ValueError) as err: + raise ScorpionTrackInvalidTokenError( + "Invalid ScorpionTrack share token" + ) from err + + client = ScorpionTrackClient( + session=async_get_clientsession(hass), + token=normalized_token, + ) + return await client.async_get_share() + + +def _share_title(share: ScorpionTrackShare) -> str: + """Return the best config entry title for a share.""" + if share.title: + return share.title + if share.vehicles: + return share.vehicles[0].display_name + return DEFAULT_NAME + + +class ScorpionTrackConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ScorpionTrack.""" + + VERSION = 1 + + @override + 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: + try: + share = await _async_validate_input(self.hass, user_input) + except ScorpionTrackConnectionError: + errors["base"] = "cannot_connect" + except ScorpionTrackInvalidTokenError: + errors["base"] = "invalid_token" + except ScorpionTrackShareUnavailableError: + errors["base"] = "share_unavailable" + except Exception: + _LOGGER.exception( + "Unexpected exception while validating ScorpionTrack share" + ) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(share.id)) + self._abort_if_unique_id_configured() + user_input[CONF_SHARE_TOKEN] = share.token + return self.async_create_entry( + title=_share_title(share), + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_SHARE_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/scorpiontrack/const.py b/homeassistant/components/scorpiontrack/const.py new file mode 100644 index 000000000000..851d745a35bc --- /dev/null +++ b/homeassistant/components/scorpiontrack/const.py @@ -0,0 +1,15 @@ +"""Constants for the ScorpionTrack integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "scorpiontrack" +DEFAULT_NAME = "ScorpionTrack" +MANUFACTURER = "ScorpionTrack" + +CONF_SHARE_TOKEN = "share_token" + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=2) + +PLATFORMS: tuple[Platform, ...] = (Platform.DEVICE_TRACKER,) diff --git a/homeassistant/components/scorpiontrack/coordinator.py b/homeassistant/components/scorpiontrack/coordinator.py new file mode 100644 index 000000000000..c8eda3c0a690 --- /dev/null +++ b/homeassistant/components/scorpiontrack/coordinator.py @@ -0,0 +1,71 @@ +"""Coordinator for ScorpionTrack.""" + +import logging +from typing import override + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, + ScorpionTrackVehicle, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +type ScorpionTrackConfigEntry = ConfigEntry[ScorpionTrackCoordinator] + + +class ScorpionTrackCoordinator(DataUpdateCoordinator[ScorpionTrackShare]): + """Coordinate shared-location updates.""" + + def __init__( + self, + hass: HomeAssistant, + client: ScorpionTrackClient, + entry: ScorpionTrackConfigEntry, + ) -> None: + """Initialize the coordinator.""" + self.client = client + self.vehicles_by_id: dict[int, ScorpionTrackVehicle] = {} + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN}_{entry.entry_id}", + update_interval=DEFAULT_SCAN_INTERVAL, + always_update=False, + ) + + @override + async def _async_update_data(self) -> ScorpionTrackShare: + """Fetch updated share data.""" + try: + share = await self.client.async_get_share() + except ScorpionTrackConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except ScorpionTrackInvalidTokenError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_token", + ) from err + except ScorpionTrackShareUnavailableError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="share_unavailable", + ) from err + else: + self.vehicles_by_id = {vehicle.id: vehicle for vehicle in share.vehicles} + return share diff --git a/homeassistant/components/scorpiontrack/device_tracker.py b/homeassistant/components/scorpiontrack/device_tracker.py new file mode 100644 index 000000000000..723f7b585f8b --- /dev/null +++ b/homeassistant/components/scorpiontrack/device_tracker.py @@ -0,0 +1,76 @@ +"""Device tracker platform for ScorpionTrack.""" + +from typing import override + +from pyscorpiontrack import ScorpionTrackVehicle + +from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ScorpionTrackConfigEntry, ScorpionTrackCoordinator +from .entity import ScorpionTrackEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ScorpionTrackConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up ScorpionTrack tracker entities.""" + coordinator = entry.runtime_data + async_add_entities( + ScorpionTrackTrackerEntity(coordinator, vehicle.id) + for vehicle in coordinator.data.vehicles + ) + + +class ScorpionTrackTrackerEntity(ScorpionTrackEntity, TrackerEntity): + """Represent the latest shared GPS location for a vehicle.""" + + _attr_name = None + _attr_translation_key = "vehicle_location" + + def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None: + """Initialize the tracker.""" + super().__init__(coordinator, vehicle_id) + self._attr_unique_id = f"{coordinator.data.id}_{vehicle_id}" + + def _available_vehicle(self) -> ScorpionTrackVehicle | None: + """Return the vehicle if the tracker is available.""" + if not super().available: + return None + return self.get_vehicle() + + @property + @override + def available(self) -> bool: + """Return if the tracker is available.""" + vehicle = self._available_vehicle() + if vehicle is None: + return False + + return ( + vehicle.position.latitude is not None + and vehicle.position.longitude is not None + ) + + @property + @override + def latitude(self) -> float | None: + """Return the latitude.""" + vehicle = self._available_vehicle() + if vehicle is None: + return None + return vehicle.position.latitude + + @property + @override + def longitude(self) -> float | None: + """Return the longitude.""" + vehicle = self._available_vehicle() + if vehicle is None: + return None + return vehicle.position.longitude diff --git a/homeassistant/components/scorpiontrack/entity.py b/homeassistant/components/scorpiontrack/entity.py new file mode 100644 index 000000000000..2fa3c597d6ce --- /dev/null +++ b/homeassistant/components/scorpiontrack/entity.py @@ -0,0 +1,44 @@ +"""Shared entity helpers for ScorpionTrack.""" + +from typing import override + +from pyscorpiontrack import ScorpionTrackShare, ScorpionTrackVehicle + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import ScorpionTrackCoordinator + + +class ScorpionTrackEntity(CoordinatorEntity[ScorpionTrackCoordinator]): + """Base class for ScorpionTrack vehicle entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ScorpionTrackCoordinator, vehicle_id: int) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._vehicle_id = vehicle_id + vehicle = self.get_vehicle() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self.share.id}_{vehicle_id}")}, + manufacturer=vehicle.make or MANUFACTURER, + model=vehicle.model, + name=vehicle.display_name, + ) + + @property + def share(self) -> ScorpionTrackShare: + """Return the active share data.""" + return self.coordinator.data + + def get_vehicle(self) -> ScorpionTrackVehicle: + """Return the matching vehicle.""" + return self.coordinator.vehicles_by_id[self._vehicle_id] + + @property + @override + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self._vehicle_id in self.coordinator.vehicles_by_id diff --git a/homeassistant/components/scorpiontrack/icons.json b/homeassistant/components/scorpiontrack/icons.json new file mode 100644 index 000000000000..a792c09e859c --- /dev/null +++ b/homeassistant/components/scorpiontrack/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "device_tracker": { + "vehicle_location": { + "default": "mdi:car" + } + } + } +} diff --git a/homeassistant/components/scorpiontrack/manifest.json b/homeassistant/components/scorpiontrack/manifest.json new file mode 100644 index 000000000000..0ced3f6c03d5 --- /dev/null +++ b/homeassistant/components/scorpiontrack/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "scorpiontrack", + "name": "ScorpionTrack", + "codeowners": ["@Herbertmt978"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/scorpiontrack", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["pyscorpiontrack"], + "quality_scale": "silver", + "requirements": ["pyscorpiontrack==0.1.1"] +} diff --git a/homeassistant/components/scorpiontrack/quality_scale.yaml b/homeassistant/components/scorpiontrack/quality_scale.yaml new file mode 100644 index 000000000000..2cacc2339081 --- /dev/null +++ b/homeassistant/components/scorpiontrack/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + 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-triggers: + status: exempt + comment: This integration does not provide triggers. + docs-conditions: + status: exempt + comment: This integration does not provide conditions. + 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 actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + ScorpionTrack share links are public tokens rather than account credentials. + Replacing an expired or revoked share requires configuring a new share link. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: | + Vehicles are exposed through a ScorpionTrack cloud shared-location/API + link generated from the ScorpionTrack website. The trackers are standalone + units that communicate over their SIM/cellular connection rather than + Wi-Fi or the local network, so Home Assistant has no local discovery + mechanism. + discovery-update-info: + status: exempt + comment: | + Vehicles are exposed through a ScorpionTrack cloud shared-location/API + link generated from the ScorpionTrack website. The trackers are standalone + units that communicate over their SIM/cellular connection rather than + Wi-Fi or the local network, so Home Assistant has no local discovery + mechanism. + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: The created entities are useful for shared vehicle tracking and should be enabled by default. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/scorpiontrack/strings.json b/homeassistant/components/scorpiontrack/strings.json new file mode 100644 index 000000000000..63595a78ae09 --- /dev/null +++ b/homeassistant/components/scorpiontrack/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "This shared location is already configured." + }, + "error": { + "cannot_connect": "The ScorpionTrack share could not be reached right now.", + "invalid_token": "Your shared-location link is invalid or incomplete.", + "share_unavailable": "Your shared-location link is invalid, expired, has been revoked, or no longer returns data.", + "unknown": "An unexpected error occurred." + }, + "step": { + "user": { + "data": { + "share_token": "Share URL or token" + }, + "data_description": { + "share_token": "Paste the full ScorpionTrack shared-location URL or just the token itself." + }, + "description": "Paste a ScorpionTrack shared-location URL or the token from that URL.", + "title": "Connect a ScorpionTrack share" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "The ScorpionTrack share could not be reached right now." + }, + "invalid_token": { + "message": "ScorpionTrack rejected the configured share token." + }, + "share_unavailable": { + "message": "The shared location is unavailable." + } + } +} diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 95177b7d2b46..1fb19d2663ca 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -236,20 +236,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> ) device_reg.async_update_device( device.id, - add_config_entry_id=entry.entry_id, - add_config_subentry_id=subentry_id, + new_config_entry_id=entry.entry_id, + new_config_subentry_id=subentry_id, new_identifiers=new_identifiers, ) - # Removing None from the list of subentries if existing - # as the device should only belong to the subentry - # and not the main config entry - device_reg.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) - # Update the resource config new_config_entry_data = dict(entry.options) new_config_entry_data[CONF_AUTH] = {} diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index bb40f3cfe2db..19c390c83ab5 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -11,10 +11,10 @@ from screenlogicpy.device_const.heat import HEAT_MODE from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.climate import ( - ATTR_PRESET_MODE, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACAction, HVACMode, ) @@ -212,9 +212,12 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): prev_state = await self.async_get_last_state() if ( prev_state is not None - and prev_state.attributes.get(ATTR_PRESET_MODE) is not None + and prev_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) + is not None ): - mode = HEAT_MODE.parse(prev_state.attributes.get(ATTR_PRESET_MODE)) + mode = HEAT_MODE.parse( + prev_state.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) + ) _LOGGER.debug( "Startup setting last_preset to %s from prev_state", mode.name, diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index b9fbd959a965..1eef57773780 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -8,19 +8,12 @@ from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWA from screenlogicpy.requests import login import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MIN_SCAN_INTERVAL +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -73,15 +66,6 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): self.discovered_gateways: dict[str, dict[str, Any]] = {} self.discovered_ip: str | None = None - @staticmethod - @callback - @override - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> ScreenLogicOptionsFlowHandler: - """Get the options flow for ScreenLogic.""" - return ScreenLogicOptionsFlowHandler() - @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -188,32 +172,3 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={}, ) - - -class ScreenLogicOptionsFlowHandler(OptionsFlow): - """Handles the options for the ScreenLogic integration.""" - - async def async_step_init(self, user_input=None) -> ConfigFlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry( - title=self.config_entry.title, data=user_input - ) - - current_interval = self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - # Polling interval is user-configurable, which is no longer allowed - # pylint: disable-next=home-assistant-config-flow-polling-field - vol.Required( - CONF_SCAN_INTERVAL, - default=current_interval, - ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)) - } - ), - description_placeholders={"gateway_name": self.config_entry.title}, - ) diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 2104f6a9ba22..b77fa0f77989 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -14,7 +14,7 @@ from screenlogicpy.const.common import ( from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -64,15 +64,12 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Initialize the Screenlogic Data Update Coordinator.""" self.gateway = gateway - interval = timedelta( - seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) super().__init__( hass, _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=interval, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), # Debounced option since the device takes # a moment to reflect the knock-on changes request_refresh_debouncer=Debouncer( diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 026597eaff9f..2ed0edd9399f 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -190,17 +190,6 @@ } } }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Seconds between scans" - }, - "description": "Specify settings for {gateway_name}", - "title": "[%key:component::screenlogic::config::step::gateway_entry::title%]" - } - } - }, "services": { "set_color_mode": { "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", diff --git a/homeassistant/components/script/llm.py b/homeassistant/components/script/llm.py new file mode 100644 index 000000000000..a669b6709110 --- /dev/null +++ b/homeassistant/components/script/llm.py @@ -0,0 +1,61 @@ +"""LLM tools for the script integration.""" + +from operator import attrgetter + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.llm import LLM_API_ASSIST, ActionTool, LLMContext, Tool + +from .const import DOMAIN + + +class ScriptTool(ActionTool): + """LLM Tool representing a Script.""" + + def __init__( + self, + hass: HomeAssistant, + script_entity_id: str, + ) -> None: + """Init the class.""" + script_name = split_entity_id(script_entity_id)[1] + + action = script_name + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(script_entity_id) + if entity_entry and entity_entry.unique_id: + action = entity_entry.unique_id + + super().__init__(hass, DOMAIN, action) + + self.name = script_name + if self.name[0].isdigit(): + self.name = "_" + self.name + + if entity_entry and ( + aliases := er.async_get_entity_aliases(hass, entity_entry) + ): + alias_text = "Aliases: " + str(sorted(aliases)) + self.description = ( + f"{self.description}. {alias_text}" if self.description else alias_text + ) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return a script LLM tool for each exposed script.""" + if api_id != LLM_API_ASSIST: + return None + + tools: list[Tool] = [ + ScriptTool(hass, state.entity_id) + for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")) + if async_should_expose(hass, llm_context.assistant, state.entity_id) + ] + if not tools: + return None + return LLMTools(tools=tools) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 214da9aacb16..5b6111e04b48 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -220,6 +220,12 @@ class Searcher: automation.blueprint_in_automation(self.hass, automation_entity_id), ) + # Labels referenced in this automation + self._add( + ItemType.LABEL, + automation.labels_in_automation(self.hass, automation_entity_id), + ) + # Floors referenced in this automation self._add( ItemType.FLOOR, @@ -290,6 +296,19 @@ class Searcher: self._add(ItemType.ENTITY, entity_entry.entity_id) self._async_search_entity(entity_entry.entity_id, entry_point=False) + @callback + def _async_search_integration(self, domain: str) -> None: + """Find results for an integration.""" + for entry in self.hass.config_entries.async_entries(domain): + self._add(ItemType.CONFIG_ENTRY, entry.entry_id) + self._async_search_config_entry(entry.entry_id) + + for entity_id, source in self._entity_sources.items(): + if source["domain"] != domain: + continue + self._add(ItemType.ENTITY, entity_id) + self._async_search_entity(entity_id, entry_point=False) + @callback def _async_search_device(self, device_id: str, *, entry_point: bool = True) -> None: """Find results for a device.""" @@ -327,6 +346,12 @@ class Searcher: # Add labels of this entity self._add(ItemType.LABEL, entity_entry.labels) + if not entry_point: + # If this entity also exists as a resource, we add it. + domain = split_entity_id(entity_id)[0] + if domain in self.EXIST_AS_ENTITY: + self._add(ItemType(domain), entity_id) + # Automations referencing this entity self._add( ItemType.AUTOMATION, @@ -394,14 +419,17 @@ class Searcher: # Areas with this label for area_entry in ar.async_entries_for_label(self._area_registry, label_id): self._add(ItemType.AREA, area_entry.id) + self._async_resolve_up_area(area_entry.id) # Devices with this label for device in dr.async_entries_for_label(self._device_registry, label_id): self._add(ItemType.DEVICE, device.id) + self._async_resolve_up_device(device.id) # Entities with this label for entity_entry in er.async_entries_for_label(self._entity_registry, label_id): self._add(ItemType.ENTITY, entity_entry.entity_id) + self._async_resolve_up_entity(entity_entry.entity_id) # If this entity also exists as a resource, we add it. domain = split_entity_id(entity_entry.entity_id)[0] @@ -420,7 +448,7 @@ class Searcher: @callback def _async_search_person(self, person_entity_id: str) -> None: """Find results for a person.""" - # Up resolve the scene entity itself + # Up resolve the person entity itself if entity_entry := self._async_resolve_up_entity(person_entity_id): # Add labels of this person entity self._add(ItemType.LABEL, entity_entry.labels) @@ -437,9 +465,9 @@ class Searcher: ) # Add all member entities of this person - self._add( - ItemType.ENTITY, person.entities_in_person(self.hass, person_entity_id) - ) + for entity_id in person.entities_in_person(self.hass, person_entity_id): + self._add(ItemType.ENTITY, entity_id) + self._async_resolve_up_entity(entity_id) @callback def _async_search_scene(self, scene_entity_id: str) -> None: @@ -483,6 +511,9 @@ class Searcher: script.blueprint_in_script(self.hass, script_entity_id), ) + # Labels referenced in this script + self._add(ItemType.LABEL, script.labels_in_script(self.hass, script_entity_id)) + # Floors referenced in this script self._add(ItemType.FLOOR, script.floors_in_script(self.hass, script_entity_id)) diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 07187066dcde..dea32f63c792 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -21,5 +21,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.14.1"] + "requirements": ["sense-energy==0.14.3"] } diff --git a/homeassistant/components/sensorpro/__init__.py b/homeassistant/components/sensorpro/__init__.py index 167c5d167462..4e8c131d698d 100644 --- a/homeassistant/components/sensorpro/__init__.py +++ b/homeassistant/components/sensorpro/__init__.py @@ -1,4 +1,5 @@ """The SensorPro integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 75906382a1b4..dc46f2c8ff31 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==12.2.0"] + "requirements": ["Pillow==12.3.0"] } diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index dd82a9e93c49..ac6de4777989 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -65,8 +65,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum _attr_has_entity_name = True _attr_name = None _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED + VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -157,12 +156,6 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum # If the last update was successful... return self.coordinator.last_update_success and self.is_online - @property - @override - def battery_level(self) -> int | None: - """Get the current battery level.""" - return self.sharkiq.get_property_value(Properties.BATTERY_CAPACITY) - @override async def async_return_to_base(self, **kwargs: Any) -> None: """Have the device return to base.""" diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 1f71d5751003..94c3e4ce26ef 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -2,10 +2,9 @@ from collections.abc import Callable from dataclasses import dataclass -from functools import partial from typing import TYPE_CHECKING, Any, Final, override -from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS +from aioshelly.const import MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( @@ -15,16 +14,14 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +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.update_coordinator import CoordinatorEntity from .const import ( CONF_SLEEP_PERIOD, DOMAIN, - LOGGER, MODEL_FRANKEVER_WATER_VALVE, ROLE_GENERIC, SHELLY_GAS_MODELS, @@ -47,9 +44,6 @@ from .utils import ( get_blu_trv_device_info, get_device_entry_gen, get_rpc_key_id, - get_rpc_key_ids, - get_rpc_key_instances, - get_rpc_role_by_key, get_virtual_component_ids, ) @@ -119,76 +113,6 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ ] -@callback -def async_migrate_unique_ids( - coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, - entity_entry: er.RegistryEntry, -) -> dict[str, Any] | None: - """Migrate button unique IDs.""" - if not entity_entry.entity_id.startswith("button"): - return None - - for key in ("reboot", "self_test", "mute", "unmute"): - old_unique_id = f"{coordinator.mac}_{key}" - if entity_entry.unique_id == old_unique_id: - new_unique_id = f"{coordinator.mac}-{key}" - 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 - ) - } - - if not isinstance(coordinator, ShellyRpcCoordinator): - return None - - if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): - 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 - ) - } - - if virtual_button_keys := get_rpc_key_instances( - coordinator.device.config, "button" - ): - for key in virtual_button_keys: - old_unique_id = f"{coordinator.mac}-{key}" - if entity_entry.unique_id == old_unique_id: - role = get_rpc_role_by_key(coordinator.device.config, key) - new_unique_id = f"{coordinator.mac}-{key}-button_{role}" - 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, @@ -206,11 +130,6 @@ async def async_setup_entry( if TYPE_CHECKING: assert coordinator is not None - if coordinator.device.initialized: - await er.async_migrate_entries( - hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) - ) - # Remove the 'restart' button for sleeping devices as it was mistakenly # added in https://github.com/home-assistant/core/pull/154673 entry_sleep_period = config_entry.data[CONF_SLEEP_PERIOD] diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 02f5979e2158..93d326bc33fe 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.26.2"], + "requirements": ["aioshelly==13.27.0"], "zeroconf": [ { "name": "shelly*", diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index c75565c1dd76..304a90d19ae7 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -10,12 +10,11 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( - ATTR_INSTALLED_VERSION, - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -391,7 +390,9 @@ class RpcSleepingUpdateEntity( if self.last_state is None: return None - return self.last_state.attributes.get(ATTR_INSTALLED_VERSION) + return self.last_state.attributes.get( + UpdateEntityStateAttribute.INSTALLED_VERSION + ) @property @override @@ -407,7 +408,7 @@ class RpcSleepingUpdateEntity( if self.last_state is None: return None - return self.last_state.attributes.get(ATTR_LATEST_VERSION) + return self.last_state.attributes.get(UpdateEntityStateAttribute.LATEST_VERSION) @property @override diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 9eccf34badc6..a6aebddbc023 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -916,7 +916,7 @@ def remove_stale_blu_trv_devices( continue LOGGER.debug("Removing stale BLU TRV device %s", device.name) - dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id) + dev_reg.async_remove_device(device.id) @callback @@ -938,9 +938,7 @@ def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: 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 - ) + dev_reg.async_remove_device(device.id) def format_ble_addr(ble_addr: str) -> str: diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index c1b7eaf54fde..a2527f5d8307 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -10,14 +10,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, - callback, -) -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.const import Platform +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType from .common import ( @@ -26,19 +21,8 @@ from .common import ( ShoppingListConfigEntry, _get_shopping_data, ) -from .const import ( - ATTR_REVERSE, - DEFAULT_REVERSE, - DOMAIN, - SERVICE_ADD_ITEM, - SERVICE_CLEAR_COMPLETED_ITEMS, - SERVICE_COMPLETE_ALL, - SERVICE_COMPLETE_ITEM, - SERVICE_INCOMPLETE_ALL, - SERVICE_INCOMPLETE_ITEM, - SERVICE_REMOVE_ITEM, - SERVICE_SORT, -) +from .const import DOMAIN +from .services import async_register_services PLATFORMS = [Platform.TODO] @@ -46,15 +30,10 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) -SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) -SERVICE_LIST_SCHEMA = vol.Schema({}) -SERVICE_SORT_SCHEMA = vol.Schema( - {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the shopping list.""" + async_register_services(hass) if DOMAIN not in config: return True @@ -88,113 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ShoppingListConfigEntry ) -> bool: """Set up shopping list from config flow.""" - - async def add_item_service(call: ServiceCall) -> None: - """Add an item with `name`.""" - await config_entry.runtime_data.async_add(call.data[ATTR_NAME]) - - async def remove_item_service(call: ServiceCall) -> None: - """Remove the first item with matching `name`.""" - data = config_entry.runtime_data - name = call.data[ATTR_NAME] - - try: - item = [item for item in data.items if item["name"] == name][0] - # pylint: disable-next=home-assistant-action-swallowed-exception - except IndexError: - _LOGGER.error("Removing of item failed: %s cannot be found", name) - else: - await data.async_remove(str(item["id"])) - - async def complete_item_service(call: ServiceCall) -> None: - """Mark the first item with matching `name` as completed.""" - name = call.data[ATTR_NAME] - try: - await config_entry.runtime_data.async_complete(name) - # pylint: disable-next=home-assistant-action-swallowed-exception - except NoMatchingShoppingListItem: - _LOGGER.error("Completing of item failed: %s cannot be found", name) - - async def incomplete_item_service(call: ServiceCall) -> None: - """Mark the first item with matching `name` as incomplete.""" - data = config_entry.runtime_data - name = call.data[ATTR_NAME] - - try: - item = [item for item in data.items if item["name"] == name][0] - # pylint: disable-next=home-assistant-action-swallowed-exception - except IndexError: - _LOGGER.error("Restoring of item failed: %s cannot be found", name) - else: - await data.async_update(str(item["id"]), {"name": name, "complete": False}) - - async def complete_all_service(call: ServiceCall) -> None: - """Mark all items in the list as complete.""" - await data.async_update_list({"complete": True}) - - async def incomplete_all_service(call: ServiceCall) -> None: - """Mark all items in the list as incomplete.""" - await data.async_update_list({"complete": False}) - - async def clear_completed_items_service(call: ServiceCall) -> None: - """Clear all completed items from the list.""" - await data.async_clear_completed() - - async def sort_list_service(call: ServiceCall) -> None: - """Sort all items by name.""" - await data.async_sort(call.data[ATTR_REVERSE]) - data = config_entry.runtime_data = ShoppingData(hass) await data.async_load() - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_INCOMPLETE_ITEM, - incomplete_item_service, - schema=SERVICE_ITEM_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_COMPLETE_ALL, - complete_all_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_INCOMPLETE_ALL, - incomplete_all_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_CLEAR_COMPLETED_ITEMS, - clear_completed_items_service, - schema=SERVICE_LIST_SCHEMA, - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SORT, - sort_list_service, - schema=SERVICE_SORT_SCHEMA, - ) - hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) hass.http.register_view(UpdateShoppingListItemView) diff --git a/homeassistant/components/shopping_list/common.py b/homeassistant/components/shopping_list/common.py index 4a8523c39e32..4305ba84bded 100644 --- a/homeassistant/components/shopping_list/common.py +++ b/homeassistant/components/shopping_list/common.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import service from homeassistant.helpers.json import save_json from homeassistant.util.json import JsonValueType, load_json_array @@ -271,9 +271,5 @@ class ShoppingData: def _get_shopping_data(hass: HomeAssistant) -> ShoppingData: - entries: list[ShoppingListConfigEntry] = hass.config_entries.async_loaded_entries( - DOMAIN - ) - if not entries: - raise HomeAssistantError("No shopping list config entry found") - return entries[0].runtime_data + entry: ShoppingListConfigEntry = service.async_get_config_entry(hass, DOMAIN, None) + return entry.runtime_data diff --git a/homeassistant/components/shopping_list/services.py b/homeassistant/components/shopping_list/services.py new file mode 100644 index 000000000000..693c0d15b0be --- /dev/null +++ b/homeassistant/components/shopping_list/services.py @@ -0,0 +1,132 @@ +"""Support for shopping list services.""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv + +from .common import NoMatchingShoppingListItem, _get_shopping_data +from .const import ( + ATTR_REVERSE, + DEFAULT_REVERSE, + DOMAIN, + SERVICE_ADD_ITEM, + SERVICE_CLEAR_COMPLETED_ITEMS, + SERVICE_COMPLETE_ALL, + SERVICE_COMPLETE_ITEM, + SERVICE_INCOMPLETE_ALL, + SERVICE_INCOMPLETE_ITEM, + SERVICE_REMOVE_ITEM, + SERVICE_SORT, +) + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ITEM_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) +SERVICE_LIST_SCHEMA = vol.Schema({}) +SERVICE_SORT_SCHEMA = vol.Schema( + {vol.Optional(ATTR_REVERSE, default=DEFAULT_REVERSE): bool} +) + + +@callback +def async_register_services(hass: HomeAssistant) -> None: + """Register shopping list services.""" + + async def add_item_service(call: ServiceCall) -> None: + """Add an item with `name`.""" + await _get_shopping_data(hass).async_add(call.data[ATTR_NAME]) + + async def remove_item_service(call: ServiceCall) -> None: + """Remove the first item with matching `name`.""" + data = _get_shopping_data(hass) + name = call.data[ATTR_NAME] + + try: + item = [item for item in data.items if item["name"] == name][0] + # pylint: disable-next=home-assistant-action-swallowed-exception + except IndexError: + _LOGGER.error("Removing of item failed: %s cannot be found", name) + else: + await data.async_remove(str(item["id"])) + + async def complete_item_service(call: ServiceCall) -> None: + """Mark the first item with matching `name` as completed.""" + name = call.data[ATTR_NAME] + try: + await _get_shopping_data(hass).async_complete(name) + # pylint: disable-next=home-assistant-action-swallowed-exception + except NoMatchingShoppingListItem: + _LOGGER.error("Completing of item failed: %s cannot be found", name) + + async def incomplete_item_service(call: ServiceCall) -> None: + """Mark the first item with matching `name` as incomplete.""" + data = _get_shopping_data(hass) + name = call.data[ATTR_NAME] + + try: + item = [item for item in data.items if item["name"] == name][0] + # pylint: disable-next=home-assistant-action-swallowed-exception + except IndexError: + _LOGGER.error("Restoring of item failed: %s cannot be found", name) + else: + await data.async_update(str(item["id"]), {"name": name, "complete": False}) + + async def complete_all_service(call: ServiceCall) -> None: + """Mark all items in the list as complete.""" + await _get_shopping_data(hass).async_update_list({"complete": True}) + + async def incomplete_all_service(call: ServiceCall) -> None: + """Mark all items in the list as incomplete.""" + await _get_shopping_data(hass).async_update_list({"complete": False}) + + async def clear_completed_items_service(call: ServiceCall) -> None: + """Clear all completed items from the list.""" + await _get_shopping_data(hass).async_clear_completed() + + async def sort_list_service(call: ServiceCall) -> None: + """Sort all items by name.""" + await _get_shopping_data(hass).async_sort(call.data[ATTR_REVERSE]) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_REMOVE_ITEM, remove_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_INCOMPLETE_ITEM, + incomplete_item_service, + schema=SERVICE_ITEM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_ALL, + complete_all_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_INCOMPLETE_ALL, + incomplete_all_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_COMPLETED_ITEMS, + clear_completed_items_service, + schema=SERVICE_LIST_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SORT, + sort_list_service, + schema=SERVICE_SORT_SCHEMA, + ) diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 5c01cf26697a..bb537ccea6ea 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==12.2.0", "simplehound==0.3"] + "requirements": ["Pillow==12.3.0", "simplehound==0.3"] } diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index d65d930c7739..df648d618854 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -1,7 +1,6 @@ """Support for SimpliSafe alarm systems.""" import asyncio -from collections.abc import Callable, Coroutine from typing import Any from simplipy import API @@ -13,18 +12,6 @@ from simplipy.errors import ( WebsocketError, ) from simplipy.system import SystemNotification -from simplipy.system.v3 import ( - MAX_ALARM_DURATION, - MAX_ENTRY_DELAY_AWAY, - MAX_ENTRY_DELAY_HOME, - MAX_EXIT_DELAY_AWAY, - MAX_EXIT_DELAY_HOME, - MIN_ALARM_DURATION, - MIN_ENTRY_DELAY_AWAY, - MIN_EXIT_DELAY_AWAY, - SystemV3, - Volume, -) from simplipy.websocket import ( EVENT_AUTOMATIC_TEST, EVENT_CAMERA_MOTION_DETECTED, @@ -35,55 +22,38 @@ from simplipy.websocket import ( EVENT_USER_INITIATED_TEST, WebsocketEvent, ) -import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, - ATTR_DEVICE_ID, CONF_CODE, CONF_TOKEN, CONF_USERNAME, Platform, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_validation as cv, device_registry as dr, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.service import ( - async_register_admin_service, - verify_domain_control, -) +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( - ATTR_ALARM_DURATION, - ATTR_ALARM_VOLUME, - ATTR_CHIME_VOLUME, - ATTR_ENTRY_DELAY_AWAY, - ATTR_ENTRY_DELAY_HOME, - ATTR_EXIT_DELAY_AWAY, - ATTR_EXIT_DELAY_HOME, ATTR_LAST_EVENT_INFO, ATTR_LAST_EVENT_SENSOR_NAME, ATTR_LAST_EVENT_SENSOR_TYPE, ATTR_LAST_EVENT_TIMESTAMP, - ATTR_LIGHT, ATTR_SYSTEM_ID, - ATTR_VOICE_PROMPT_VOLUME, DISPATCHER_TOPIC_WEBSOCKET_EVENT, DOMAIN, LOGGER, ) from .coordinator import SimpliSafeDataUpdateCoordinator +from .services import async_setup_services from .typing import SystemType type SimpliSafeConfigEntry = ConfigEntry[SimpliSafe] @@ -94,9 +64,6 @@ ATTR_LAST_EVENT_SENSOR_SERIAL = "last_event_sensor_serial" ATTR_LAST_EVENT_TYPE = "last_event_type" ATTR_LAST_EVENT_TYPE = "last_event_type" ATTR_MESSAGE = "message" -ATTR_PIN_LABEL = "label" -ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" -ATTR_PIN_VALUE = "pin" ATTR_TIMESTAMP = "timestamp" WEBSOCKET_RECONNECT_RETRIES = 3 @@ -114,74 +81,6 @@ PLATFORMS = [ Platform.SENSOR, ] -VOLUME_MAP = { - "high": Volume.HIGH, - "low": Volume.LOW, - "medium": Volume.MEDIUM, - "off": Volume.OFF, -} - -SERVICE_NAME_REMOVE_PIN = "remove_pin" -SERVICE_NAME_SET_PIN = "set_pin" -SERVICE_NAME_SET_SYSTEM_PROPERTIES = "set_system_properties" - -SERVICES = ( - SERVICE_NAME_REMOVE_PIN, - SERVICE_NAME_SET_PIN, - SERVICE_NAME_SET_SYSTEM_PROPERTIES, -) - -SERVICE_REMOVE_PIN_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string, - } -) - -SERVICE_SET_PIN_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Required(ATTR_PIN_LABEL): cv.string, - vol.Required(ATTR_PIN_VALUE): cv.string, - }, -) - -SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional(ATTR_ALARM_DURATION): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION), - ), - vol.Optional(ATTR_ALARM_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), - vol.Optional(ATTR_CHIME_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), - vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY), - ), - vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(max=MAX_ENTRY_DELAY_HOME), - ), - vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY), - ), - vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All( - cv.time_period, - lambda value: value.total_seconds(), - vol.Range(max=MAX_EXIT_DELAY_HOME), - ), - vol.Optional(ATTR_LIGHT): cv.boolean, - vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All( - vol.In(VOLUME_MAP), VOLUME_MAP.get - ), - } -) WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [ EVENT_AUTOMATIC_TEST, @@ -193,47 +92,13 @@ WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [ EVENT_USER_INITIATED_TEST, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -@callback -def _async_get_system_for_service_call( - hass: HomeAssistant, call: ServiceCall -) -> SystemType: - """Get the SimpliSafe system related to a service call (by device ID).""" - device_id = call.data[ATTR_DEVICE_ID] - device_registry = dr.async_get(hass) - if ( - alarm_control_panel_device_entry := device_registry.async_get(device_id) - ) is None: - raise vol.Invalid("Invalid device ID specified") - - assert alarm_control_panel_device_entry.via_device_id - - if ( - base_station_device_entry := device_registry.async_get( - alarm_control_panel_device_entry.via_device_id - ) - ) is None: - raise ValueError("No base station registered for alarm control panel") - - [system_id_str] = [ - identity[1] - for identity in base_station_device_entry.identifiers - if identity[0] == DOMAIN - ] - system_id = int(system_id_str) - - entry: SimpliSafeConfigEntry | None - for entry_id in base_station_device_entry.config_entries: - if ( - (entry := hass.config_entries.async_get_entry(entry_id)) is None - or entry.domain != DOMAIN - or entry.state is not ConfigEntryState.LOADED - ): - continue - return entry.runtime_data.systems[system_id] - - raise ValueError(f"No system for device ID: {device_id}") +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the integration.""" + async_setup_services(hass) + return True @callback @@ -295,7 +160,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) - """Set up SimpliSafe as config entry.""" _async_standardize_config_entry(hass, entry) - _verify_domain_control = verify_domain_control(DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) try: @@ -319,64 +183,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - @callback - def extract_system( - func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]], - ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: - """Define a decorator to get the correct system for a service call.""" - - async def wrapper(call: ServiceCall) -> None: - """Wrap the service function.""" - system = _async_get_system_for_service_call(hass, call) - - try: - await func(call, system) - except SimplipyError as err: - raise HomeAssistantError( - f'Error while executing "{call.service}": {err}' - ) from err - - return wrapper - - @_verify_domain_control - @extract_system - async def async_remove_pin(call: ServiceCall, system: SystemType) -> None: - """Remove a PIN.""" - await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) - - @_verify_domain_control - @extract_system - async def async_set_pin(call: ServiceCall, system: SystemType) -> None: - """Set a PIN.""" - await system.async_set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) - - @_verify_domain_control - @extract_system - async def async_set_system_properties( - call: ServiceCall, system: SystemType - ) -> None: - """Set one or more system parameters.""" - if not isinstance(system, SystemV3): - raise HomeAssistantError("Can only set system properties on V3 systems") - - await system.async_set_properties( - {prop: value for prop, value in call.data.items() if prop != ATTR_DEVICE_ID} - ) - - for service, method, schema in ( - (SERVICE_NAME_REMOVE_PIN, async_remove_pin, SERVICE_REMOVE_PIN_SCHEMA), - (SERVICE_NAME_SET_PIN, async_set_pin, SERVICE_SET_PIN_SCHEMA), - ( - SERVICE_NAME_SET_SYSTEM_PROPERTIES, - async_set_system_properties, - SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA, - ), - ): - if hass.services.has_service(DOMAIN, service): - continue - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - async_register_admin_service(hass, DOMAIN, service, method, schema=schema) - current_options = {**entry.options} async def async_reload_entry(_: HomeAssistant, updated_entry: ConfigEntry) -> None: @@ -403,15 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: SimpliSafeConfigEntry) -> bool: """Unload a SimpliSafe config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if not hass.config_entries.async_loaded_entries(DOMAIN): - # If this is the last loaded instance of SimpliSafe, deregister any services - # defined during integration setup: - for service_name in SERVICES: - hass.services.async_remove(DOMAIN, service_name) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class SimpliSafe: diff --git a/homeassistant/components/simplisafe/services.py b/homeassistant/components/simplisafe/services.py new file mode 100644 index 000000000000..55c465ec1840 --- /dev/null +++ b/homeassistant/components/simplisafe/services.py @@ -0,0 +1,235 @@ +"""Services for SimpliSafe.""" + +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from simplipy.errors import SimplipyError +from simplipy.system.v3 import ( + MAX_ALARM_DURATION, + MAX_ENTRY_DELAY_AWAY, + MAX_ENTRY_DELAY_HOME, + MAX_EXIT_DELAY_AWAY, + MAX_EXIT_DELAY_HOME, + MIN_ALARM_DURATION, + MIN_ENTRY_DELAY_AWAY, + MIN_EXIT_DELAY_AWAY, + SystemV3, + Volume, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.service import ( + async_register_admin_service, + verify_domain_control, +) + +from .const import ( + ATTR_ALARM_DURATION, + ATTR_ALARM_VOLUME, + ATTR_CHIME_VOLUME, + ATTR_ENTRY_DELAY_AWAY, + ATTR_ENTRY_DELAY_HOME, + ATTR_EXIT_DELAY_AWAY, + ATTR_EXIT_DELAY_HOME, + ATTR_LIGHT, + ATTR_VOICE_PROMPT_VOLUME, + DOMAIN, +) +from .typing import SystemType + +if TYPE_CHECKING: + from . import SimpliSafeConfigEntry + +ATTR_PIN_LABEL = "label" +ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" +ATTR_PIN_VALUE = "pin" + +VOLUME_MAP = { + "high": Volume.HIGH, + "low": Volume.LOW, + "medium": Volume.MEDIUM, + "off": Volume.OFF, +} + +SERVICE_NAME_REMOVE_PIN = "remove_pin" +SERVICE_NAME_SET_PIN = "set_pin" +SERVICE_NAME_SET_SYSTEM_PROPERTIES = "set_system_properties" + +SERVICE_REMOVE_PIN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_PIN_LABEL_OR_VALUE): cv.string, + } +) + +SERVICE_SET_PIN_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_PIN_LABEL): cv.string, + vol.Required(ATTR_PIN_VALUE): cv.string, + }, +) + +SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_ALARM_DURATION): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_ALARM_DURATION, max=MAX_ALARM_DURATION), + ), + vol.Optional(ATTR_ALARM_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), + vol.Optional(ATTR_CHIME_VOLUME): vol.All(vol.In(VOLUME_MAP), VOLUME_MAP.get), + vol.Optional(ATTR_ENTRY_DELAY_AWAY): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_ENTRY_DELAY_AWAY, max=MAX_ENTRY_DELAY_AWAY), + ), + vol.Optional(ATTR_ENTRY_DELAY_HOME): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(max=MAX_ENTRY_DELAY_HOME), + ), + vol.Optional(ATTR_EXIT_DELAY_AWAY): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(min=MIN_EXIT_DELAY_AWAY, max=MAX_EXIT_DELAY_AWAY), + ), + vol.Optional(ATTR_EXIT_DELAY_HOME): vol.All( + cv.time_period, + lambda value: value.total_seconds(), + vol.Range(max=MAX_EXIT_DELAY_HOME), + ), + vol.Optional(ATTR_LIGHT): cv.boolean, + vol.Optional(ATTR_VOICE_PROMPT_VOLUME): vol.All( + vol.In(VOLUME_MAP), VOLUME_MAP.get + ), + } +) + +_verify_domain_control = verify_domain_control(DOMAIN) + + +@callback +def _async_get_system_for_service_call(call: ServiceCall) -> SystemType: + """Get the SimpliSafe system related to a service call (by device ID).""" + device_id = call.data[ATTR_DEVICE_ID] + device_registry = dr.async_get(call.hass) + + if ( + alarm_control_panel_device_entry := device_registry.async_get(device_id) + ) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, + ) + + if TYPE_CHECKING: + assert alarm_control_panel_device_entry.via_device_id + + if ( + base_station_device_entry := device_registry.async_get( + alarm_control_panel_device_entry.via_device_id + ) + ) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_base_station", + translation_placeholders={"device_id": device_id}, + ) + + [system_id_str] = [ + identity[1] + for identity in base_station_device_entry.identifiers + if identity[0] == DOMAIN + ] + system_id = int(system_id_str) + + entry: SimpliSafeConfigEntry | None + for entry_id in base_station_device_entry.config_entries: + if ( + (entry := call.hass.config_entries.async_get_entry(entry_id)) is None + or entry.domain != DOMAIN + or entry.state is not ConfigEntryState.LOADED + ): + continue + return entry.runtime_data.systems[system_id] + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_system_for_device", + translation_placeholders={"device_id": device_id}, + ) + + +@callback +def extract_system( + func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]], +) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: + """Define a decorator to get the correct system for a service call.""" + + async def wrapper(call: ServiceCall) -> None: + """Wrap the service function.""" + system = _async_get_system_for_service_call(call) + + try: + await func(call, system) + except SimplipyError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_while_executing", + translation_placeholders={"service": call.service, "error": str(err)}, + ) from err + + return wrapper + + +@_verify_domain_control +@extract_system +async def async_remove_pin(call: ServiceCall, system: SystemType) -> None: + """Remove a PIN.""" + await system.async_remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) + + +@_verify_domain_control +@extract_system +async def async_set_pin(call: ServiceCall, system: SystemType) -> None: + """Set a PIN.""" + await system.async_set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) + + +@_verify_domain_control +@extract_system +async def async_set_system_properties(call: ServiceCall, system: SystemType) -> None: + """Set one or more system parameters.""" + if not isinstance(system, SystemV3): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="set_system_properties_not_v3", + ) + + await system.async_set_properties( + {prop: value for prop, value in call.data.items() if prop != ATTR_DEVICE_ID} + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + + for service, method, schema in ( + (SERVICE_NAME_REMOVE_PIN, async_remove_pin, SERVICE_REMOVE_PIN_SCHEMA), + (SERVICE_NAME_SET_PIN, async_set_pin, SERVICE_SET_PIN_SCHEMA), + ( + SERVICE_NAME_SET_SYSTEM_PROPERTIES, + async_set_system_properties, + SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA, + ), + ): + async_register_admin_service(hass, DOMAIN, service, method, schema=schema) diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index a8bbb8679865..2e858766e2e5 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -27,6 +27,23 @@ } } }, + "exceptions": { + "error_while_executing": { + "message": "Error while executing \"{service}\": {error}" + }, + "invalid_device_id": { + "message": "No device could be found for ID: {device_id}" + }, + "no_base_station": { + "message": "No base station could be found for device ID: {device_id}" + }, + "no_system_for_device": { + "message": "No SimpliSafe system could be found for device ID: {device_id}" + }, + "set_system_properties_not_v3": { + "message": "System properties can only be set on V3 systems." + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 77abd69ac833..694f4e98a6fb 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -27,6 +27,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_GROUP, DOMAIN, GROUPS @@ -34,6 +39,39 @@ from .const import CONF_GROUP, DOMAIN, GROUPS _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +STEP_DISCOVERY_CONFIRM_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + async def validate_input( hass: HomeAssistant, user_input: dict[str, Any], @@ -130,18 +168,9 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=self._data[CONF_HOST]): cv.string, - vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, - vol.Optional( - CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] - ): cv.boolean, - vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( - GROUPS - ), - vol.Required(CONF_PASSWORD): cv.string, - } + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input, ), errors=errors, ) @@ -172,20 +201,14 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SSL: user_input[CONF_SSL], CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], CONF_GROUP: user_input[CONF_GROUP], + CONF_PASSWORD: user_input[CONF_PASSWORD], }, ) return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_SSL): cv.boolean, - vol.Optional(CONF_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_GROUP): vol.In(GROUPS), - } - ), + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input or dict(reconf_entry.data), ), errors=errors, @@ -221,7 +244,12 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ), errors=errors, @@ -290,17 +318,9 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", - data_schema=vol.Schema( - { - vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, - vol.Optional( - CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] - ): cv.boolean, - vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( - GROUPS - ), - vol.Required(CONF_PASSWORD): cv.string, - } + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_DISCOVERY_CONFIRM_DATA_SCHEMA, + suggested_values=user_input, ), description_placeholders={CONF_HOST: self._data[CONF_HOST]}, errors=errors, diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 82d8e751498d..1eb9559a2b8f 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -314,9 +314,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for device_identifier in device_status ): continue - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) return True diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 76db28b72bc0..b233cf39ce92 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -4,18 +4,17 @@ from pysmlight import Api2 from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .bluetooth import async_connect_scanner +from .bluetooth import async_setup_ble_scanner from .const import DOMAIN from .coordinator import ( SmConfigEntry, SmDataUpdateCoordinator, SmFirmwareUpdateCoordinator, SmlightData, - base_device_info, ) from .services import async_setup_services @@ -55,13 +54,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: hass, client.sse.client(), "smlight-sse-client" ) - if info.ble is not None and info.ble.proxy_enabled: - device_registry = dr.async_get(hass) - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - **base_device_info(info, client.host), - ) - entry.async_on_unload(async_connect_scanner(hass, entry, info.model, device.id)) + if info.ble is not None and ( + unload_callback := await async_setup_ble_scanner(hass, entry, client, info) + ): + entry.async_on_unload(unload_callback) entry.runtime_data = SmlightData( data=data_coordinator, diff --git a/homeassistant/components/smlight/bluetooth.py b/homeassistant/components/smlight/bluetooth.py index a6c7647ca99b..f3e0d52de208 100644 --- a/homeassistant/components/smlight/bluetooth.py +++ b/homeassistant/components/smlight/bluetooth.py @@ -1,9 +1,10 @@ """Bluetooth proxy for SLZB devices using bleak-smlight.""" from functools import partial +import logging from bleak_smlight import SLZB_BLE_SERVER_PORT, connect_scanner -from pysmlight import BleProxyClient +from pysmlight import Api2, BleProxyClient, Info from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -11,9 +12,12 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr -from .const import DOMAIN -from .coordinator import SmConfigEntry +from .const import CONF_BLE_SCANNER_MODE, DOMAIN, BLEScannerMode +from .coordinator import SmConfigEntry, base_device_info + +_LOGGER = logging.getLogger(__name__) @callback @@ -33,6 +37,7 @@ def async_connect_scanner( entry: SmConfigEntry, model: str | None, device_id: str, + scanner_mode: BluetoothScanningMode = BluetoothScanningMode.AUTO, ) -> CALLBACK_TYPE: """Connect scanner using the external bleak-smlight backend.""" assert entry.unique_id is not None @@ -44,7 +49,7 @@ def async_connect_scanner( port=SLZB_BLE_SERVER_PORT, ) - client_data.scanner.async_set_scanning_mode(BluetoothScanningMode.AUTO) + client_data.scanner.async_set_scanning_mode(scanner_mode) entry.async_create_background_task( hass, @@ -65,3 +70,57 @@ def async_connect_scanner( ] return partial(_async_unload, unload_callbacks, client_data.client) + + +async def async_setup_ble_scanner( + hass: HomeAssistant, + entry: SmConfigEntry, + client: Api2, + info: Info, +) -> CALLBACK_TYPE | None: + """Set up the BLE scanner/proxy configuration.""" + assert info.ble is not None + + scanner_mode = get_ble_scanner_mode(entry, info) + + remote_adapter_enabled = scanner_mode != BLEScannerMode.DISABLED + + if remote_adapter_enabled: + if not info.ble.proxy_enabled: + _LOGGER.warning( + "SMLIGHT BLE proxy is enabled in Home Assistant options but disabled on the device. " + "Please reconfigure the integration options to align settings" + ) + return None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + **base_device_info(info, client.host), + ) + return async_connect_scanner( + hass, + entry, + info.model, + device.id, + BluetoothScanningMode(scanner_mode), + ) + + return None + + +@callback +def get_ble_scanner_mode( + entry: SmConfigEntry, + info: Info, +) -> BLEScannerMode: + """Get the BLE scanner mode config or default.""" + if info.ble is None: + return BLEScannerMode.DISABLED + + return BLEScannerMode( + entry.options.get( + CONF_BLE_SCANNER_MODE, + BLEScannerMode.AUTO if info.ble.proxy_enabled else BLEScannerMode.DISABLED, + ) + ) diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 6f8ea41980d2..3e19b4690b7d 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, ZWAVE_TYPES from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity @@ -129,6 +129,15 @@ class SmButton(SmEntity, ButtonEntity): button = f"_{idx}" if idx else "" self._attr_unique_id = f"{coordinator.unique_id}-{description.key}{button}" + if ( + idx < len(coordinator.data.info.radios) + and coordinator.data.info.radios[idx].zb_type in ZWAVE_TYPES + ): + if description.key == "zigbee_restart": + self._attr_translation_key = "z_wave_restart" + elif description.key == "zigbee_flash_mode": + self._attr_translation_key = "z_wave_flash_mode" + @override async def async_press(self) -> None: """Trigger button press.""" diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index ce081b35ff43..00cda638dbd5 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -13,14 +13,23 @@ from homeassistant.config_entries import ( SOURCE_USER, ConfigFlow, ConfigFlowResult, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import DOMAIN +from .bluetooth import get_ble_scanner_mode +from .const import CONF_BLE_SCANNER_MODE, DOMAIN, BLEScannerMode +from .coordinator import SmConfigEntry STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -35,6 +44,25 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema( } ) +BLE_SCANNER_OPTIONS = [ + BLEScannerMode.DISABLED, + BLEScannerMode.AUTO, + BLEScannerMode.ACTIVE, + BLEScannerMode.PASSIVE, +] + +BLE_SCANNER_SCHEMA = vol.Schema( + { + vol.Required(CONF_BLE_SCANNER_MODE): SelectSelector( + SelectSelectorConfig( + options=BLE_SCANNER_OPTIONS, + translation_key=CONF_BLE_SCANNER_MODE, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } +) + class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" @@ -275,3 +303,86 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): assert info.model is not None title = self._device_name or info.model return self.async_create_entry(title=title, data=user_input) + + @staticmethod + @callback + @override + def async_get_options_flow( + config_entry: SmConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + + +class OptionsFlowHandler(OptionsFlowWithReload): + """Handle options flow for SMLIGHT.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + errors: dict[str, str] = {} + + if not hasattr(self.config_entry, "runtime_data"): + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + BLE_SCANNER_SCHEMA, + user_input or {CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED}, + ), + errors=errors, + ) + + coordinator = self.config_entry.runtime_data.data + info = coordinator.data.info + + if info.ble is None: + return await self.async_step_no_settings() + + if user_input is not None: + scanner_mode = BLEScannerMode(user_input[CONF_BLE_SCANNER_MODE]) + user_input[CONF_BLE_SCANNER_MODE] = scanner_mode + current_mode = get_ble_scanner_mode(self.config_entry, info) + + if (scanner_mode == BLEScannerMode.DISABLED) != ( + current_mode == BLEScannerMode.DISABLED + ): + try: + await coordinator.client.set_ble_proxy( + scanner_mode != BLEScannerMode.DISABLED + ) + except SmlightConnectionError: + errors["base"] = "cannot_connect" + except SmlightAuthError: + errors["base"] = "invalid_auth" + self.config_entry.async_start_reauth(self.hass) + + if not errors: + return self.async_create_entry(title="", data=user_input) + + suggested_values = { + CONF_BLE_SCANNER_MODE: get_ble_scanner_mode(self.config_entry, info) + } + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + BLE_SCANNER_SCHEMA, user_input or suggested_values + ), + errors=errors, + ) + + async def async_step_no_settings( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options for devices without settings.""" + if user_input is not None: + return self.async_create_entry(title="", data={}) + + coordinator = self.config_entry.runtime_data.data + return self.async_show_form( + step_id="no_settings", + data_schema=vol.Schema({}), + description_placeholders={"model": coordinator.data.info.model}, + ) diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index 0a45363f8adf..3dc91ebd312e 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -1,8 +1,11 @@ """Constants for the SMLIGHT Zigbee integration.""" from datetime import timedelta +from enum import StrEnum import logging +from pysmlight.const import ZB_TYPES + DOMAIN = "smlight" ATTR_MANUFACTURER = "SMLIGHT" @@ -14,3 +17,17 @@ LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERNET_INTERVAL = timedelta(minutes=15) UPTIME_DEVIATION = timedelta(seconds=5) + +CONF_BLE_SCANNER_MODE = "ble_scanner_mode" + + +class BLEScannerMode(StrEnum): + """BLE scanner mode.""" + + DISABLED = "disabled" + AUTO = "auto" + ACTIVE = "active" + PASSIVE = "passive" + + +ZWAVE_TYPES = tuple(k for k, v in ZB_TYPES.items() if v.lower().startswith("zwave")) diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py index 0e9859473128..7567ba60d116 100644 --- a/homeassistant/components/smlight/infrared.py +++ b/homeassistant/components/smlight/infrared.py @@ -2,11 +2,17 @@ from typing import override +from pysmlight.const import Events as SmEvents from pysmlight.exceptions import SmlightError from pysmlight.models import IRPayload -from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity -from homeassistant.core import HomeAssistant +from homeassistant.components.infrared import ( + InfraredCommand, + InfraredEmitterEntity, + InfraredReceivedSignal, + InfraredReceiverEntity, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -26,14 +32,17 @@ async def async_setup_entry( coordinator = entry.runtime_data.data if coordinator.data.info.has_peripherals: - async_add_entities([SmInfraredEntity(coordinator)]) + async_add_entities( + [ + SmInfraredEntity(coordinator), + SmInfraredReceiverEntity(coordinator), + ] + ) class SmInfraredEntity(SmEntity, InfraredEmitterEntity): """Representation of a SLZB-Ultima infrared emitter.""" - _attr_translation_key = "infrared_emitter" - def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: """Initialize the SLZB-Ultima infrared.""" super().__init__(coordinator) @@ -59,3 +68,27 @@ class SmInfraredEntity(SmEntity, InfraredEmitterEntity): translation_key="send_ir_code_failed", translation_placeholders={"error": str(err)}, ) from err + + +class SmInfraredReceiverEntity(SmEntity, InfraredReceiverEntity): + """Representation of a SLZB-Ultima infrared receiver.""" + + def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: + """Initialize the SLZB-Ultima infrared receiver.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.unique_id}-receiver" + + @override + async def async_added_to_hass(self) -> None: + """Register SSE callbacks when entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.client.sse.register_callback( + SmEvents.IR_CODE, self._handle_ir_code + ) + ) + + @callback + def _handle_ir_code(self, timings: list[int]) -> None: + """Handle received IR code.""" + self._handle_received_signal(InfraredReceivedSignal(timings=timings)) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index e43028669e73..8a818fccfd8f 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["pysmlight==0.5.2", "bleak-smlight==1.1.0"], + "requirements": ["pysmlight==0.5.3", "bleak-smlight==1.1.0"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 5335e0aec9ba..69927ddf0668 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import UPTIME_DEVIATION +from .const import UPTIME_DEVIATION, ZWAVE_TYPES from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity @@ -159,7 +159,8 @@ async def async_setup_entry( entities.extend( SmInfoSensorEntity(coordinator, RADIO_INFO, idx) - for idx, _ in enumerate(coordinator.data.info.radios) + for idx, radio in enumerate(coordinator.data.info.radios) + if radio.zb_type not in ZWAVE_TYPES ) if coordinator.data.sensors.zb_temp2 is not None: diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index ad83fc40d7e1..6de1f48ecaed 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -84,6 +84,12 @@ "reconnect_zigbee_router": { "name": "Reconnect Zigbee router" }, + "z_wave_flash_mode": { + "name": "Z-Wave flash mode" + }, + "z_wave_restart": { + "name": "Z-Wave restart" + }, "zigbee_flash_mode": { "name": "Zigbee flash mode" }, @@ -91,11 +97,6 @@ "name": "Zigbee restart" } }, - "infrared": { - "infrared_emitter": { - "name": "IR emitter" - } - }, "light": { "ambilight": { "name": "Ambilight" @@ -165,6 +166,9 @@ "core_update": { "name": "Core firmware" }, + "z_wave_update": { + "name": "Z-Wave firmware" + }, "zigbee_update": { "name": "Zigbee firmware" } @@ -196,6 +200,36 @@ "title": "SLZB core firmware update required" } }, + "options": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "init": { + "data": { + "ble_scanner_mode": "Bluetooth scanner mode" + }, + "data_description": { + "ble_scanner_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly." + }, + "description": "Configure SLZB Bluetooth settings. Switching between Disabled and another mode will reboot the device to apply the new configuration." + }, + "no_settings": { + "description": "This SMLIGHT device ({model}) does not support settings." + } + } + }, + "selector": { + "ble_scanner_mode": { + "options": { + "active": "Active (uses more device battery, fastest updates)", + "auto": "Auto (recommended, saves device battery)", + "disabled": "[%key:common::state::disabled%]", + "passive": "Passive (lowest device battery use, some details may be missing)" + } + } + }, "services": { "play_rtttl": { "description": "Play an RTTTL melody on the SMLIGHT device buzzer.", diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 899ccfaa6468..37b738ab47e8 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, ZWAVE_TYPES from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity @@ -77,7 +77,8 @@ async def async_setup_entry( entities.extend( SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx) - for idx, _ in enumerate(radios) + for idx, radio in enumerate(radios) + if radio.zb_type != -1 ) async_add_entities(entities) @@ -114,6 +115,14 @@ class SmUpdateEntity(SmEntity, UpdateEntity): self._unload: list[Callable] = [] self.idx = idx + if ( + (data := coordinator.data) + and idx < len(data.info.radios) + and data.info.radios[idx].zb_type in ZWAVE_TYPES + ): + if description.key == "zigbee_update": + self._attr_translation_key = "z_wave_update" + @override async def async_added_to_hass(self) -> None: """When entity is added to hass.""" @@ -173,7 +182,16 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def release_notes(self) -> str | None: """Return release notes for firmware.""" if "zigbee" in self.entity_description.key: - notes = f"### {'ZNP' if self.idx else 'EZSP'} Firmware\n\n" + radio_desc = "Zigbee" + if (data := self.coordinator.data) and self.idx < len(data.info.radios): + radio = data.info.radios[self.idx] + if radio.zb_type in ZWAVE_TYPES: + radio_desc = "Z-Wave" + elif radio.zb_hw and "EFR32" in radio.zb_hw: + radio_desc = "EZSP" + elif radio.zb_hw and "CC2" in radio.zb_hw: + radio_desc = "ZNP" + notes = f"### {radio_desc} Firmware\n\n" else: notes = "### Core Firmware\n\n" diff --git a/homeassistant/components/smtp/config_flow.py b/homeassistant/components/smtp/config_flow.py index b38cfef48c85..28e43b3c9388 100644 --- a/homeassistant/components/smtp/config_flow.py +++ b/homeassistant/components/smtp/config_flow.py @@ -10,6 +10,8 @@ from typing import Any, override import voluptuous as vol +from homeassistant import data_entry_flow +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ( SOURCE_USER, ConfigFlow, @@ -33,7 +35,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -58,17 +60,34 @@ from .const import ( DEFAULT_TIMEOUT, DOMAIN, ENCRYPTION_OPTIONS, + SECTION_OPTIONS, SUBENTRY_TYPE_RECIPIENT, ) _LOGGER = logging.getLogger(__name__) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, + max=1800, + step=1, + unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ) + } +) STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_SENDER): TextSelector( TextSelectorConfig( - type=TextSelectorType.TEXT, + type=TextSelectorType.EMAIL, autocomplete="email", ), ), @@ -114,23 +133,6 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema( } ) -OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.All( - NumberSelector( - NumberSelectorConfig( - min=1, - max=1800, - step=1, - unit_of_measurement=UnitOfTime.SECONDS, - mode=NumberSelectorMode.BOX, - ) - ), - vol.Coerce(int), - ) - } -) - class MailConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMTP.""" @@ -165,16 +167,29 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + entry_data = user_input.copy() + options = entry_data.pop(SECTION_OPTIONS) + errors = await self.hass.async_add_executor_job( + validate_input, entry_data, options + ) if not errors: return self.async_create_entry( - title=user_input.get(CONF_SENDER_NAME, user_input[CONF_SENDER]), - data=user_input, + title=entry_data.get(CONF_SENDER_NAME, entry_data[CONF_SENDER]), + data=entry_data, + options=options, ) return self.async_show_form( step_id="user", data_schema=self.add_suggested_values_to_schema( - data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + data_schema=STEP_USER_DATA_SCHEMA.extend( + { + vol.Required(SECTION_OPTIONS): data_entry_flow.section( + OPTIONS_SCHEMA, + {"collapsed": True}, + ), + } + ), + suggested_values=user_input, ), errors=errors, ) @@ -208,7 +223,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input.get(CONF_USERNAME), } ) - errors = await self.hass.async_add_executor_job(validate_input, user_input) + errors = await self.hass.async_add_executor_job( + validate_input, user_input, dict(entry.options) + ) if not errors: return self.async_update_and_abort( entry, @@ -239,7 +256,7 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: errors = await self.hass.async_add_executor_job( - validate_input, {**entry.data, **user_input} + validate_input, {**entry.data, **user_input}, dict(entry.options) ) if not errors: return self.async_update_and_abort( @@ -262,7 +279,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): options = {CONF_TIMEOUT: import_info.pop(CONF_TIMEOUT, DEFAULT_TIMEOUT)} self._async_abort_entries_match(import_info) - errors = await self.hass.async_add_executor_job(validate_input, import_info) + errors = await self.hass.async_add_executor_job( + validate_input, import_info, options + ) if not errors: title = ( import_info.get(CONF_NAME) @@ -287,7 +306,9 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason=errors["base"]) -def validate_input(user_input: dict[str, Any]) -> dict[str, str]: +def validate_input( + user_input: dict[str, Any], options: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect.""" errors: dict[str, str] = {} ssl_context = create_client_context() if user_input[CONF_VERIFY_SSL] else None @@ -297,12 +318,14 @@ def validate_input(user_input: dict[str, Any]) -> dict[str, str]: mail = SMTP_SSL( user_input[CONF_SERVER], user_input[CONF_PORT], - timeout=DEFAULT_TIMEOUT, + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), context=ssl_context, ) else: mail = SMTP( - user_input[CONF_SERVER], user_input[CONF_PORT], timeout=DEFAULT_TIMEOUT + user_input[CONF_SERVER], + user_input[CONF_PORT], + timeout=options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) mail.ehlo_or_helo_if_needed() if user_input[CONF_ENCRYPTION] == "starttls": @@ -351,7 +374,7 @@ class RecipientSubentryFlowHandler(ConfigSubentryFlow): vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_RECIPIENT): TextSelector( TextSelectorConfig( - type=TextSelectorType.TEXT, + type=TextSelectorType.EMAIL, autocomplete="email", ), ), @@ -359,6 +382,55 @@ class RecipientSubentryFlowHandler(ConfigSubentryFlow): ), ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure flow to update a recipient.""" + + entry = self._get_entry() + subentry = self._get_reconfigure_subentry() + + if user_input is not None: + old_unique_id = subentry.unique_id + result = self.async_update_and_abort( + entry, + subentry=subentry, + title=( + user_input[CONF_RECIPIENT] + if subentry.title == old_unique_id + else subentry.title + ), + data_updates={}, + unique_id=user_input[CONF_RECIPIENT], + ) + if result.get("reason") == "reconfigure_successful" and ( + entity := er.async_get(self.hass).async_get_entity_id( + NOTIFY_DOMAIN, DOMAIN, f"{entry.entry_id}_{old_unique_id}" + ) + ): + er.async_get(self.hass).async_update_entity( + entity, + new_unique_id=f"{entry.entry_id}_{user_input[CONF_RECIPIENT]}", + ) + return result + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + { + vol.Required(CONF_RECIPIENT): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ), + ) + } + ), + suggested_values={CONF_RECIPIENT: subentry.unique_id}, + ), + ) + class OptionsFlowHandler(OptionsFlow): """Handle options flow.""" diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py index dc9fccd3d5ab..78fb8d99cf27 100644 --- a/homeassistant/components/smtp/const.py +++ b/homeassistant/components/smtp/const.py @@ -11,6 +11,7 @@ ATTR_SENDER_NAME: Final = "sender_name" CONF_ENCRYPTION: Final = "encryption" CONF_SERVER: Final = "server" CONF_SENDER_NAME: Final = "sender_name" +SECTION_OPTIONS: Final = "options" DEFAULT_HOST: Final = "localhost" DEFAULT_PORT: Final = 587 diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 15b65b36ea39..325f121575e3 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -201,6 +201,9 @@ class MailNotifyEntity(NotifyEntity): msg["From"] = email.utils.formataddr( (self._entry.data.get(CONF_SENDER_NAME), self._entry.data[CONF_SENDER]) ) + msg["To"] = email.utils.formataddr( + (self._subentry.title, self._subentry.unique_id) + ) msg["X-Mailer"] = "Home Assistant" msg["Date"] = email.utils.format_datetime(dt_util.now()) msg["Message-Id"] = email.utils.make_msgid() @@ -312,21 +315,37 @@ class MailNotificationService(SmtpClient, BaseNotificationService): def _send_email(self, msg: MIMEMultipart | MIMEText, recipients: list[str]) -> None: """Send the message.""" mail = self.connect() - for _ in range(self.tries): + for attempt in range(self.tries): try: mail.sendmail(self._sender, recipients, msg.as_string()) break - except SMTPServerDisconnected: + except SMTPServerDisconnected as e: + with suppress(SMTPException): + mail.quit() + if attempt == self.tries - 1: + _LOGGER.debug("Full exception:", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_mail_connection_error", + ) from e _LOGGER.warning( - "SMTPServerDisconnected sending mail: retrying connection" + "SMTPServerDisconnected sending mail: retrying connection", + exc_info=_LOGGER.isEnabledFor(logging.DEBUG), ) - with suppress(SMTPException): - mail.quit() mail = self.connect() - except SMTPException: - _LOGGER.warning("SMTPException sending mail: retrying connection") + except SMTPException as e: with suppress(SMTPException): mail.quit() + if attempt == self.tries - 1: + _LOGGER.debug("Full exception:", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_mail_connection_error", + ) from e + _LOGGER.warning( + "SMTPException sending mail: retrying connection", + exc_info=_LOGGER.isEnabledFor(logging.DEBUG), + ) mail = self.connect() with suppress(SMTPException): mail.quit() diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index 1c6a8cf6c2c5..c48c4798e429 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -67,6 +67,17 @@ "server": "Hostname or IP address of the SMTP server. For example, `smtp.example.com`.", "username": "Username used to authenticate with the SMTP server.", "verify_ssl": "Enable certificate verification for secure SSL/TLS connections." + }, + "sections": { + "options": { + "data": { + "timeout": "[%key:component::smtp::options::step::init::data::timeout%]" + }, + "data_description": { + "timeout": "[%key:component::smtp::options::step::init::data_description::timeout%]" + }, + "name": "Additional options" + } } } } @@ -74,13 +85,24 @@ "config_subentries": { "recipient": { "abort": { - "already_configured": "Recipient is already configured" + "already_configured": "Recipient is already configured", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Recipient", "initiate_flow": { "user": "Add recipient" }, "step": { + "reconfigure": { + "data": { + "recipient": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "recipient": "[%key:component::smtp::config_subentries::recipient::step::user::data_description::recipient%]" + }, + "description": "Update the recipient email address.", + "title": "Reconfigure recipient" + }, "user": { "data": { "name": "[%key:common::config_flow::data::name%]", diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index 7ec6eb89b6f5..19db35212796 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -14,7 +14,11 @@ from pysnooz.commands import ( ) import voluptuous as vol -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + FanEntity, + FanEntityFeature, + FanEntityStateAttribute, +) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -109,7 +113,9 @@ class SnoozFan(FanEntity, RestoreEntity): self._is_on = last_state.state == STATE_ON else: self._is_on = None - self._percentage = last_state.attributes.get(ATTR_PERCENTAGE) + self._percentage = last_state.attributes.get( + FanEntityStateAttribute.PERCENTAGE + ) self.async_on_remove(self._async_subscribe_to_device_change()) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 27d3d3eadc0b..1678eac5e599 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -15,7 +15,7 @@ from aiohttp import ClientError from requests.exceptions import HTTPError, Timeout from soco import events_asyncio, zonegroupstate import soco.config as soco_config -from soco.core import SoCo +from soco.core import SoCo, soco_reset from soco.events_base import Event as SonosEvent, SubscriptionBase from soco.exceptions import SoCoException import voluptuous as vol @@ -114,6 +114,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: SonosConfigEntry) -> bool: """Set up Sonos from a config entry.""" + _LOGGER.debug("Setting up Sonos config entry: %s", entry.entry_id) + soco_reset() soco_config.EVENTS_MODULE = events_asyncio soco_config.REQUEST_TIMEOUT = 9.5 soco_config.ZGT_EVENT_FALLBACK = False @@ -153,6 +155,8 @@ async def async_unload_entry( config_entry, PLATFORMS ) await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown() + soco_reset() + _LOGGER.debug("Sonos config entry unloaded: %s", config_entry.entry_id) return unload_ok @@ -492,9 +496,22 @@ class SonosDiscoveryManager: None, ) if not known_speaker: + try: + uid = await self.hass.async_add_executor_job(getattr, soco, "uid") + except HTTPError as err: + await self._process_http_connection_error(err, ip_addr) + continue + except ( + OSError, + SoCoException, + Timeout, + TimeoutError, + ) as ex: + _LOGGER.warning("Could not get Sonos uid from %s: %s", ip_addr, ex) + continue try: await self._async_handle_discovery_message( - soco.uid, + uid, ip_addr, "manual zone scan", ) @@ -511,7 +528,7 @@ class SonosDiscoveryManager: # Only send the message if the ping was successful. async_dispatcher_send( self.hass, - f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}", + f"{SONOS_SPEAKER_ACTIVITY}-{known_speaker.uid}", "manual zone scan", ) except SonosUpdateError: diff --git a/homeassistant/components/sonos/button.py b/homeassistant/components/sonos/button.py new file mode 100644 index 000000000000..c286a9363c9f --- /dev/null +++ b/homeassistant/components/sonos/button.py @@ -0,0 +1,50 @@ +"""Button entities for Sonos.""" + +from typing import override + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import SONOS_CREATE_BUTTON +from .entity import SonosEntity +from .helpers import SonosConfigEntry +from .speaker import SonosSpeaker + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Sonos button entities from a config entry.""" + + @callback + def async_create_entities(speaker: SonosSpeaker) -> None: + """Handle device discovery and create button entities.""" + async_add_entities([SonosCancelAnnouncementButton(speaker, config_entry)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_BUTTON, async_create_entities) + ) + + +class SonosCancelAnnouncementButton(SonosEntity, ButtonEntity): + """Button to cancel the current Sonos announcement.""" + + _attr_translation_key = "cancel_announcement" + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the cancel announcement button.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-cancel_announcement" + + @override + async def _async_fallback_poll(self) -> None: + """No-op: button state does not need polling.""" + + @override + async def async_press(self) -> None: + """Cancel the current announcement audio clip.""" + await self.speaker.async_cancel_announcement() diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 3142e72e6854..07d7d11ea461 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -11,6 +11,7 @@ DOMAIN = "sonos" DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, @@ -159,6 +160,7 @@ PLAYABLE_MEDIA_TYPES = [ SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" +SONOS_CREATE_BUTTON = "sonos_create_button" SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_FAVORITES_SENSOR = "sonos_create_favorites_sensor" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 2b4df23b4ccc..db7229c2e15c 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import dispatcher_send -from .const import SONOS_SPEAKER_ACTIVITY +from .const import DOMAIN, SONOS_SPEAKER_ACTIVITY from .exception import SonosUpdateError if TYPE_CHECKING: @@ -30,6 +30,8 @@ if TYPE_CHECKING: UID_PREFIX = "RINCON_" UID_POSTFIX = "01400" +UPNP_ERROR_COMMAND_FAILED = "800" + _LOGGER = logging.getLogger(__name__) type _SonosEntitiesType = ( @@ -76,8 +78,24 @@ def soco_error[_T: _SonosEntitiesType, **_P, _R]( if (target := _find_target_identifier(self, args_soco)) is None: raise RuntimeError("Unexpected use of soco_error") from err - message = f"Error calling {function} on {target}: {err}" - raise SonosUpdateError(message) from err + translation_key = "call_failed" + placeholders = { + "target": target, + "error": str(err), + } + + if error_code is not None: + translation_key = "upnp_call_failed" + placeholders["error_code"] = str(error_code) + + if str(error_code) == UPNP_ERROR_COMMAND_FAILED: + translation_key = "upnp_call_failed_music_service_unavailable" + + raise SonosUpdateError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders=placeholders, + ) from err dispatch_soco = args_soco or self.soco # type: ignore[union-attr] dispatcher_send( diff --git a/homeassistant/components/sonos/icons.json b/homeassistant/components/sonos/icons.json index e28e4c305a99..2c16c854be9a 100644 --- a/homeassistant/components/sonos/icons.json +++ b/homeassistant/components/sonos/icons.json @@ -5,6 +5,11 @@ "default": "mdi:microphone" } }, + "button": { + "cancel_announcement": { + "default": "mdi:cancel" + } + }, "sensor": { "audio_input_format": { "default": "mdi:import" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 001f0c9e220e..90d59e0c7db4 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -2,7 +2,7 @@ "domain": "sonos", "name": "Sonos", "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], - "codeowners": ["@jjlawren", "@peterager"], + "codeowners": ["@peterager", "@jjlawren"], "config_flow": true, "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index d1f5fb1b2cc4..94de448dc6ae 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -17,6 +17,7 @@ from soco.core import ( from soco.data_structures import DidlFavorite, DidlMusicTrack from soco.exceptions import SoCoException from soco.ms_data_structures import MusicServiceItem +from sonos_websocket import CLIP_ID_KEY from sonos_websocket.exception import SonosWebsocketError from homeassistant.components import media_source, spotify @@ -528,8 +529,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) _LOGGER.debug("Playing %s using websocket audioclip", media_id) try: + self.speaker.last_announce_id = None assert self.speaker.websocket - response, _ = await self.speaker.websocket.play_clip( + response, data = await self.speaker.websocket.play_clip( async_process_play_media_url(self.hass, media_id), volume=volume, ) @@ -538,6 +540,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): f"Error when calling Sonos websocket: {exc}" ) from exc if response.get("success"): + if data: + self.speaker.last_announce_id = data.get(CLIP_ID_KEY) return if response.get("type") in ANNOUNCE_NOT_SUPPORTED_ERRORS: # If the speaker does not support announce do not raise and diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f55204491156..24cd43bbcb3e 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -17,10 +17,11 @@ from soco.plugins.plex import PlexPlugin from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot from sonos_websocket import SonosWebsocket +from sonos_websocket.exception import SonosWebsocketError from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -44,6 +45,7 @@ from .const import ( SONOS_CREATE_ALARM, SONOS_CREATE_AUDIO_FORMAT_SENSOR, SONOS_CREATE_BATTERY, + SONOS_CREATE_BUTTON, SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, SONOS_CREATE_MIC_SENSOR, @@ -186,6 +188,9 @@ class SonosSpeaker: self.snapshot_group: list[SonosSpeaker] = [] self._group_members_missing: set[str] = set() + # Announcement tracking + self.last_announce_id: str | None = None + async def async_setup( self, entry: SonosConfigEntry, @@ -261,6 +266,7 @@ class SonosSpeaker: dispatches.append((SONOS_CREATE_SELECTS, self)) dispatches.append((SONOS_CREATE_SWITCHES, self)) + dispatches.append((SONOS_CREATE_BUTTON, self)) dispatches.append((SONOS_CREATE_MEDIA_PLAYER, self)) dispatches.append((SONOS_SPEAKER_ADDED, self.soco.uid)) @@ -1294,6 +1300,35 @@ class SonosSpeaker: any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() + async def async_cancel_announcement(self) -> None: + """Cancel the current announcement audio clip.""" + if self.last_announce_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_no_id", + ) + if not self.websocket: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": "websocket not available"}, + ) + try: + response, _ = await self.websocket.cancel_clip(self.last_announce_id) + except SonosWebsocketError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="announcement_connection_error", + translation_placeholders={"error": str(exc)}, + ) from exc + if not response.get("success"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cancel_announcement_error", + translation_placeholders={"response": str(response)}, + ) + self.last_announce_id = None + # # Media and playback state handlers # diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index f2e01da70fa3..82c99a7e1d23 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -18,6 +18,11 @@ "name": "Microphone" } }, + "button": { + "cancel_announcement": { + "name": "Cancel announcement" + } + }, "number": { "audio_delay": { "name": "Audio delay" @@ -109,6 +114,18 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "announcement_connection_error": { + "message": "Failed to reach Sonos speaker for announcement: {error}" + }, + "call_failed": { + "message": "Error on {target}: {error}" + }, + "cancel_announcement_error": { + "message": "Cancelling announcement failed: {response}" + }, + "cancel_announcement_no_id": { + "message": "No active announcement to cancel" + }, "entity_not_found": { "message": "Entity {entity_id} not found." }, @@ -141,6 +158,12 @@ }, "toggle_failed": { "message": "Could not toggle {entity_id}." + }, + "upnp_call_failed": { + "message": "Error on {target} (UPnP error code {error_code}): {error}" + }, + "upnp_call_failed_music_service_unavailable": { + "message": "Error on {target} (UPnP error code {error_code}): {error}. This may indicate the selected music service is not available on the speaker." } }, "issues": { diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 106ce1b87192..a05867b8fd7f 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -15,7 +15,6 @@ from homeassistant.const import ( ATTR_LOCATION, ATTR_NAME, ATTR_STATE, - ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, CONF_EMAIL, CONF_ENTITY_ID, @@ -23,6 +22,7 @@ from homeassistant.const import ( CONF_SENSORS, CONF_STATE, CONF_URL, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -280,8 +280,10 @@ class APISpaceApiView(HomeAssistantView): else: sensor_data[ATTR_LOCATION] = spaceapi[CONF_SPACE] # Some sensors don't have a unit of measurement - if ATTR_UNIT_OF_MEASUREMENT in sensor_state.attributes: - sensor_data[ATTR_UNIT] = sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + if EntityStateAttribute.UNIT_OF_MEASUREMENT in sensor_state.attributes: + sensor_data[ATTR_UNIT] = sensor_state.attributes[ + EntityStateAttribute.UNIT_OF_MEASUREMENT + ] return sensor_data diff --git a/homeassistant/components/splunk/config_flow.py b/homeassistant/components/splunk/config_flow.py index 43157661be74..437b8ad24e6e 100644 --- a/homeassistant/components/splunk/config_flow.py +++ b/homeassistant/components/splunk/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SSL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -56,7 +56,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_TOKEN): str, vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, vol.Optional(CONF_VERIFY_SSL, default=True): bool, vol.Optional(CONF_NAME): str, } @@ -109,7 +109,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_TOKEN): str, vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, vol.Optional(CONF_VERIFY_SSL, default=True): bool, vol.Optional(CONF_NAME): str, } @@ -159,7 +159,7 @@ class SplunkConfigFlow(ConfigFlow, domain=DOMAIN): host=user_input.get(CONF_HOST, DEFAULT_HOST), port=user_input.get(CONF_PORT, DEFAULT_PORT), token=user_input[CONF_TOKEN], - use_ssl=user_input.get(CONF_SSL, False), + use_ssl=user_input.get(CONF_SSL, DEFAULT_SSL), verify_ssl=user_input.get(CONF_VERIFY_SSL, True), ) diff --git a/homeassistant/components/splunk/const.py b/homeassistant/components/splunk/const.py index 1be3beaf5019..c9cec957ca8e 100644 --- a/homeassistant/components/splunk/const.py +++ b/homeassistant/components/splunk/const.py @@ -6,5 +6,5 @@ CONF_FILTER = "filter" DEFAULT_HOST = "localhost" DEFAULT_PORT = 8088 -DEFAULT_SSL = False +DEFAULT_SSL = True DEFAULT_NAME = "HASS" diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 99dce6f63424..c8a23a2cca3c 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.46.2"] + "requirements": ["async-upnp-client==0.47.0"] } diff --git a/homeassistant/components/starline/__init__.py b/homeassistant/components/starline/__init__.py index 5ae3994632f0..d41bec14781a 100644 --- a/homeassistant/components/starline/__init__.py +++ b/homeassistant/components/starline/__init__.py @@ -1,12 +1,11 @@ """The StarLine component.""" -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .account import StarlineAccount from .const import ( @@ -15,13 +14,19 @@ from .const import ( DEFAULT_SCAN_OBD_INTERVAL, DOMAIN, PLATFORMS, - SERVICE_SET_SCAN_INTERVAL, - SERVICE_SET_SCAN_OBD_INTERVAL, - SERVICE_UPDATE_STATE, ) +from .services import async_setup_services type StarlineConfigEntry = ConfigEntry[StarlineAccount] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the StarLine integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StarlineConfigEntry) -> bool: """Set up the StarLine device from a config entry.""" @@ -41,52 +46,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: StarlineConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def async_set_scan_interval(call: ServiceCall) -> None: - """Set scan interval.""" - options = dict(entry.options) - options[CONF_SCAN_INTERVAL] = call.data[CONF_SCAN_INTERVAL] - hass.config_entries.async_update_entry(entry=entry, options=options) - - async def async_set_scan_obd_interval(call: ServiceCall) -> None: - """Set OBD info scan interval.""" - options = dict(entry.options) - options[CONF_SCAN_OBD_INTERVAL] = call.data[CONF_SCAN_INTERVAL] - hass.config_entries.async_update_entry(entry=entry, options=options) - - async def async_update(call: ServiceCall | None = None) -> None: - """Update all data.""" - await account.update() - await account.update_obd() - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, async_update) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_SCAN_INTERVAL, - async_set_scan_interval, - schema=vol.Schema( - { - vol.Required(CONF_SCAN_INTERVAL): vol.All( - vol.Coerce(int), vol.Range(min=10) - ) - } - ), - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_SCAN_OBD_INTERVAL, - async_set_scan_obd_interval, - schema=vol.Schema( - { - vol.Required(CONF_SCAN_INTERVAL): vol.All( - vol.Coerce(int), vol.Range(min=180) - ) - } - ), - ) - entry.async_on_unload(entry.add_update_listener(async_options_updated)) await async_options_updated(hass, entry) diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index 80fa4d9f8af2..f7cda744db7f 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -1,6 +1,6 @@ """Config flow to configure StarLine component.""" -from typing import override +from typing import TYPE_CHECKING, override from starline import StarlineAuth import voluptuous as vol @@ -192,13 +192,18 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Authenticate application.""" try: - self._app_code = await self.hass.async_add_executor_job( - self._auth.get_app_code, self._app_id, self._app_secret - ) - # pylint: disable-next=home-assistant-sequential-executor-jobs - self._app_token = await self.hass.async_add_executor_job( - self._auth.get_app_token, self._app_id, self._app_secret, self._app_code - ) + + def _get_app_token() -> str: + if TYPE_CHECKING: + assert self._app_id is not None + assert self._app_secret is not None + + app_code = self._auth.get_app_code(self._app_id, self._app_secret) + return self._auth.get_app_token( + self._app_id, self._app_secret, app_code + ) + + self._app_token = await self.hass.async_add_executor_job(_get_app_token) return self._async_form_auth_user(error) except Exception as err: # noqa: BLE001 _LOGGER.error("Error auth StarLine: %s", err) diff --git a/homeassistant/components/starline/services.py b/homeassistant/components/starline/services.py new file mode 100644 index 000000000000..2ae893b27927 --- /dev/null +++ b/homeassistant/components/starline/services.py @@ -0,0 +1,65 @@ +"""Services for the StarLine integration.""" + +import voluptuous as vol + +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant, ServiceCall, callback + +from .const import ( + CONF_SCAN_OBD_INTERVAL, + DOMAIN, + SERVICE_SET_SCAN_INTERVAL, + SERVICE_SET_SCAN_OBD_INTERVAL, + SERVICE_UPDATE_STATE, +) + +SET_SCAN_INTERVAL_SCHEMA = vol.Schema( + {vol.Required(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=10))} +) + +SET_SCAN_OBD_INTERVAL_SCHEMA = vol.Schema( + {vol.Required(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=180))} +) + + +async def _async_update(call: ServiceCall) -> None: + """Update all data.""" + for entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + account = entry.runtime_data + await account.update() + await account.update_obd() + + +async def _async_set_scan_interval(call: ServiceCall) -> None: + """Set scan interval.""" + for entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + options = dict(entry.options) + options[CONF_SCAN_INTERVAL] = call.data[CONF_SCAN_INTERVAL] + call.hass.config_entries.async_update_entry(entry=entry, options=options) + + +async def _async_set_scan_obd_interval(call: ServiceCall) -> None: + """Set OBD info scan interval.""" + for entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + options = dict(entry.options) + options[CONF_SCAN_OBD_INTERVAL] = call.data[CONF_SCAN_INTERVAL] + call.hass.config_entries.async_update_entry(entry=entry, options=options) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register StarLine services.""" + + hass.services.async_register(DOMAIN, SERVICE_UPDATE_STATE, _async_update) + hass.services.async_register( + DOMAIN, + SERVICE_SET_SCAN_INTERVAL, + _async_set_scan_interval, + schema=SET_SCAN_INTERVAL_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_SCAN_OBD_INTERVAL, + _async_set_scan_obd_interval, + schema=SET_SCAN_OBD_INTERVAL_SCHEMA, + ) diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index 49dcb19ceb56..4de69276a9a2 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -35,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 688d999a3c64..23c595e16d7e 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -24,14 +24,13 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -841,7 +840,9 @@ class StatisticsSensor(SensorEntity): state characteristics. """ - base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + base_unit: str | None = new_state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) unit: str | None = None stat_type = self._state_characteristic if self.is_binary and stat_type in STATS_BINARY_PERCENTAGE: @@ -880,7 +881,7 @@ class StatisticsSensor(SensorEntity): if stat_type in STATS_DATETIME: return SensorDeviceClass.TIMESTAMP if stat_type in STATS_NUMERIC_RETAIN_UNIT: - device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + device_class = new_state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class is None: return None if ( diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 8d4464e06c25..335636df105c 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -1,9 +1,13 @@ """The Steam integration.""" +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from .const import CONF_ACCOUNTS, DOMAIN, SUBENTRY_TYPE_FRIEND from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -16,9 +20,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> boo entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True +async def _async_update_listener(hass: HomeAssistant, entry: SteamConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -40,4 +51,24 @@ async def async_migrate_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> b await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) hass.config_entries.async_update_entry(entry, version=2) + if entry.version < 3: + for steamid, name in entry.options[CONF_ACCOUNTS].items(): + if steamid == entry.unique_id: + continue + subentry = ConfigSubentry( + subentry_type=SUBENTRY_TYPE_FRIEND, + title=name, + unique_id=steamid, + data={}, # type: ignore[arg-type] + ) + hass.config_entries.async_add_subentry(entry, subentry) + + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get_device({(DOMAIN, entry.entry_id)}): + if TYPE_CHECKING: + assert entry.unique_id + dev_reg.async_update_device( + device.id, new_identifiers={(DOMAIN, entry.unique_id)} + ) + hass.config_entries.async_update_entry(entry, version=3, options={}) return True diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index fc5313376e80..b70f56b6b4f6 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -1,8 +1,9 @@ """Config flow for Steam integration.""" -from collections.abc import Iterator, Mapping +from collections.abc import Mapping +from itertools import batched import logging -from typing import Any, override +from typing import TYPE_CHECKING, Any, override import steam.api import voluptuous as vol @@ -10,15 +11,21 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlowWithReload, + ConfigSubentryFlow, + SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) -from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, PLACEHOLDERS +from .const import CONF_ACCOUNT, DOMAIN, PLACEHOLDERS, SUBENTRY_TYPE_FRIEND from .coordinator import SteamConfigEntry _LOGGER = logging.getLogger(__name__) @@ -47,16 +54,16 @@ def validate_input(user_input: dict[str, str]) -> dict[str, str | int]: class SteamFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Steam.""" - VERSION = 2 + VERSION = 3 - @staticmethod + @classmethod @callback @override - def async_get_options_flow( - config_entry: SteamConfigEntry, - ) -> SteamOptionsFlowHandler: - """Get the options flow for this handler.""" - return SteamOptionsFlowHandler(config_entry) + def async_get_supported_subentry_types( + cls, config_entry: SteamConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {SUBENTRY_TYPE_FRIEND: FriendSubentryFlowHandler} @override async def async_step_user( @@ -67,6 +74,14 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: await self.async_set_unique_id(user_input[CONF_ACCOUNT]) self._abort_if_unique_id_configured() + + config_entries = self.hass.config_entries.async_entries(DOMAIN) + for entry in config_entries: + if user_input[CONF_ACCOUNT] in { + subentry.unique_id for subentry in entry.subentries.values() + }: + return self.async_abort(reason="already_configured_as_subentry") + try: res = await self.hass.async_add_executor_job(validate_input, user_input) if res is not None: @@ -83,11 +98,7 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown exception") errors["base"] = "unknown" if not errors: - return self.async_create_entry( - title=name, - data=user_input, - options={CONF_ACCOUNTS: {user_input[CONF_ACCOUNT]: name}}, - ) + return self.async_create_entry(title=name, data=user_input) user_input = user_input or {} return self.async_show_form( step_id="user", @@ -138,9 +149,7 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" if not errors: - return self.async_update_reload_and_abort( - entry, data_updates=user_input - ) + return self.async_update_and_abort(entry, data_updates=user_input) return self.async_show_form( step_id=( "reauth_confirm" if self.source == SOURCE_REAUTH else SOURCE_RECONFIGURE @@ -153,77 +162,119 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): ) -def _batch_ids(ids: list[str]) -> Iterator[list[str]]: - for i in range(0, len(ids), MAX_IDS_TO_REQUEST): - yield ids[i : i + MAX_IDS_TO_REQUEST] +class FriendSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding a friend.""" + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Subentry user flow.""" + errors: dict[str, str] = {} + config_entry: SteamConfigEntry = self._get_entry() -class SteamOptionsFlowHandler(OptionsFlowWithReload): - """Handle Steam client options.""" + if config_entry.state is not ConfigEntryState.LOADED: + return self.async_abort(reason="config_entry_not_loaded") - def __init__(self, entry: SteamConfigEntry) -> None: - """Initialize options flow.""" - self.options = dict(entry.options) + client = config_entry.runtime_data.user_interface + if TYPE_CHECKING: + assert config_entry.unique_id - async def async_step_init( - self, user_input: dict[str, dict[str, str]] | None = None - ) -> ConfigFlowResult: - """Manage Steam options.""" if user_input is not None: - for _id in self.options[CONF_ACCOUNTS]: - if _id not in user_input[CONF_ACCOUNTS] and ( - entity_id := er.async_get(self.hass).async_get_entity_id( - Platform.SENSOR, DOMAIN, f"{_id}_account" - ) - ): - er.async_get(self.hass).async_remove(entity_id) - channel_data = { - CONF_ACCOUNTS: { - _id: name - for _id, name in self.options[CONF_ACCOUNTS].items() - if _id in user_input[CONF_ACCOUNTS] - } - } - return self.async_create_entry(title="", data=channel_data) - error = None + config_entries = self.hass.config_entries.async_entries(DOMAIN) + if user_input[CONF_ACCOUNT] in { + entry.unique_id for entry in config_entries + }: + return self.async_abort(reason="already_configured_as_entry") + for entry in config_entries: + if user_input[CONF_ACCOUNT] in { + subentry.unique_id + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND) + }: + return self.async_abort(reason="already_configured") + + try: + title = await self.hass.async_add_executor_job( + lambda: client.GetPlayerSummaries( + steamids=[user_input[CONF_ACCOUNT]] + )["response"]["players"]["player"][0]["personaname"] + ) + except steam.api.HTTPTimeoutError: + errors["base"] = "timeout_connect" + except steam.api.HTTPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unknown exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=title, + data={}, + unique_id=user_input[CONF_ACCOUNT], + ) + + def get_accounts() -> list[dict[str, Any]]: + friends = client.GetFriendList(steamid=config_entry.unique_id)[ + "friendslist" + ]["friends"] + accounts = [] + for steamids in batched( + [friend["steamid"] for friend in friends], + MAX_IDS_TO_REQUEST, + strict=False, + ): + accounts.extend( + client.GetPlayerSummaries(steamids=list(steamids))["response"][ + "players" + ]["player"] + ) + return accounts + try: - users = { - name["steamid"]: name["personaname"] - for name in await self.hass.async_add_executor_job(self.get_accounts) - } - if not users: - error = {"base": "unauthorized"} - + accounts = await self.hass.async_add_executor_job(get_accounts) except steam.api.HTTPTimeoutError: - users = self.options[CONF_ACCOUNTS] + return self.async_abort(reason="timeout_connect") + except steam.api.HTTPError as e: + if "401" in str(e): + me = config_entry.runtime_data.data[config_entry.unique_id] + return self.async_abort( + reason="friendlist_private", + description_placeholders={ + CONF_NAME: me.personaname, + "privacy_settings_url": f"{me.profileurl}edit/settings", + }, + ) + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception("Unknown exception") + return self.async_abort(reason="unknown") - options = { - vol.Required( - CONF_ACCOUNTS, - default=set(self.options[CONF_ACCOUNTS]), - ): cv.multi_select(users | self.options[CONF_ACCOUNTS]), + existing_subentries = { + subentry.unique_id + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND) } - self.options[CONF_ACCOUNTS] = users | self.options[CONF_ACCOUNTS] + options = [ + SelectOptionDict( + value=account["steamid"], + label=account["personaname"], + ) + for account in accounts + if account["steamid"] not in existing_subentries + ] + + if not options: + return self.async_abort(reason="no_more_friends") return self.async_show_form( - step_id="init", data_schema=vol.Schema(options), errors=error + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ACCOUNT): SelectSelector( + SelectSelectorConfig(options=options, sort=True) + ) + } + ), + user_input, + ), + errors=errors, ) - - def get_accounts(self) -> list[dict[str, str | int]]: - """Get accounts.""" - interface = steam.api.interface("ISteamUser") - try: - friends = interface.GetFriendList( - steamid=self.config_entry.data[CONF_ACCOUNT] - ) - _users_str = [user["steamid"] for user in friends["friendslist"]["friends"]] - except steam.api.HTTPError: - return [] - names = [] - for id_batch in _batch_ids(_users_str): - names.extend( - interface.GetPlayerSummaries(steamids=id_batch)["response"]["players"][ - "player" - ] - ) - return names diff --git a/homeassistant/components/steam_online/const.py b/homeassistant/components/steam_online/const.py index 14654da3b765..c5f1233d3723 100644 --- a/homeassistant/components/steam_online/const.py +++ b/homeassistant/components/steam_online/const.py @@ -6,7 +6,6 @@ CONF_ACCOUNT = "account" CONF_ACCOUNTS = "accounts" DATA_KEY_COORDINATOR = "coordinator" -DEFAULT_NAME = "Steam" DOMAIN: Final = "steam_online" @@ -35,3 +34,5 @@ STEAM_API_URL = "https://steamcdn-a.akamaihd.net/steam/apps/" STEAM_HEADER_IMAGE_FILE = "header.jpg" STEAM_MAIN_IMAGE_FILE = "capsule_616x353.jpg" STEAM_ICON_URL = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/" + +SUBENTRY_TYPE_FRIEND = "friend" diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 7c3df2b82c4a..bc929103df13 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import timedelta import logging -from typing import override +from typing import TYPE_CHECKING, override import steam.api @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_ACCOUNTS, DOMAIN +from .const import DOMAIN, SUBENTRY_TYPE_FRIEND type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] @@ -45,6 +45,7 @@ class PlayerData: loccityid: int | None = None gameextrainfo: str | None = None gameid: str | None = None + lobbysteamid: str | None = None level: int | None = None @@ -76,8 +77,16 @@ class SteamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PlayerData]]): def _update(self) -> dict[str, PlayerData]: """Fetch data from API endpoint.""" - accounts = self.config_entry.options[CONF_ACCOUNTS] - _ids = list(accounts) + if TYPE_CHECKING: + assert self.config_entry.unique_id + _ids = [self.config_entry.unique_id] + _ids.extend( + subentry.unique_id + for subentry in self.config_entry.get_subentries_of_type( + SUBENTRY_TYPE_FRIEND + ) + if subentry.unique_id + ) response = self.user_interface.GetPlayerSummaries(steamids=_ids) players = { diff --git a/homeassistant/components/steam_online/entity.py b/homeassistant/components/steam_online/entity.py index be8eebde909b..2c2732ebfa7a 100644 --- a/homeassistant/components/steam_online/entity.py +++ b/homeassistant/components/steam_online/entity.py @@ -4,7 +4,7 @@ from homeassistant.components.sensor import SensorEntityDescription from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN from .coordinator import SteamDataUpdateCoordinator @@ -25,9 +25,10 @@ class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]): self.entity_description = description self._attr_unique_id = f"{steamid}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url="https://store.steampowered.com", + configuration_url=str(coordinator.data[steamid].profileurl), entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - manufacturer=DEFAULT_NAME, - name=DEFAULT_NAME, + identifiers={(DOMAIN, steamid)}, + model="Steam", + manufacturer="Valve", + name=str(coordinator.data[steamid].personaname), ) diff --git a/homeassistant/components/steam_online/icons.json b/homeassistant/components/steam_online/icons.json index f2a9deb155c2..9a62815c2bbc 100644 --- a/homeassistant/components/steam_online/icons.json +++ b/homeassistant/components/steam_online/icons.json @@ -3,6 +3,19 @@ "sensor": { "account": { "default": "mdi:steam" + }, + "last_online": { + "default": "mdi:account-clock" + }, + "level": { + "default": "mdi:trophy-award" + }, + "now_playing": { + "default": "mdi:controller", + "state": { + "unavailable": "mdi:controller-off", + "unknown": "mdi:controller-off" + } } } } diff --git a/homeassistant/components/steam_online/manifest.json b/homeassistant/components/steam_online/manifest.json index 9da1f3f3c232..6c5210f8f17d 100644 --- a/homeassistant/components/steam_online/manifest.json +++ b/homeassistant/components/steam_online/manifest.json @@ -1,7 +1,7 @@ { "domain": "steam_online", "name": "Steam", - "codeowners": ["@tkdrob"], + "codeowners": ["@tr4nt0r", "@tkdrob"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/steam_online", "integration_type": "service", diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 7749c3a5b29b..45c1cdac4de0 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -1,26 +1,31 @@ """Sensor for Steam account status.""" -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime from enum import StrEnum -from typing import Any, override +from typing import TYPE_CHECKING, Any, override -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( - CONF_ACCOUNTS, STEAM_API_URL, STEAM_HEADER_IMAGE_FILE, STEAM_ICON_URL, STEAM_MAIN_IMAGE_FILE, STEAM_STATUSES, + SUBENTRY_TYPE_FRIEND, ) -from .coordinator import PlayerData, SteamConfigEntry, SteamDataUpdateCoordinator +from .coordinator import PlayerData, SteamConfigEntry from .entity import SteamEntity PARALLEL_UPDATES = 1 @@ -30,15 +35,20 @@ class SteamSensor(StrEnum): """Steam sensors.""" ACCOUNT = "account" + LAST_ONLINE = "last_online" + NOW_PLAYING = "now_playing" + LEVEL = "level" @dataclass(kw_only=True, frozen=True) class SteamSensorEntityDescription(SensorEntityDescription): """Steam sensor description.""" - value_fn: Callable[[PlayerData], StateType] - name_fn: Callable[[PlayerData], str] - entity_picture_fn: Callable[[PlayerData], str] | None = None + value_fn: Callable[[PlayerData], StateType | datetime] + entity_picture_fn: Callable[[PlayerData, dict[str, str]], str | None] | None = None + extra_state_attributes_fn: ( + Callable[[PlayerData, dict[str, str]], Mapping[str, Any]] | None + ) = None SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = ( @@ -46,8 +56,62 @@ SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = ( key=SteamSensor.ACCOUNT, translation_key=SteamSensor.ACCOUNT, value_fn=lambda x: STEAM_STATUSES[x.personastate], - name_fn=lambda x: x.personaname, - entity_picture_fn=lambda x: x.avatarfull, + device_class=SensorDeviceClass.ENUM, + options=list(STEAM_STATUSES.values()), + entity_picture_fn=lambda x, _: x.avatarfull, + name=None, + # Attributes game, game_id, game_image_header, game_image_main, game_icon, + # last_online, and level are deprecated and can be removed in 2027.2 + extra_state_attributes_fn=lambda x, icons: { + "real_name": x.realname, + "created": ( + dt_util.as_local(dt_util.utc_from_timestamp(x.timecreated)) + if x.timecreated is not None + else None + ), + "game": x.gameextrainfo, + "game_id": x.gameid, + "game_image_header": ( + f"{STEAM_API_URL}{x.gameid}/{STEAM_HEADER_IMAGE_FILE}" + if x.gameid is not None + else None + ), + "game_image_main": ( + f"{STEAM_API_URL}{x.gameid}/{STEAM_MAIN_IMAGE_FILE}" + if x.gameid is not None + else None + ), + "game_icon": ( + f"{STEAM_ICON_URL}{x.gameid}/{info}.jpg" + if x.gameid is not None and (info := icons.get(x.gameid)) is not None + else None + ), + "last_online": dt_util.utc_from_timestamp(x.lastlogoff), + "level": x.level, + }, + ), + SteamSensorEntityDescription( + key=SteamSensor.LAST_ONLINE, + translation_key=SteamSensor.LAST_ONLINE, + value_fn=(lambda x: dt_util.utc_from_timestamp(x.lastlogoff)), + device_class=SensorDeviceClass.TIMESTAMP, + ), + SteamSensorEntityDescription( + key=SteamSensor.NOW_PLAYING, + translation_key=SteamSensor.NOW_PLAYING, + value_fn=lambda x: x.gameextrainfo, + entity_picture_fn=lambda x, icons: ( + f"{STEAM_ICON_URL}{x.gameid}/{game_icon_url}.jpg" + if x.gameid and (game_icon_url := icons.get(x.gameid)) + else None + ), + extra_state_attributes_fn=lambda x, _: {"app_id": x.gameid}, + ), + SteamSensorEntityDescription( + key=SteamSensor.LEVEL, + translation_key=SteamSensor.LEVEL, + value_fn=lambda x: x.level, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -59,33 +123,33 @@ async def async_setup_entry( ) -> None: """Set up the Steam platform.""" coordinator = entry.runtime_data - + if TYPE_CHECKING: + assert entry.unique_id async_add_entities( - SteamSensorEntity(coordinator, steamid, description) - for steamid in entry.options[CONF_ACCOUNTS] + SteamSensorEntity(coordinator, entry.unique_id, description) for description in SENSOR_DESCRIPTIONS - if steamid in coordinator.data + if entry.unique_id in coordinator.data ) + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND): + async_add_entities( + [ + SteamSensorEntity(coordinator, subentry.unique_id, description) + for description in SENSOR_DESCRIPTIONS + if subentry.unique_id in coordinator.data + ], + config_subentry_id=subentry.subentry_id, + ) + class SteamSensorEntity(SteamEntity, SensorEntity): """Representation of a Steam sensor entity.""" entity_description: SteamSensorEntityDescription - def __init__( - self, - coordinator: SteamDataUpdateCoordinator, - steamid: str, - description: SteamSensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, steamid, description) - self._attr_name = self.entity_description.name_fn(coordinator.data[steamid]) - @property @override - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data[self._steamid]) @@ -94,40 +158,20 @@ class SteamSensorEntity(SteamEntity, SensorEntity): def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" return ( - fn(self.coordinator.data[self._steamid]) + fn(self.coordinator.data[self._steamid], self.coordinator.game_icons) if (fn := self.entity_description.entity_picture_fn) is not None else super().entity_picture ) @property @override - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of the sensor.""" - player = self.coordinator.data[self._steamid] - - attrs: dict[str, str | int | datetime] = {} - if game := player.gameextrainfo: - attrs["game"] = game - if game_id := player.gameid: - attrs["game_id"] = game_id - game_url = f"{STEAM_API_URL}{player.gameid}/" - attrs["game_image_header"] = f"{game_url}{STEAM_HEADER_IMAGE_FILE}" - attrs["game_image_main"] = f"{game_url}{STEAM_MAIN_IMAGE_FILE}" - if info := self._get_game_icon(player): - attrs["game_icon"] = f"{STEAM_ICON_URL}{game_id}/{info}.jpg" - if last_online := player.lastlogoff: - attrs["last_online"] = dt_util.as_local( - dt_util.utc_from_timestamp(last_online) - ) - if level := self.coordinator.data[self._steamid].level: - attrs["level"] = level - return attrs - - def _get_game_icon(self, player: PlayerData) -> str | None: - """Get game icon identifier.""" - if player.gameid is not None and player.gameid in self.coordinator.game_icons: - return self.coordinator.game_icons[player.gameid] - return None + return ( + fn(self.coordinator.data[self._steamid], self.coordinator.game_icons) + if (fn := self.entity_description.extra_state_attributes_fn) is not None + else super().extra_state_attributes + ) @property @override diff --git a/homeassistant/components/steam_online/strings.json b/homeassistant/components/steam_online/strings.json index 4d51800a505e..fdb497f21fea 100644 --- a/homeassistant/components/steam_online/strings.json +++ b/homeassistant/components/steam_online/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_configured_as_subentry": "This Steam account is already configured as a sub-entry.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, @@ -46,6 +47,41 @@ } } }, + "config_subentries": { + "friend": { + "abort": { + "already_configured": "Already configured as a friend in this or another account.", + "already_configured_as_entry": "This account is already configured as a service and cannot be added as a friend.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "config_entry_not_loaded": "Cannot add friend accounts when the main account is disabled or not loaded.", + "friendlist_private": "Your friend list appears to be private or inaccessible.\n\nTo add friend accounts open Steam and go to [**{name} > Profile > Edit Profile > Privacy Settings**]({privacy_settings_url}) and set **Friends List** to **Public**.\n\nOnce your friends are added, you can switch it back to your preferred privacy setting.", + "no_more_friends": "All friends from your friend list have already been added.", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "entry_type": "Friend", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "Add friend" + }, + "step": { + "user": { + "data": { + "account": "Friend" + }, + "data_description": { + "account": "Select a friend from your friend list to track their online status." + }, + "description": "Track the online status of a Steam friend.", + "title": "Friend online status" + } + } + } + }, "entity": { "sensor": { "account": { @@ -59,13 +95,27 @@ "snooze": "Snooze" }, "state_attributes": { + "created": { "name": "Account created" }, "game": { "name": "Game" }, "game_icon": { "name": "Game icon" }, "game_id": { "name": "Game ID" }, "game_image_header": { "name": "Game header image" }, "game_image_main": { "name": "Game image" }, "last_online": { "name": "Last online" }, - "level": { "name": "Level" } + "level": { "name": "Level" }, + "real_name": { "name": "Real name" } + } + }, + "last_online": { + "name": "Last online" + }, + "level": { + "name": "Level" + }, + "now_playing": { + "name": "Now playing", + "state_attributes": { + "app_id": { "name": "Steam App ID" } } } } @@ -80,17 +130,5 @@ "timeout_exception": { "message": "Failed to connect to Steam due to a request timeout" } - }, - "options": { - "error": { - "unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends" - }, - "step": { - "init": { - "data": { - "accounts": "Names of accounts to be monitored" - } - } - } } } diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index 92904ec5fd1f..5b96db9e2a3f 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -1,4 +1,5 @@ """The Steamist integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import timedelta from typing import Any diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 7802bb954b7b..41933a733e42 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -2,14 +2,15 @@ import logging -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusError +from modbus_connection.pymodbus import connect_tcp from pystiebeleltron import StiebelEltronModbusError, get_controller_model from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady -from .const import DEFAULT_PORT +from .const import DEFAULT_PORT, UNIT_ID from .coordinator import StiebelEltronConfigEntry, StiebelEltronDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -25,17 +26,27 @@ async def async_setup_entry( port = entry.data.get(CONF_PORT, DEFAULT_PORT) try: - model = await get_controller_model(host, port) - except ModbusException as exception: + connection = await connect_tcp(host, port=port) + except ModbusError as exception: raise ConfigEntryNotReady("Could not connect to device") from exception - except StiebelEltronModbusError as exception: - raise ConfigEntryError(exception) from exception + entry.async_on_unload(connection.close) - coordinator = StiebelEltronDataCoordinator(hass, entry, model, host, port) + try: + model = await get_controller_model(connection.for_unit(UNIT_ID)) + except StiebelEltronModbusError as exception: + raise ConfigEntryNotReady("Could not read controller model") from exception + + coordinator = StiebelEltronDataCoordinator(hass, entry, model, connection, host) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() + entry.async_on_unload( + connection.on_connection_lost( + lambda: hass.config_entries.async_schedule_reload(entry.entry_id) + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True @@ -45,6 +56,4 @@ async def async_unload_entry( entry: StiebelEltronConfigEntry, ) -> bool: """Unload a config entry.""" - coordinator = entry.runtime_data - await coordinator.close() return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py index 1f1b3ab4c8e1..1833ffc8d8b0 100644 --- a/homeassistant/components/stiebel_eltron/climate.py +++ b/homeassistant/components/stiebel_eltron/climate.py @@ -3,7 +3,7 @@ import logging from typing import Any, override -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusError from pystiebeleltron.lwz import OperatingMode from homeassistant.components.climate import ( @@ -17,13 +17,15 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import StiebelEltronConfigEntry from .coordinator import StiebelEltronDataCoordinator +from .entity import StiebelEltronEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + CLIMATE_HK_1 = "climate_hk_1" # Mapping STIEBEL ELTRON states to homeassistant states/preset. @@ -79,13 +81,12 @@ async def async_setup_entry( ) -> None: """Set up STIEBEL ELTRON climate platform.""" - async_add_entities([StiebelEltron(entry.entry_id, entry.runtime_data)]) + async_add_entities([StiebelEltron(entry.runtime_data)]) -class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEntity): +class StiebelEltron(StiebelEltronEntity, ClimateEntity): """Representation of a STIEBEL ELTRON heat pump.""" - _attr_has_entity_name = True _attr_name = None _attr_hvac_modes = list(HA_TO_LWZ_HVAC) _attr_preset_modes = list(HA_TO_LWZ_PRESET) @@ -100,14 +101,12 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti _attr_min_temp = 10.0 _attr_max_temp = 30.0 - def __init__( - self, unique_id: str, coordinator: StiebelEltronDataCoordinator - ) -> None: + def __init__(self, coordinator: StiebelEltronDataCoordinator) -> None: """Initialize the unit.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{unique_id}-{CLIMATE_HK_1}" - # Initialize runtime attributes to avoid attribute errors + assert coordinator.config_entry is not None + super().__init__( + coordinator, f"{coordinator.config_entry.entry_id}-{CLIMATE_HK_1}" + ) self._set_attr() @override @@ -132,13 +131,11 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti @override async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - if self.preset_mode: - return new_mode = HA_TO_LWZ_HVAC[hvac_mode] _LOGGER.debug("async_set_hvac_mode: %s -> %s", self._attr_hvac_mode, new_mode) try: await self.coordinator.api_client.set_operation(new_mode) - except ModbusException as e: + except ModbusError as e: _LOGGER.error("Error setting HVAC mode: %s", e) raise HomeAssistantError("Failed to set HVAC mode") from e await self.coordinator.async_request_refresh() @@ -146,12 +143,11 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (target_temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - raise HomeAssistantError("target temperature must be provided") + target_temperature = kwargs[ATTR_TEMPERATURE] _LOGGER.debug("async_set_temperature: %s", target_temperature) try: await self.coordinator.api_client.set_target_temp(target_temperature) - except ModbusException as e: + except ModbusError as e: _LOGGER.error("Error setting target temperature: %s", e) raise HomeAssistantError("Failed to set target temperature") from e await self.coordinator.async_request_refresh() @@ -165,7 +161,7 @@ class StiebelEltron(CoordinatorEntity[StiebelEltronDataCoordinator], ClimateEnti ) try: await self.coordinator.api_client.set_operation(new_preset) - except ModbusException as e: + except ModbusError as e: _LOGGER.error("Error setting preset mode: %s", e) raise HomeAssistantError("Failed to set preset mode") from e await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/stiebel_eltron/config_flow.py b/homeassistant/components/stiebel_eltron/config_flow.py index ff3761e62e7f..0bbe59b0a5ee 100644 --- a/homeassistant/components/stiebel_eltron/config_flow.py +++ b/homeassistant/components/stiebel_eltron/config_flow.py @@ -3,22 +3,48 @@ import logging from typing import Any, override +from modbus_connection import ModbusError +from modbus_connection.pymodbus import connect_tcp from pystiebeleltron import StiebelEltronModbusError, get_controller_model import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DEFAULT_PORT, DOMAIN +from .const import DEFAULT_PORT, DOMAIN, UNIT_ID _LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, max=65535, mode=NumberSelectorMode.BOX) + ), + vol.Coerce(int), + ), + } +) + async def check_controller_model(host: str, port: int) -> str | None: """Check if the controller model is valid.""" try: - await get_controller_model(host, port) - except StiebelEltronModbusError: + connection = await connect_tcp(host, port=port) + try: + await get_controller_model(connection.for_unit(UNIT_ID)) + finally: + await connection.close() + except StiebelEltronModbusError, ModbusError: _LOGGER.debug("Cannot connect to Stiebel Eltron device", exc_info=True) return "cannot_connect" except Exception: @@ -32,6 +58,41 @@ class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _discovered_host: str + + @override + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + + error = await check_controller_model(discovery_info.ip, DEFAULT_PORT) + if error is not None: + return self.async_abort(reason=error) + + self._discovered_host = discovery_info.ip + self.context["title_placeholders"] = {CONF_HOST: discovery_info.ip} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow the user to confirm adding the discovered device.""" + if user_input is not None: + return self.async_create_entry( + title="Stiebel Eltron", + data={CONF_HOST: self._discovered_host, CONF_PORT: DEFAULT_PORT}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={CONF_HOST: self._discovered_host}, + ) + @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -52,11 +113,39 @@ class StiebelEltronConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): int, - } + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow.""" + config_entry = self._get_reconfigure_entry() + + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + error = await check_controller_model( + user_input[CONF_HOST], user_input[CONF_PORT] + ) + if error is not None: + errors["base"] = error + else: + return self.async_update_reload_and_abort( + config_entry, + data_updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, config_entry.data ), errors=errors, ) diff --git a/homeassistant/components/stiebel_eltron/const.py b/homeassistant/components/stiebel_eltron/const.py index 6b6965dd244e..9522a541c31d 100644 --- a/homeassistant/components/stiebel_eltron/const.py +++ b/homeassistant/components/stiebel_eltron/const.py @@ -5,3 +5,4 @@ DOMAIN = "stiebel_eltron" DEFAULT_PORT = 502 DEFAULT_SCAN_INTERVAL = 30 +UNIT_ID = 1 diff --git a/homeassistant/components/stiebel_eltron/coordinator.py b/homeassistant/components/stiebel_eltron/coordinator.py index 4b2fda2ce817..8e1f6dde65b4 100644 --- a/homeassistant/components/stiebel_eltron/coordinator.py +++ b/homeassistant/components/stiebel_eltron/coordinator.py @@ -4,7 +4,7 @@ from datetime import timedelta import logging from typing import override -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusConnection, ModbusError from pystiebeleltron import ControllerModel from pystiebeleltron.lwz import LwzStiebelEltronAPI @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN, UNIT_ID _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -28,8 +28,8 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]): hass: HomeAssistant, entry: StiebelEltronConfigEntry, model: ControllerModel, + connection: ModbusConnection, host: str, - port: int, ) -> None: """Initialize the StiebelEltronDataCoordinator.""" super().__init__( @@ -42,50 +42,20 @@ class StiebelEltronDataCoordinator(DataUpdateCoordinator[None]): # the register values), so there is nothing to diff against. always_update=True, ) - self._model = model - self.api_client = LwzStiebelEltronAPI(host=host, port=port) + self.api_client = LwzStiebelEltronAPI(connection.for_unit(UNIT_ID)) self.device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, - configuration_url=f"http://{self.host}", + configuration_url=f"http://{host}", name=self.name, model=model.name, model_id=str(model.value), manufacturer=ATTR_MANUFACTURER, ) - async def close(self) -> None: - """Disconnect client.""" - _LOGGER.debug("Closing connection to %s", self.host) - await self.api_client.close() - - async def connect(self) -> None: - """Connect client.""" - _LOGGER.debug("Connecting to %s", self.host) - await self.api_client.connect() - - @property - def is_connected(self) -> bool: - """Check modbus client connection status.""" - if self.api_client is None: - return False - return self.api_client.is_connected - - @property - def host(self) -> str: - """Return the host address of the Stiebel Eltron ISG.""" - return self.api_client.host - - @property - def model(self) -> str: - """Return the controller model name of the Stiebel Eltron ISG.""" - return self._model.name - @override async def _async_update_data(self) -> None: """Fetch the latest data from the source.""" try: - if not self.api_client.is_connected: - await self.api_client.connect() await self.api_client.async_update() - except ModbusException as exception: + except ModbusError as exception: raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/stiebel_eltron/diagnostics.py b/homeassistant/components/stiebel_eltron/diagnostics.py new file mode 100644 index 000000000000..5267286d830c --- /dev/null +++ b/homeassistant/components/stiebel_eltron/diagnostics.py @@ -0,0 +1,32 @@ +"""Diagnostics support for STIEBEL ELTRON.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from . import StiebelEltronConfigEntry + +TO_REDACT = {CONF_HOST} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: StiebelEltronConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "model": coordinator.device_info["model"], + "data": { + "current_temp": coordinator.api_client.get_current_temp(), + "target_temp": coordinator.api_client.get_target_temp(), + "current_humidity": coordinator.api_client.get_current_humidity(), + "operating_mode": coordinator.api_client.get_operation().name, + "heating_status": coordinator.api_client.get_heating_status(), + "cooling_status": coordinator.api_client.get_cooling_status(), + "filter_alarm": coordinator.api_client.get_filter_alarm_status(), + }, + } diff --git a/homeassistant/components/stiebel_eltron/entity.py b/homeassistant/components/stiebel_eltron/entity.py new file mode 100644 index 000000000000..63fc2a96062d --- /dev/null +++ b/homeassistant/components/stiebel_eltron/entity.py @@ -0,0 +1,19 @@ +"""Base entity for the STIEBEL ELTRON integration.""" + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import StiebelEltronDataCoordinator + + +class StiebelEltronEntity(CoordinatorEntity[StiebelEltronDataCoordinator]): + """Base class for STIEBEL ELTRON entities.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: StiebelEltronDataCoordinator, unique_id: str + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + self._attr_unique_id = unique_id diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json index f3ff88e0e2b7..f490c4622d66 100644 --- a/homeassistant/components/stiebel_eltron/manifest.json +++ b/homeassistant/components/stiebel_eltron/manifest.json @@ -3,9 +3,15 @@ "name": "STIEBEL ELTRON", "codeowners": ["@fucm", "@ThyMYthOS"], "config_flow": true, + "dhcp": [ + { + "hostname": "servicewelt*" + } + ], "documentation": "https://www.home-assistant.io/integrations/stiebel_eltron", "integration_type": "device", "iot_class": "local_polling", "loggers": ["pymodbus", "pystiebeleltron"], - "requirements": ["pystiebeleltron==0.2.5"] + "quality_scale": "silver", + "requirements": ["pystiebeleltron==0.5.0"] } diff --git a/homeassistant/components/stiebel_eltron/quality_scale.yaml b/homeassistant/components/stiebel_eltron/quality_scale.yaml new file mode 100644 index 000000000000..ca4e6a6579d3 --- /dev/null +++ b/homeassistant/components/stiebel_eltron/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom 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 register custom actions. + docs-conditions: + status: exempt + comment: Integration does not register custom conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not register custom triggers. + 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: + status: exempt + comment: 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: + status: exempt + comment: Integration does not require reauthentication. + test-coverage: done + + # 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: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Integration does not use web sessions. + strict-typing: todo diff --git a/homeassistant/components/stiebel_eltron/strings.json b/homeassistant/components/stiebel_eltron/strings.json index aa40b754a6d6..e10233150821 100644 --- a/homeassistant/components/stiebel_eltron/strings.json +++ b/homeassistant/components/stiebel_eltron/strings.json @@ -3,13 +3,28 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, + "flow_title": "STIEBEL ELTRON ({host})", "step": { + "discovery_confirm": { + "description": "Do you want to set up the STIEBEL ELTRON heat pump at {host}?" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::stiebel_eltron::config::step::user::data_description::host%]", + "port": "[%key:component::stiebel_eltron::config::step::user::data_description::port%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index efbf973476bc..2279c14029b1 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -1,33 +1,29 @@ """Support for Streamlabs Water Monitor devices.""" from streamlabswater.streamlabswater import StreamlabsClient -import voluptuous as vol from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import StreamlabsConfigEntry, StreamlabsCoordinator - -ATTR_AWAY_MODE = "away_mode" -SERVICE_SET_AWAY_MODE = "set_away_mode" -AWAY_MODE_AWAY = "away" -AWAY_MODE_HOME = "home" - -CONF_LOCATION_ID = "location_id" +from .services import async_setup_services ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=streamlabswater"} -SET_AWAY_MODE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), - vol.Optional(CONF_LOCATION_ID): cv.string, - } -) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the integration.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) -> bool: """Set up StreamLabs from a config entry.""" @@ -41,17 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: StreamlabsConfigEntry) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - def set_away_mode(service: ServiceCall) -> None: - """Set the StreamLabsWater Away Mode.""" - away_mode = service.data.get(ATTR_AWAY_MODE) - location_id = service.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0] - client.update_location(location_id, away_mode) - - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA - ) - return True diff --git a/homeassistant/components/streamlabswater/services.py b/homeassistant/components/streamlabswater/services.py new file mode 100644 index 000000000000..f015f6365cf7 --- /dev/null +++ b/homeassistant/components/streamlabswater/services.py @@ -0,0 +1,44 @@ +"""Services for Streamlabs Water.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN +from .coordinator import StreamlabsConfigEntry + +ATTR_AWAY_MODE = "away_mode" +SERVICE_SET_AWAY_MODE = "set_away_mode" +AWAY_MODE_AWAY = "away" +AWAY_MODE_HOME = "home" + +CONF_LOCATION_ID = "location_id" + +SET_AWAY_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), + vol.Optional(CONF_LOCATION_ID): cv.string, + } +) + + +def set_away_mode(call: ServiceCall) -> None: + """Set the StreamLabsWater Away Mode.""" + entry: StreamlabsConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, None + ) + coordinator = entry.runtime_data + coordinator.client.update_location( + call.data.get(CONF_LOCATION_ID) or list(coordinator.data)[0], + call.data[ATTR_AWAY_MODE], + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + + hass.services.async_register( + DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA + ) diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py index 24ea65eb465d..2e77211698ce 100644 --- a/homeassistant/components/subaru/button.py +++ b/homeassistant/components/subaru/button.py @@ -10,15 +10,14 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_device_info from .const import ( SERVICE_REMOTE_START, SERVICE_REMOTE_STOP, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_START, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruEntity from .remote_service import async_call_remote_service @@ -59,10 +58,9 @@ async def async_setup_entry( ) -class SubaruButton(ButtonEntity): +class SubaruButton(SubaruEntity, ButtonEntity): """Class for a Subaru button.""" - _attr_has_entity_name = True entity_description: SubaruButtonEntityDescription def __init__( @@ -73,13 +71,10 @@ class SubaruButton(ButtonEntity): description: SubaruButtonEntityDescription, ) -> None: """Initialize the button for the vehicle.""" + super().__init__(vehicle_info, description.key) self.controller = controller self.coordinator = coordinator - self.vehicle_info = vehicle_info self.entity_description = description - vin = vehicle_info[VEHICLE_VIN] - self._attr_unique_id = f"{vin}_{description.key}" - self._attr_device_info = get_device_info(vehicle_info) @override async def async_press(self) -> None: diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 53148f9a00ad..d1a1662d6474 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -24,6 +24,14 @@ VEHICLE_HAS_REMOTE_SERVICE = "has_remote" VEHICLE_HAS_SAFETY_SERVICE = "has_safety" VEHICLE_LAST_UPDATE = "last_update" VEHICLE_STATUS = "vehicle_status" +VEHICLE_HEALTH = "vehicle_health" + +# Synthetic keys for sensors that don't read a single field directly; used +# as both unique_id suffix and translation_key, so they must stay stable +# across releases (changing them would orphan existing entity registry +# entries). +KEY_RECOMMENDED_TIRE_PRESSURE_FRONT = "recommended_tire_pressure_front" +KEY_RECOMMENDED_TIRE_PRESSURE_REAR = "recommended_tire_pressure_rear" API_GEN_1 = "g1" diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index 9ea7929b5dcf..f31ac633893b 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -7,11 +7,10 @@ from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import get_device_info -from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS, VEHICLE_VIN +from .const import VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_STATUS from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruCoordinatorEntity async def async_setup_entry( @@ -29,23 +28,17 @@ async def async_setup_entry( ) -class SubaruDeviceTracker( - CoordinatorEntity[SubaruDataUpdateCoordinator], TrackerEntity -): +class SubaruDeviceTracker(SubaruCoordinatorEntity, TrackerEntity): """Class for Subaru device tracker.""" _attr_translation_key = "location" - _attr_has_entity_name = True _attr_name = None def __init__( self, vehicle_info: dict, coordinator: SubaruDataUpdateCoordinator ) -> None: """Initialize the device tracker.""" - super().__init__(coordinator) - self.vin = vehicle_info[VEHICLE_VIN] - self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_location" + super().__init__(vehicle_info, coordinator, "location") @property @override @@ -72,8 +65,8 @@ class SubaruDeviceTracker( @property @override def available(self) -> bool: - """Return if entity is available.""" - if vehicle_data := self.coordinator.data.get(self.vin): - if status := vehicle_data.get(VEHICLE_STATUS): - return status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP} - return False + """Return if available; not gated on last_update_success, only on the relevant status keys being present.""" + if not (vehicle_data := (self.coordinator.data or {}).get(self.vin)): + return False + status = vehicle_data.get(VEHICLE_STATUS) or {} + return bool(status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP}) diff --git a/homeassistant/components/subaru/entity.py b/homeassistant/components/subaru/entity.py new file mode 100644 index 000000000000..a9e4ff154615 --- /dev/null +++ b/homeassistant/components/subaru/entity.py @@ -0,0 +1,45 @@ +"""Base entities for the Subaru integration.""" + +from typing import Any, override + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import get_device_info +from .const import VEHICLE_VIN +from .coordinator import SubaruDataUpdateCoordinator + + +class SubaruEntity(Entity): + """Base class for Subaru entities: device_info, unique_id, has_entity_name.""" + + _attr_has_entity_name = True + + def __init__(self, vehicle_info: dict[str, Any], unique_id_suffix: str) -> None: + """Initialize the entity from the vehicle_info dict.""" + self.vehicle_info = vehicle_info + self.vin: str = vehicle_info[VEHICLE_VIN] + self._attr_device_info = get_device_info(vehicle_info) + self._attr_unique_id = f"{self.vin}_{unique_id_suffix}" + + +class SubaruCoordinatorEntity( + CoordinatorEntity[SubaruDataUpdateCoordinator], SubaruEntity +): + """Base class for coordinator-backed Subaru entities.""" + + def __init__( + self, + vehicle_info: dict[str, Any], + coordinator: SubaruDataUpdateCoordinator, + unique_id_suffix: str, + ) -> None: + """Initialize the coordinator-backed entity.""" + super().__init__(coordinator) + SubaruEntity.__init__(self, vehicle_info, unique_id_suffix) + + @property + @override + def available(self) -> bool: + """Return if available; also gates on data for this vehicle being present.""" + return super().available and self.vin in self.coordinator.data diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 62547ee51e5b..362e3ebe4c3d 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_device_info from .const import ( ATTR_DOOR, SERVICE_UNLOCK_SPECIFIC_DOOR, @@ -19,9 +18,9 @@ from .const import ( UNLOCK_VALID_DOORS, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_NAME, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry +from .entity import SubaruEntity from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,7 @@ async def async_setup_entry( ) -class SubaruLock(LockEntity): +class SubaruLock(SubaruEntity, LockEntity): """Representation of a Subaru door lock. Note that the Subaru API currently does not support @@ -58,17 +57,13 @@ class SubaruLock(LockEntity): always unknown. """ - _attr_has_entity_name = True _attr_translation_key = "door_locks" def __init__(self, vehicle_info, controller): """Initialize the locks for the vehicle.""" + super().__init__(vehicle_info, "door_locks") self.controller = controller - self.vehicle_info = vehicle_info - vin = vehicle_info[VEHICLE_VIN] self.car_name = vehicle_info[VEHICLE_NAME] - self._attr_unique_id = f"{vin}_door_locks" - self._attr_device_info = get_device_info(vehicle_info) @override async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index ccf99fc46500..4fff8efb10ba 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -1,5 +1,9 @@ """Support for Subaru sensors.""" +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal import logging from typing import Any, override @@ -12,25 +16,33 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfPressure, UnitOfVolume +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfLength, + UnitOfPressure, + UnitOfVolume, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.typing import StateType from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter from homeassistant.util.unit_system import METRIC_SYSTEM -from . import get_device_info from .const import ( API_GEN_2, API_GEN_3, API_GEN_4, + KEY_RECOMMENDED_TIRE_PRESSURE_FRONT, + KEY_RECOMMENDED_TIRE_PRESSURE_REAR, VEHICLE_API_GEN, VEHICLE_HAS_EV, + VEHICLE_HEALTH, VEHICLE_STATUS, - VEHICLE_VIN, ) from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .entity import SubaruCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -42,9 +54,56 @@ FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal" L_PER_GAL = VolumeConverter.convert(1, UnitOfVolume.GALLONS, UnitOfVolume.LITERS) KM_PER_MI = DistanceConverter.convert(1, UnitOfLength.MILES, UnitOfLength.KILOMETERS) +# Readable aliases for subarulink field-key constants. +API_KEY_VEHICLE_STATE_TYPE = sc.VEHICLE_STATE +API_KEY_RECOMMENDED_TIRE_PRESSURE = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE +API_KEY_FRONT_TIRES = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE_FRONT +API_KEY_REAR_TIRES = sc.HEALTH_RECOMMENDED_TIRE_PRESSURE_REAR + + +@dataclass(frozen=True, kw_only=True) +class SubaruSensorEntityDescription(SensorEntityDescription): + """Describes a Subaru sensor entity.""" + + value_fn: Callable[[dict[str, Any]], StateType] | None = None + + +def _recommended_tire_pressure(axle: str) -> Callable[[dict[str, Any]], StateType]: + """Return a getter for recommended FRONT or REAR axle tire pressure from vehicle_health.""" + + def getter(data: dict[str, Any]) -> StateType: + health = data.get(VEHICLE_HEALTH) or {} + recommended = health.get(API_KEY_RECOMMENDED_TIRE_PRESSURE) or {} + return recommended.get(axle) + + return getter + + +# Snake-case ENUM options for vehicle_state. Authoritative values confirmed by +# the integration codeowner against the Subaru Android app source (see PR +# #174054 discussion_r3488335137 and subarulink PR G-Two/subarulink#121). +# IGN-ACC, IGN-ON, and ENGINE_ON_REMOTE_START are not yet exported as +# `sc.*` constants in the released subarulink; the literal strings will be +# replaced once the next subarulink pin lands. +VEHICLE_STATE_OPTIONS = { + sc.IGNITION_OFF: "ignition_off", + "IGN-ACC": "ignition_acc", + "IGN-ON": "ignition_on", + "ENGINE_ON_REMOTE_START": "engine_on_remote_start", +} + + +def _vehicle_state_enum(data: dict[str, Any]) -> StateType: + """Map the raw VEHICLE_STATE_TYPE to a snake_case ENUM option (unmapped → None → `unknown`).""" + raw = (data.get(VEHICLE_STATUS) or {}).get(API_KEY_VEHICLE_STATE_TYPE) + if raw is None: + return None + return VEHICLE_STATE_OPTIONS.get(raw) + + # Sensor available for Gen1 or Gen2 vehicles SAFETY_SENSORS = [ - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.ODOMETER, translation_key="odometer", device_class=SensorDeviceClass.DISTANCE, @@ -55,52 +114,78 @@ SAFETY_SENSORS = [ # Sensors available to subscribers with Gen2/Gen3 vehicles API_GEN_2_SENSORS = [ - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, translation_key="average_fuel_consumption", native_unit_of_measurement=FUEL_CONSUMPTION_MILES_PER_GALLON, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.DIST_TO_EMPTY, translation_key="range", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.TIRE_PRESSURE_FL, translation_key="tire_pressure_front_left", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.TIRE_PRESSURE_FR, translation_key="tire_pressure_front_right", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.TIRE_PRESSURE_RL, translation_key="tire_pressure_rear_left", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.TIRE_PRESSURE_RR, translation_key="tire_pressure_rear_right", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), + SubaruSensorEntityDescription( + key=API_KEY_VEHICLE_STATE_TYPE, + translation_key="vehicle_state", + device_class=SensorDeviceClass.ENUM, + options=sorted(VEHICLE_STATE_OPTIONS.values()), + value_fn=_vehicle_state_enum, + ), + # Static manufacturer reference value, not a live measurement; no state_class. + SubaruSensorEntityDescription( + key=KEY_RECOMMENDED_TIRE_PRESSURE_FRONT, + translation_key=KEY_RECOMMENDED_TIRE_PRESSURE_FRONT, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=_recommended_tire_pressure(API_KEY_FRONT_TIRES), + ), + SubaruSensorEntityDescription( + key=KEY_RECOMMENDED_TIRE_PRESSURE_REAR, + translation_key=KEY_RECOMMENDED_TIRE_PRESSURE_REAR, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=_recommended_tire_pressure(API_KEY_REAR_TIRES), + ), ] # Sensors available for Gen3 vehicles API_GEN_3_SENSORS = [ - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.REMAINING_FUEL_PERCENT, translation_key="fuel_level", native_unit_of_measurement=PERCENTAGE, @@ -110,21 +195,21 @@ API_GEN_3_SENSORS = [ # Sensors available to subscribers with PHEV vehicles EV_SENSORS = [ - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.EV_DISTANCE_TO_EMPTY, translation_key="ev_range", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.EV_STATE_OF_CHARGE_PERCENT, translation_key="ev_battery_level", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + SubaruSensorEntityDescription( key=sc.EV_TIME_TO_FULLY_CHARGED_UTC, translation_key="ev_time_to_full_charge", device_class=SensorDeviceClass.TIMESTAMP, @@ -173,34 +258,37 @@ def create_vehicle_sensors( ] -class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity): +class SubaruSensor(SubaruCoordinatorEntity, SensorEntity): """Class for Subaru sensors.""" - _attr_has_entity_name = True + entity_description: SubaruSensorEntityDescription def __init__( self, vehicle_info: dict, coordinator: SubaruDataUpdateCoordinator, - description: SensorEntityDescription, + description: SubaruSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) - self.vin = vehicle_info[VEHICLE_VIN] + super().__init__(vehicle_info, coordinator, description.key) self.entity_description = description - self._attr_device_info = get_device_info(vehicle_info) - self._attr_unique_id = f"{self.vin}_{description.key}" @property @override - def native_value(self) -> int | float | None: + def native_value(self) -> StateType | date | datetime | Decimal: """Return the state of the sensor.""" - current_value = self.coordinator.data[self.vin][VEHICLE_STATUS].get( - self.entity_description.key - ) + vehicle_data = self.coordinator.data[self.vin] + if self.entity_description.value_fn is not None: + current_value = self.entity_description.value_fn(vehicle_data) + else: + current_value = vehicle_data[VEHICLE_STATUS].get( + self.entity_description.key + ) if ( self.entity_description.key == sc.AVG_FUEL_CONSUMPTION + and isinstance(current_value, (int, float)) + and current_value > 0 and self.hass.config.units == METRIC_SYSTEM ): return round((100.0 * L_PER_GAL) / (KM_PER_MI * current_value), 1) @@ -218,15 +306,6 @@ class SubaruSensor(CoordinatorEntity[SubaruDataUpdateCoordinator], SensorEntity) return FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS return self.entity_description.native_unit_of_measurement - @property - @override - def available(self) -> bool: - """Return if entity is available.""" - last_update_success = super().available - if last_update_success and self.vin not in self.coordinator.data: - return False - return last_update_success - async def _async_migrate_entries( hass: HomeAssistant, config_entry: ConfigEntry diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 5e72848e46b3..fd8209f150ac 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -82,6 +82,12 @@ "range": { "name": "Range" }, + "recommended_tire_pressure_front": { + "name": "Recommended tire pressure front" + }, + "recommended_tire_pressure_rear": { + "name": "Recommended tire pressure rear" + }, "tire_pressure_front_left": { "name": "Tire pressure front left" }, @@ -93,6 +99,15 @@ }, "tire_pressure_rear_right": { "name": "Tire pressure rear right" + }, + "vehicle_state": { + "name": "Vehicle state", + "state": { + "engine_on_remote_start": "Engine on (remote start)", + "ignition_acc": "Accessory power", + "ignition_off": "Ignition off", + "ignition_on": "Ignition on" + } } } }, diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 262457c4620e..d7e67ba34fbf 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant @@ -41,8 +42,8 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( SuezWaterSensorEntityDescription( key="water_price", translation_key="water_price", - native_unit_of_measurement=CURRENCY_EURO, - device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda suez_data: suez_data.price, ), ) diff --git a/homeassistant/components/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index 6a13d3c5d1ef..c4012e10f588 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -59,10 +59,7 @@ def _remove_missing_devices( continue if domain_device_ids.isdisjoint(known_device_ids): - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool: diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 01d21e1be58e..e1d7f163843a 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -3,30 +3,31 @@ from datetime import timedelta import logging -from surepy.enums import Location from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError -import voluptuous as vol -from homeassistant.const import ATTR_LOCATION, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_FLAP_ID, - ATTR_LOCK_STATE, - ATTR_PET_NAME, - DOMAIN, - SERVICE_SET_LOCK_STATE, - SERVICE_SET_PET_LOCATION, -) +from .const import DOMAIN from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=3) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Sure Petcare services.""" + async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: SurePetcareConfigEntry) -> bool: """Set up Sure Petcare from a config entry.""" @@ -43,45 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SurePetcareConfigEntry) entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - lock_state_service_schema = vol.Schema( - { - vol.Required(ATTR_FLAP_ID): vol.All( - cv.positive_int, vol.In(coordinator.data.keys()) - ), - vol.Required(ATTR_LOCK_STATE): vol.All( - cv.string, - vol.Lower, - vol.In(coordinator.lock_states_callbacks.keys()), - ), - } - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_LOCK_STATE, - coordinator.handle_set_lock_state, - schema=lock_state_service_schema, - ) - - set_pet_location_schema = vol.Schema( - { - vol.Required(ATTR_PET_NAME): vol.In(coordinator.get_pets().keys()), - vol.Required(ATTR_LOCATION): vol.In( - [ - Location.INSIDE.name.title(), - Location.OUTSIDE.name.title(), - ] - ), - } - ) - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, - SERVICE_SET_PET_LOCATION, - coordinator.handle_set_pet_location, - schema=set_pet_location_schema, - ) - return True diff --git a/homeassistant/components/surepetcare/services.py b/homeassistant/components/surepetcare/services.py new file mode 100644 index 000000000000..6ff3d3c41c3a --- /dev/null +++ b/homeassistant/components/surepetcare/services.py @@ -0,0 +1,91 @@ +"""Support for Sure Petcare services.""" + +from surepy.enums import Location +import voluptuous as vol + +from homeassistant.const import ATTR_LOCATION +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, service + +from .const import ( + ATTR_FLAP_ID, + ATTR_LOCK_STATE, + ATTR_PET_NAME, + DOMAIN, + SERVICE_SET_LOCK_STATE, + SERVICE_SET_PET_LOCATION, +) +from .coordinator import SurePetcareConfigEntry + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register Sure Petcare services.""" + + async def handle_set_lock_state(call: ServiceCall) -> None: + """Set lock state for a flap.""" + entry: SurePetcareConfigEntry = service.async_get_config_entry( + hass, DOMAIN, None + ) + coordinator = entry.runtime_data + if call.data[ATTR_FLAP_ID] not in coordinator.data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_flap_id", + translation_placeholders={"flap_id": call.data[ATTR_FLAP_ID]}, + ) + await coordinator.handle_set_lock_state(call) + + async def handle_set_pet_location(call: ServiceCall) -> None: + """Set pet location.""" + entry: SurePetcareConfigEntry = service.async_get_config_entry( + hass, DOMAIN, None + ) + coordinator = entry.runtime_data + if call.data[ATTR_PET_NAME] not in coordinator.get_pets(): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_pet_name", + translation_placeholders={"pet_name": call.data[ATTR_PET_NAME]}, + ) + await coordinator.handle_set_pet_location(call) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_LOCK_STATE, + handle_set_lock_state, + schema=vol.Schema( + { + vol.Required(ATTR_FLAP_ID): cv.positive_int, + vol.Required(ATTR_LOCK_STATE): vol.All( + cv.string, + vol.Lower, + vol.In( + [ + "unlocked", + "locked_in", + "locked_out", + "locked_all", + ] + ), + ), + } + ), + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PET_LOCATION, + handle_set_pet_location, + schema=vol.Schema( + { + vol.Required(ATTR_PET_NAME): cv.string, + vol.Required(ATTR_LOCATION): vol.In( + [ + Location.INSIDE.name.title(), + Location.OUTSIDE.name.title(), + ] + ), + } + ), + ) diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json index 890c97ba3e57..ecb60ced5486 100644 --- a/homeassistant/components/surepetcare/strings.json +++ b/homeassistant/components/surepetcare/strings.json @@ -25,6 +25,14 @@ } } }, + "exceptions": { + "invalid_flap_id": { + "message": "Unknown Sure Petcare flap ID: {flap_id}" + }, + "invalid_pet_name": { + "message": "Unknown Sure Petcare pet: {pet_name}" + } + }, "services": { "set_lock_state": { "description": "Sets lock state.", diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index fe1e92ab6f26..c17a591e64c1 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -128,9 +128,7 @@ async def async_migrate_entry( device_registry, config_entry_id=config_entry.entry_id ) for dev in device_entries: - device_registry.async_update_device( - dev.id, remove_config_entry_id=config_entry.entry_id - ) + device_registry.async_remove_device(dev.id) entity_id = entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, "None_departure" diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json index 8b259e82d90d..6beb51f2b7fe 100644 --- a/homeassistant/components/swisscom/manifest.json +++ b/homeassistant/components/swisscom/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/swisscom", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["python-swisscom-internet-box==0.1.1"] + "requirements": ["python-swisscom-internet-box==0.2.0"] } diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index ef0a5cc5e3a0..e44aa0da3b1d 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -60,7 +60,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_get_parent_device_id(hass, entity_id), diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index b97cfda1ea93..3223f24fa027 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -427,9 +427,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) minor_version = entry.minor_version _LOGGER.debug("Migrating from version %s.%s", version, minor_version) - if version > 1: - return False - if version == 1 and minor_version < 2: new_options: dict[str, Any] = {**entry.options} diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 683f98ec4b27..7487e64d312f 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -6,13 +6,12 @@ from typing import Any, override import switchbot from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, - ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, + CoverEntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -77,11 +76,14 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_POSITION not in last_state.attributes + ): return self._attr_current_cover_position = last_state.attributes.get( - ATTR_CURRENT_POSITION + CoverEntityStateAttribute.CURRENT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if self._attr_current_cover_position is not None: @@ -172,11 +174,15 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_TILT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_TILT_POSITION + not in last_state.attributes + ): return self._attr_current_cover_tilt_position = last_state.attributes.get( - ATTR_CURRENT_TILT_POSITION + CoverEntityStateAttribute.CURRENT_TILT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if (_tilt := self._attr_current_cover_tilt_position) is not None: @@ -260,11 +266,14 @@ class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Run when entity about to be added.""" await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + if ( + not last_state + or CoverEntityStateAttribute.CURRENT_POSITION not in last_state.attributes + ): return self._attr_current_cover_position = last_state.attributes.get( - ATTR_CURRENT_POSITION + CoverEntityStateAttribute.CURRENT_POSITION ) self._last_run_success = last_state.attributes.get("last_run_success") if self._attr_current_cover_position is not None: diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 4ee1116a78cb..421a867da3be 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -186,24 +186,6 @@ async def make_device_data( ) devices_data.vacuums.append((device, coordinator)) - if isinstance(device, Device) and device.device_type in [ - "Smart Lock", - "Smart Lock Lite", - "Smart Lock Pro", - "Smart Lock Ultra", - "Smart Lock Vision", - "Smart Lock Vision Pro", - "Smart Lock Pro Wifi", - "Lock Vision", - "Lock Vision Pro", - ]: - coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id - ) - devices_data.locks.append((device, coordinator)) - devices_data.sensors.append((device, coordinator)) - devices_data.binary_sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "Bot": coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id, True @@ -297,13 +279,6 @@ async def make_device_data( ) devices_data.humidifiers.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "AI Art Frame": - coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id - ) - devices_data.buttons.append((device, coordinator)) - devices_data.sensors.append((device, coordinator)) - devices_data.images.append((device, coordinator)) await make_new_device_data( hass, entry, api, device, devices_data, coordinators_by_id diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 0b5bc94ef8b7..971dab2d94eb 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -15,7 +15,6 @@ from switchbot_api import ( from homeassistant.components import climate as FanState from homeassistant.components.climate import ( - ATTR_FAN_MODE, ATTR_TEMPERATURE, PRESET_BOOST, PRESET_COMFORT, @@ -24,6 +23,7 @@ from homeassistant.components.climate import ( PRESET_NONE, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACMode, ) from homeassistant.const import ( @@ -128,10 +128,10 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity, RestoreE _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 + ClimateEntityStateAttribute.FAN_MODE, self._attr_fan_mode ) self._attr_target_temperature = last_state.attributes.get( - ATTR_TEMPERATURE, self._attr_target_temperature + ClimateEntityStateAttribute.TEMPERATURE, self._attr_target_temperature ) def _get_mode(self, hvac_mode: HVACMode | None) -> int: diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 835e5ce1077b..d496470a26df 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -124,5 +124,35 @@ DEVICE_SUPPORT_MAP: Final[dict[str, SwitchbotCloudDeviceConfig]] = { "WoIOSensor": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "Hub 2": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), "MeterPro": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "Smart Lock": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.BINARY_SENSOR, Platform.SENSOR, Platform.LOCK) + ), + "Smart Lock Ultra": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Vision": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Vision Pro": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Lock Vision": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Lock Vision Pro": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Lite": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Pro": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), + "Smart Lock Pro Wifi": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BINARY_SENSOR, Platform.LOCK) + ), "MeterPro(CO2)": SwitchbotCloudDeviceConfig(True, entity_config=(Platform.SENSOR,)), + "AI Art Frame": SwitchbotCloudDeviceConfig( + True, entity_config=(Platform.SENSOR, Platform.BUTTON, Platform.IMAGE) + ), } diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index bb06518111b7..c17ac1128b45 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -44,8 +44,14 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): 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 != "Smart Lock Lite": + self._attr_is_locked = coord_data["lockState"].lower() == "locked" + if self.__model not in [ + "Smart Lock Lite", + "Smart Lock Vision", + "Smart Lock Vision Pro", + "Lock Vision", + "Lock Vision Pro", + ]: self._attr_supported_features = LockEntityFeature.OPEN @override diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 892b1f1bd021..7c3ff8f5584b 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -75,8 +75,7 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): # "Robot Vacuum Cleaner S1 Plus" _attr_supported_features: VacuumEntityFeature = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED + VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -123,7 +122,6 @@ class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): if self.coordinator.data is None: return - self._attr_battery_level = self.coordinator.data.get("battery") self._attr_available = self.coordinator.data.get("onlineStatus") == "online" switchbot_state = str(self.coordinator.data.get("workingStatus")) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index fd3fb5a6f181..00d599145150 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.10.2"], + "requirements": ["py-synologydsm-api==2.10.3"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:Basic:1", diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index ddde26ae083f..6d22cb777f22 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -18,7 +18,6 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( @@ -223,9 +222,9 @@ class OptionsFlowHandler(OptionsFlow): return self.async_show_form(step_id="init", data_schema=data_schema) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class TadoRateLimitExceeded(HomeAssistantError): +class TadoRateLimitExceeded(Exception): """Error to indicate Tado API rate limit exceeded.""" diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 46387ef34f61..4574f2bc0b45 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -13,8 +13,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: TankerkoenigConfigEntry ) -> bool: """Set a tankerkoenig configuration entry up.""" - hass.data.setdefault(DOMAIN, {}) - coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL) await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 344778e7ba42..336c3e4377d7 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -42,7 +42,7 @@ async def async_setup_entry( class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorEntity): """Shows if a station is open or closed.""" - _attr_device_class = BinarySensorDeviceClass.DOOR + _attr_device_class = BinarySensorDeviceClass.OPENING _attr_translation_key = "status" def __init__( @@ -56,8 +56,8 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE self._attr_unique_id = f"{station.id}_status" if coordinator.show_on_map: self._attr_extra_state_attributes = { - ATTR_LATITUDE: station.lat, - ATTR_LONGITUDE: station.lng, + EntityStateAttribute.LATITUDE: station.lat, + EntityStateAttribute.LONGITUDE: station.lng, } @property diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index c8dd1b396dad..1e47c5344a60 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -108,9 +108,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf for station_id in self._selected_stations ): _LOGGER.debug("Removing obsolete device entry %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) if len(self.stations) > 10: _LOGGER.warning( diff --git a/homeassistant/components/tankerkoenig/icons.json b/homeassistant/components/tankerkoenig/icons.json index 05b9d3bcbca6..47f03fd7bca5 100644 --- a/homeassistant/components/tankerkoenig/icons.json +++ b/homeassistant/components/tankerkoenig/icons.json @@ -1,5 +1,14 @@ { "entity": { + "binary_sensor": { + "status": { + "default": "mdi:store", + "state": { + "off": "mdi:store-off", + "on": "mdi:store" + } + } + }, "sensor": { "diesel": { "default": "mdi:gas-station" diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index e551432b5f4a..06483fec5714 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -6,7 +6,7 @@ from typing import override from aiotankerkoenig import GasType, Station from homeassistant.components.sensor import SensorEntity, SensorStateClass -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO +from homeassistant.const import CURRENCY_EURO, EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -76,8 +76,8 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): ATTR_STATION_NAME, ATTR_STREET, ATTRIBUTION, - ATTR_LATITUDE, - ATTR_LONGITUDE, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, } ) @@ -104,8 +104,8 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): } if coordinator.show_on_map: - attrs[ATTR_LATITUDE] = station.lat - attrs[ATTR_LONGITUDE] = station.lng + attrs[EntityStateAttribute.LATITUDE] = station.lat + attrs[EntityStateAttribute.LONGITUDE] = station.lng self._attr_extra_state_attributes = attrs @property diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 18c979b2f373..844aed33075a 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -3,6 +3,7 @@ import logging from typing import Protocol, cast +import telegram from telegram import Bot from telegram.constants import InputMediaType from telegram.error import InvalidToken, TelegramError @@ -33,6 +34,7 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, entity_registry as er, issue_registry as ir, ) @@ -104,6 +106,7 @@ from .const import ( CHAT_ACTION_UPLOAD_VIDEO_NOTE, CHAT_ACTION_UPLOAD_VOICE, CONF_API_ENDPOINT, + CONF_CHAT_ID, CONF_CONFIG_ENTRY_ID, DEFAULT_API_ENDPOINT, DOMAIN, @@ -705,6 +708,46 @@ async def async_migrate_entry( updated, ) + # version 1.2 -> 1.3: give each chat its own device, linked to the shared bot device, + # and make sure the bot device is tied to (entry, None). + if version == 1 and config_entry.minor_version < 3: + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + if devices: + bot_device = devices[0] + bot_id = next( + identifier + for domain, identifier in bot_device.identifiers + if domain == DOMAIN + ) + notify_entities = { + entity.config_subentry_id: entity + for entity in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + # The event entity (no subentry) stays on the shared bot device + if entity.config_subentry_id is not None + } + for subentry_id, subentry in config_entry.subentries.items(): + per_chat_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_id, + identifiers={(DOMAIN, f"{bot_id}_{subentry.data[CONF_CHAT_ID]}")}, + via_device_id=bot_device.id, + ) + if entity := notify_entities.get(subentry_id): + entity_registry.async_update_entity( + entity.entity_id, device_id=per_chat_device.id + ) + # Hand the bot device back to (entry, None), keeping the event entity + device_registry.async_update_device( + bot_device.id, new_config_subentry_id=None + ) + hass.config_entries.async_update_entry(config_entry, minor_version=3) + return True @@ -906,6 +949,18 @@ def _warn_chat_id_migration(service: ServiceCall) -> set[int]: return chat_ids +def bot_device_info(config_entry: TelegramBotConfigEntry, bot_id: int) -> dr.DeviceInfo: + """Return device info for the shared bot device.""" + return dr.DeviceInfo( + name=config_entry.title, + entry_type=dr.DeviceEntryType.SERVICE, + manufacturer="Telegram", + model=config_entry.data[CONF_PLATFORM].capitalize(), + sw_version=telegram.__version__, + identifiers={(DOMAIN, f"{bot_id}")}, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool: """Create the Telegram bot from config entry.""" bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data) @@ -933,6 +988,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) ) entry.runtime_data = notify_service + # Create the bot device before the platforms are set up, so the per-chat devices can + # resolve it as their via_device no matter which platform is set up first + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, **bot_device_info(entry, bot.id) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 6d5422b8368b..0aa84bc996c9 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -192,7 +192,7 @@ class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Telegram.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback diff --git a/homeassistant/components/telegram_bot/entity.py b/homeassistant/components/telegram_bot/entity.py index 95adc934781a..1b71426a89fe 100644 --- a/homeassistant/components/telegram_bot/entity.py +++ b/homeassistant/components/telegram_bot/entity.py @@ -1,13 +1,8 @@ """Base entity for Telegram bot integration.""" -import telegram - -from homeassistant.const import CONF_PLATFORM -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from . import TelegramBotConfigEntry -from .const import DOMAIN +from . import TelegramBotConfigEntry, bot_device_info class TelegramBotEntity(Entity): @@ -28,11 +23,4 @@ class TelegramBotEntity(Entity): self.service = config_entry.runtime_data self._attr_unique_id = f"{self.bot_id}_{entity_description.key}" - self._attr_device_info = DeviceInfo( - name=config_entry.title, - entry_type=DeviceEntryType.SERVICE, - manufacturer="Telegram", - model=config_entry.data[CONF_PLATFORM].capitalize(), - sw_version=telegram.__version__, - identifiers={(DOMAIN, f"{self.bot_id}")}, - ) + self._attr_device_info = bot_device_info(config_entry, self.bot_id) diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py index c49d106a84d2..e95cf2de681e 100644 --- a/homeassistant/components/telegram_bot/notify.py +++ b/homeassistant/components/telegram_bot/notify.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TelegramBotConfigEntry -from .const import ATTR_TITLE, CONF_CHAT_ID +from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN from .entity import TelegramBotEntity @@ -33,6 +33,7 @@ async def async_setup_entry( class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): """Representation of a telegram bot notification entity.""" + _attr_name = None _attr_supported_features = NotifyEntityFeature.TITLE def __init__( @@ -45,7 +46,12 @@ class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): config_entry, NotifyEntityDescription(key=subentry.data[CONF_CHAT_ID]) ) self.chat_id = subentry.data[CONF_CHAT_ID] - self._attr_name = subentry.title + # Each chat gets its own device (keyed per chat) linked to the shared bot device. + device_info = self._attr_device_info + assert device_info is not None + device_info["identifiers"] = {(DOMAIN, f"{self.bot_id}_{self.chat_id}")} + device_info["name"] = subentry.title + device_info["via_device"] = (DOMAIN, f"{self.bot_id}") @override async def async_send_message(self, message: str, title: str | None = None) -> None: diff --git a/homeassistant/components/teltonika/quality_scale.yaml b/homeassistant/components/teltonika/quality_scale.yaml index 96e783ea3585..eb563705413f 100644 --- a/homeassistant/components/teltonika/quality_scale.yaml +++ b/homeassistant/components/teltonika/quality_scale.yaml @@ -59,7 +59,9 @@ rules: docs-supported-functions: todo docs-troubleshooting: done docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: + status: exempt + comment: Integration only has static hardware devices, single device per config entry entity-category: todo entity-device-class: done entity-disabled-by-default: todo @@ -68,7 +70,9 @@ rules: icon-translations: todo reconfiguration-flow: todo repair-issues: todo - stale-devices: todo + stale-devices: + status: exempt + comment: Integration only has static hardware devices, single device per config entry # Platinum async-dependency: done diff --git a/homeassistant/components/temperature/condition.py b/homeassistant/components/temperature/condition.py index 9b407851eb64..a793108de64d 100644 --- a/homeassistant/components/temperature/condition.py +++ b/homeassistant/components/temperature/condition.py @@ -3,20 +3,19 @@ from typing import override from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, + ClimateEntityStateAttribute, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.components.water_heater import ( - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterStateAttribute, ) from homeassistant.components.weather import ( - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_TEMPERATURE_UNIT, DOMAIN as WEATHER_DOMAIN, + WeatherEntityStateAttribute, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature +from homeassistant.const import EntityStateAttribute, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.condition import ( @@ -27,16 +26,16 @@ from homeassistant.util.unit_conversion import TemperatureConverter TEMPERATURE_DOMAIN_SPECS: dict[str, DomainSpec] = { CLIMATE_DOMAIN: DomainSpec( - value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, + value_source=ClimateEntityStateAttribute.CURRENT_TEMPERATURE, ), SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), WATER_HEATER_DOMAIN: DomainSpec( - value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE, + value_source=WaterHeaterStateAttribute.CURRENT_TEMPERATURE, ), WEATHER_DOMAIN: DomainSpec( - value_source=ATTR_WEATHER_TEMPERATURE, + value_source=WeatherEntityStateAttribute.TEMPERATURE, ), } @@ -68,9 +67,11 @@ class TemperatureCondition(EntityNumericalConditionWithUnitBase): def _get_entity_unit(self, entity_state: State) -> str | None: """Get the temperature unit of an entity from its state.""" if entity_state.domain == SENSOR_DOMAIN: - return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if entity_state.domain == WEATHER_DOMAIN: - return entity_state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT) + return entity_state.attributes.get( + WeatherEntityStateAttribute.TEMPERATURE_UNIT + ) # Climate and water_heater: show_temp converts to system unit return self._hass.config.units.temperature_unit diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index a1022422b207..dd7d6de43845 100644 --- a/homeassistant/components/temperature/trigger.py +++ b/homeassistant/components/temperature/trigger.py @@ -3,20 +3,19 @@ from typing import override from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, + ClimateEntityStateAttribute, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.components.water_heater import ( - ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterStateAttribute, ) from homeassistant.components.weather import ( - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_TEMPERATURE_UNIT, DOMAIN as WEATHER_DOMAIN, + WeatherEntityStateAttribute, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature +from homeassistant.const import EntityStateAttribute, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.helpers.automation import DomainSpec from homeassistant.helpers.trigger import ( @@ -29,14 +28,16 @@ from homeassistant.util.unit_conversion import TemperatureConverter TEMPERATURE_DOMAIN_SPECS: dict[str, DomainSpec] = { CLIMATE_DOMAIN: DomainSpec( - value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE, + value_source=ClimateEntityStateAttribute.CURRENT_TEMPERATURE, ), SENSOR_DOMAIN: DomainSpec( device_class=SensorDeviceClass.TEMPERATURE, ), - WATER_HEATER_DOMAIN: DomainSpec(value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE), + WATER_HEATER_DOMAIN: DomainSpec( + value_source=WaterHeaterStateAttribute.CURRENT_TEMPERATURE + ), WEATHER_DOMAIN: DomainSpec( - value_source=ATTR_WEATHER_TEMPERATURE, + value_source=WeatherEntityStateAttribute.TEMPERATURE, ), } @@ -70,9 +71,9 @@ class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase): def _get_entity_unit(self, state: State) -> str | None: """Get the temperature unit of an entity from its state.""" if state.domain == SENSOR_DOMAIN: - return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if state.domain == WEATHER_DOMAIN: - return state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT) + return state.attributes.get(WeatherEntityStateAttribute.TEMPERATURE_UNIT) # Climate and water_heater: show_temp converts to system unit return self._hass.config.units.temperature_unit diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 1ba5fa21e824..b825552e8170 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -29,7 +29,14 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN, PLATFORMS +from .const import ( + CONF_ADDITIONAL_OPTIONS, + CONF_MAX, + CONF_MIN, + CONF_STEP, + DOMAIN, + PLATFORMS, +) from .coordinator import TriggerUpdateCoordinator from .helpers import async_get_blueprints @@ -141,6 +148,14 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, version=1, minor_version=2 ) + options = {**config_entry.options} + # The "advanced_options" section was renamed to "additional_options" + if (additional := options.pop("advanced_options", None)) is not None: + options[CONF_ADDITIONAL_OPTIONS] = additional + hass.config_entries.async_update_entry( + config_entry, options=options, version=2, minor_version=1 + ) + _LOGGER.debug( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 0d28bc3c6aa9..934cd6a3f6bf 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -60,7 +60,7 @@ from .alarm_control_panel import ( ) from .binary_sensor import async_create_preview_binary_sensor from .const import ( - CONF_ADVANCED_OPTIONS, + CONF_ADDITIONAL_OPTIONS, CONF_AVAILABILITY, CONF_PRESS, CONF_TURN_OFF, @@ -157,7 +157,7 @@ _SCHEMA_STATE: dict[vol.Marker, Any] = { def generate_schema(domain: str, flow_type: str) -> vol.Schema: """Generate schema.""" schema: dict[vol.Marker, Any] = {} - advanced_options: dict[vol.Marker, Any] = {} + additional_options: dict[vol.Marker, Any] = {} if flow_type == "config": schema = {vol.Required(CONF_NAME): selector.TextSelector()} @@ -240,7 +240,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Optional(CONF_LATITUDE): selector.TemplateSelector(), vol.Optional(CONF_LONGITUDE): selector.TemplateSelector(), } - advanced_options |= { + additional_options |= { vol.Optional(CONF_LOCATION_ACCURACY): selector.TemplateSelector(), } @@ -445,11 +445,11 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: schema |= { vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - vol.Optional(CONF_ADVANCED_OPTIONS): section( + vol.Optional(CONF_ADDITIONAL_OPTIONS): section( vol.Schema( { vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(), - **advanced_options, + **additional_options, } ), {"collapsed": True}, @@ -782,8 +782,7 @@ class TemplateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW options_flow_reloads = True - MINOR_VERSION = 2 - VERSION = 1 + VERSION = 2 @callback @override @@ -901,9 +900,9 @@ def ws_start_preview( return config: dict = msg["user_input"] - advanced_options = config.pop(CONF_ADVANCED_OPTIONS, {}) + additional_options = config.pop(CONF_ADDITIONAL_OPTIONS, {}) preview_entity = CREATE_PREVIEW_ENTITY[template_type]( - hass, name, {**config, **advanced_options} + hass, name, {**config, **additional_options} ) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index cbb9c3beb272..816b77b5284d 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform from homeassistant.helpers.typing import ConfigType -CONF_ADVANCED_OPTIONS = "advanced_options" +CONF_ADDITIONAL_OPTIONS = "additional_options" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 040887e67132..a61b9cb5e60e 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -1,6 +1,7 @@ """Support for covers which integrate with other components.""" -from typing import TYPE_CHECKING, Any, override +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -22,6 +23,7 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -158,13 +160,40 @@ def async_create_preview_cover( ) -class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): +@dataclass(kw_only=True) +class CoverExtraStoredData(ExtraStoredData): + """Holds extra stored data for template cover entities.""" + + current_cover_position: int | None + current_cover_tilt_position: int | None + is_opening: bool | None + is_closing: bool | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the cover data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self: + """Initialize a stored cover state from a dict.""" + return cls( + current_cover_position=restored["current_cover_position"], + current_cover_tilt_position=restored["current_cover_tilt_position"], + is_opening=restored["is_opening"], + is_closing=restored["is_closing"], + ) + + +class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity, RestoreEntity): """Representation of a template cover features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _extra_optimistic_options = (CONF_POSITION,) _state_option = CONF_STATE + _restore_state_extra_data = CoverExtraStoredData + _restore_state_properties = ("_attr_current_cover_position",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -324,6 +353,25 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): if self._tilt_optimistic: self.async_write_ha_state() + @property + @override + def extra_restore_state_data(self) -> CoverExtraStoredData: + """Return cover specific state data to be restored.""" + return CoverExtraStoredData( + current_cover_position=self._attr_current_cover_position, + current_cover_tilt_position=self._attr_current_cover_tilt_position, + is_opening=self._attr_is_opening, + is_closing=self._attr_is_closing, + ) + + @override + def restore_extra_data(self, extra_data: CoverExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_current_cover_position = extra_data.current_cover_position + self._attr_current_cover_tilt_position = extra_data.current_cover_tilt_position + self._attr_is_opening = extra_data.is_opening + self._attr_is_closing = extra_data.is_closing + class StateCoverEntity(TemplateEntity, AbstractTemplateCover): """Representation of a Template cover.""" diff --git a/homeassistant/components/template/device_tracker.py b/homeassistant/components/template/device_tracker.py index 3c7f013ddbc7..e0103fdf92ca 100644 --- a/homeassistant/components/template/device_tracker.py +++ b/homeassistant/components/template/device_tracker.py @@ -1,7 +1,8 @@ """Support for device trackers which integrates with other components.""" from collections.abc import Callable -from typing import Any +from dataclasses import asdict, dataclass +from typing import Any, Self, override import voluptuous as vol @@ -19,6 +20,7 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -174,10 +176,37 @@ def async_create_preview_tracker( ) -class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity): +@dataclass(kw_only=True) +class TrackerExtraStoredData(ExtraStoredData): + """Holds extra stored data for template tracker entities.""" + + in_zones: list[str] | None + latitude: float | None + longitude: float | None + location_accuracy: float + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the tracker data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self: + """Initialize a stored tracker state from a dict.""" + return cls( + in_zones=restored["in_zones"], + latitude=restored["latitude"], + longitude=restored["longitude"], + location_accuracy=restored["location_accuracy"], + ) + + +class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity, RestoreEntity): """Representation of a template device tracker features.""" _entity_id_format = ENTITY_ID_FORMAT + _restore_state_extra_data = TrackerExtraStoredData + _restore_state_properties = ("_attr_in_zones",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -217,6 +246,25 @@ class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity): """Update the location accuracy.""" self._attr_location_accuracy = self._location_accuracy_validator(value) or 0.0 + @property + @override + def extra_restore_state_data(self) -> TrackerExtraStoredData: + """Return tracker specific state data to be restored.""" + return TrackerExtraStoredData( + in_zones=self._attr_in_zones, + latitude=self._attr_latitude, + longitude=self._attr_longitude, + location_accuracy=self._attr_location_accuracy, + ) + + @override + def restore_extra_data(self, extra_data: TrackerExtraStoredData) -> None: + """Restore the extra data.""" + self._attr_in_zones = extra_data.in_zones + self._attr_latitude = extra_data.latitude + self._attr_longitude = extra_data.longitude + self._attr_location_accuracy = extra_data.location_accuracy + class StateTrackerEntity(TemplateEntity, AbstractTemplateTracker): """Representation of a Template device tracker.""" diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 755a928af372..edcbc7056820 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -6,15 +6,13 @@ from dataclasses import dataclass from typing import Any, override from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - ATTR_FRIENDLY_NAME, - ATTR_ICON, CONF_DEVICE_ID, CONF_ICON, CONF_NAME, CONF_OPTIMISTIC, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr @@ -289,9 +287,9 @@ class AbstractTemplateEntity(Entity): """Restore attributes from the last state.""" # Restore built-in attributes from templates for conf_key, attr, _attr in ( - (CONF_ICON, ATTR_ICON, "_attr_icon"), - (CONF_NAME, ATTR_FRIENDLY_NAME, "_attr_name"), - (CONF_PICTURE, ATTR_ENTITY_PICTURE, "_attr_entity_picture"), + (CONF_ICON, EntityStateAttribute.ICON, "_attr_icon"), + (CONF_NAME, EntityStateAttribute.FRIENDLY_NAME, "_attr_name"), + (CONF_PICTURE, EntityStateAttribute.ENTITY_PICTURE, "_attr_entity_picture"), ): if conf_key not in self._config or attr not in last_state.attributes: continue diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index d2c7b3b68150..5a8f221ae98a 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -1,8 +1,9 @@ """Support for Template fans.""" +from dataclasses import asdict, dataclass from enum import StrEnum import logging -from typing import TYPE_CHECKING, Any, override +from typing import TYPE_CHECKING, Any, Self, override import voluptuous as vol @@ -22,6 +23,7 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import validators as template_validators @@ -155,12 +157,56 @@ def async_create_preview_fan( ) -class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): +@dataclass(kw_only=True) +class FanExtraStoredData(ExtraStoredData): + """Fan extra stored data.""" + + is_on: bool | None + percentage: int | None + preset_mode: str | None + oscillating: bool | None + direction: str | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the fan data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored fan data from a dict.""" + is_on = restored.get("is_on") + percentage = restored.get("percentage") + preset_mode = restored.get("preset_mode") + oscillating = restored.get("oscillating") + direction = restored.get("direction") + if is_on is not None and not isinstance(is_on, bool): + return None + if percentage is not None and not isinstance(percentage, int): + return None + if preset_mode is not None and not isinstance(preset_mode, str): + return None + if oscillating is not None and not isinstance(oscillating, bool): + return None + if direction is not None and not isinstance(direction, str): + return None + return cls( + is_on=is_on, + percentage=percentage, + preset_mode=preset_mode, + oscillating=oscillating, + direction=direction, + ) + + +class AbstractTemplateFan(AbstractTemplateEntity, FanEntity, RestoreEntity): """Representation of a template fan features.""" _entity_id_format = ENTITY_ID_FORMAT _optimistic_entity = True _state_option = CONF_STATE + _restore_state_extra_data = FanExtraStoredData + _restore_state_properties = ("_attr_is_on",) # The super init is not called because TemplateEntity # and TriggerEntity will call @@ -344,6 +390,27 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): ", ".join(_VALID_DIRECTIONS), ) + @property + @override + def extra_restore_state_data(self) -> FanExtraStoredData: + """Return extra state data to be restored.""" + return FanExtraStoredData( + is_on=self._attr_is_on, + percentage=self._attr_percentage, + preset_mode=self._attr_preset_mode, + oscillating=self._attr_oscillating, + direction=self._attr_current_direction, + ) + + @override + def restore_extra_data(self, extra_data: FanExtraStoredData) -> None: + """Restore extra state data.""" + self._attr_is_on = extra_data.is_on + self._attr_percentage = extra_data.percentage + self._attr_preset_mode = extra_data.preset_mode + self._attr_oscillating = extra_data.oscillating + self._attr_current_direction = extra_data.direction + class StateFanEntity(TemplateEntity, AbstractTemplateFan): """A template fan component.""" diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 959fbcb0bc37..66ca4eec45b5 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -31,7 +31,7 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import CONF_ADVANCED_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN +from .const import CONF_ADDITIONAL_OPTIONS, CONF_DEFAULT_ENTITY_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import TemplateEntity from .trigger_entity import TriggerEntity @@ -240,8 +240,8 @@ async def async_setup_template_entry( options = dict(config_entry.options) options.pop("template_type") - if advanced_options := options.pop(CONF_ADVANCED_OPTIONS, None): - options = {**options, **advanced_options} + if additional_options := options.pop(CONF_ADDITIONAL_OPTIONS, None): + options = {**options, **additional_options} if replace_value_template and CONF_VALUE_TEMPLATE in options: options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 5e550532e189..8c9028e03b9f 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1,6 +1,6 @@ { "common": { - "advanced_options": "Advanced options", + "additional_options": "Additional options", "availability": "Availability template", "availability_description": "Defines a template to get the `available` state of the entity. If the template either fails to render or returns `True`, `\"1\"`, `\"true\"`, `\"yes\"`, `\"on\"`, `\"enable\"`, or a non-zero number, the entity will be `available`. If the template returns any other value, the entity will be `unavailable`. If not configured, the entity will always be `available`. Note that the string comparison is not case sensitive; `\"TrUe\"` and `\"yEs\"` are allowed.", "code_format": "Code format", @@ -42,14 +42,14 @@ "value_template": "Defines a template to set the state of the alarm panel. Valid output values from the template are `armed_away`, `armed_home`, `armed_night`, `armed_vacation`, `arming`, `disarmed`, `pending`, and `triggered`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template alarm control panel" @@ -66,14 +66,14 @@ "state": "The sensor is `on` if the template evaluates as `True`, `yes`, `on`, `enable` or a positive number. Any other value will render it as `off`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template binary sensor" @@ -90,14 +90,14 @@ "press": "Defines actions to run when button is pressed." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template button" @@ -124,14 +124,14 @@ "stop_cover": "Defines actions to run when the cover is stopped." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template cover" @@ -152,7 +152,7 @@ "name": "[%key:common::config_flow::data::name%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]", "location_accuracy": "Location accuracy" @@ -161,7 +161,7 @@ "availability": "[%key:component::template::common::availability_description%]", "location_accuracy": "Defines a template to get the accuracy of the device tracker's location in meters. Valid values are numbers greater than or equal to `0`." }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template device tracker" @@ -180,14 +180,14 @@ "event_types": "Defines a template for a list of available event types." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template event" @@ -213,14 +213,14 @@ "turn_on": "Defines actions to run when the fan is turned on. Receives variables `percentage` and/or `preset_mode`." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template fan" @@ -238,14 +238,14 @@ "verify_ssl": "Enable or disable SSL certificate verification. Disable to use an http URL, or if you have a self-signed SSL certificate and haven’t installed the CA certificate to enable verification." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template image" @@ -277,14 +277,14 @@ "turn_on": "Defines actions to run when the light is turned on." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template light" @@ -308,14 +308,14 @@ "unlock": "Defines actions to run when the lock is unlocked." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template lock" @@ -342,14 +342,14 @@ "unit_of_measurement": "Defines the unit of measurement of the number, if any." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template number" @@ -369,14 +369,14 @@ "state": "Template for the select’s current value." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template select" @@ -396,14 +396,14 @@ "unit_of_measurement": "Defines the unit of measurement for the sensor, if any. This will also display the value based on the number format setting in the user profile and influence the graphical presentation in the history visualization as a continuous value." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template sensor" @@ -423,14 +423,14 @@ "value_template": "Defines a template to set the state of the switch. If not defined, the switch will optimistically assume all commands are successful." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template switch" @@ -465,14 +465,14 @@ "update_percentage": "Defines a template to get the update completion percentage." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template update" @@ -529,14 +529,14 @@ "stop": "Defines actions to run when the vacuum is stopped." }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template vacuum" @@ -562,11 +562,11 @@ "temperature_unit": "The temperature unit" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template weather" @@ -589,10 +589,6 @@ "config_format_triggers": { "description": "A trigger template configuration needs a trigger and at least one domain when defining an entity. This will be an configuration validation error in Home Assistant Core 2026.5.\n\n Please remove the orphaned trigger from the configuration.\n\n```\n{config}\n```", "title": "Incomplete template configuration" - }, - "deprecated_battery_level": { - "description": "The template vacuum options `battery_level` and `battery_level_template` are being removed in 2026.8.\n\nPlease remove the `battery_level` or `battery_level_template` option from the YAML configuration for {entity_id} ({entity_name}).", - "title": "Deprecated battery level option in {entity_name}" } }, "options": { @@ -625,14 +621,14 @@ "value_template": "[%key:component::template::config::step::alarm_control_panel::data_description::value_template%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::alarm_control_panel::title%]" @@ -648,14 +644,14 @@ "state": "[%key:component::template::config::step::binary_sensor::data_description::state%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::binary_sensor::title%]" @@ -670,14 +666,14 @@ "press": "[%key:component::template::config::step::button::data_description::press%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::button::title%]" @@ -702,14 +698,14 @@ "stop_cover": "[%key:component::template::config::step::cover::data_description::stop_cover%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::cover::title%]" @@ -728,16 +724,16 @@ "longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]", - "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data::location_accuracy%]" + "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::additional_options::data::location_accuracy%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]", - "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data_description::location_accuracy%]" + "location_accuracy": "[%key:component::template::config::step::device_tracker::sections::additional_options::data_description::location_accuracy%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::device_tracker::title%]" @@ -755,14 +751,14 @@ "event_types": "[%key:component::template::config::step::event::data_description::event_types%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::event::title%]" @@ -787,14 +783,14 @@ "turn_on": "[%key:component::template::config::step::fan::data_description::turn_on%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::fan::title%]" @@ -811,14 +807,14 @@ "verify_ssl": "[%key:component::template::config::step::image::data_description::verify_ssl%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::image::title%]" @@ -850,14 +846,14 @@ "turn_on": "[%key:component::template::config::step::light::data_description::turn_on%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::light::title%]" @@ -880,14 +876,14 @@ "unlock": "[%key:component::template::config::step::lock::data_description::unlock%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::lock::title%]" @@ -912,14 +908,14 @@ "step": "[%key:component::template::config::step::number::data_description::step%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::number::title%]" @@ -939,14 +935,14 @@ "state": "[%key:component::template::config::step::select::data_description::state%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::select::title%]" @@ -965,14 +961,14 @@ "unit_of_measurement": "[%key:component::template::config::step::sensor::data_description::unit_of_measurement%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::sensor::title%]" @@ -992,14 +988,14 @@ "value_template": "[%key:component::template::config::step::switch::data_description::value_template%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::switch::title%]" @@ -1034,14 +1030,14 @@ "update_percentage": "[%key:component::template::config::step::update::data_description::update_percentage%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "Template update" @@ -1075,14 +1071,14 @@ "stop": "[%key:component::template::config::step::vacuum::data_description::stop%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, "data_description": { "availability": "[%key:component::template::common::availability_description%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::vacuum::title%]" @@ -1108,11 +1104,11 @@ "temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]" }, "sections": { - "advanced_options": { + "additional_options": { "data": { "availability": "[%key:component::template::common::availability%]" }, - "name": "[%key:component::template::common::advanced_options%]" + "name": "[%key:component::template::common::additional_options%]" } }, "title": "[%key:component::template::config::step::weather::title%]" diff --git a/homeassistant/components/template/update.py b/homeassistant/components/template/update.py index c0c33b5a0406..dcd7ff22284e 100644 --- a/homeassistant/components/template/update.py +++ b/homeassistant/components/template/update.py @@ -6,13 +6,12 @@ from typing import TYPE_CHECKING, Any, override 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, + UpdateEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME @@ -244,8 +243,12 @@ class AbstractTemplateUpdate(AbstractTemplateEntity, UpdateEntity): @override def restore_last_state_state(self, last_state: State) -> bool: """Restore the state from the last state.""" - self._attr_installed_version = last_state.attributes[ATTR_INSTALLED_VERSION] - self._attr_latest_version = last_state.attributes[ATTR_LATEST_VERSION] + self._attr_installed_version = last_state.attributes[ + UpdateEntityStateAttribute.INSTALLED_VERSION + ] + self._attr_latest_version = last_state.attributes[ + UpdateEntityStateAttribute.LATEST_VERSION + ] return True diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 6a8cf8a5bfd6..f0412fb90373 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -23,12 +23,11 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_STATE, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator, validators as template_validators @@ -49,7 +48,6 @@ from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) -CONF_BATTERY_LEVEL = "battery_level" CONF_CLEAN_SEGMENTS = "clean_segments" CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_LIST = "fan_speeds" @@ -75,7 +73,6 @@ CLEAN_AREA_GROUP = "clean_area_group" VACUUM_COMMON_SCHEMA = vol.Schema( { - vol.Optional(CONF_BATTERY_LEVEL): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED): cv.template, vol.Optional(CONF_STATE): cv.template, @@ -164,26 +161,6 @@ def async_create_preview_vacuum( ) -def create_issue( - hass: HomeAssistant, supported_features: int, name: str, entity_id: str -) -> None: - """Create the battery_level issue.""" - if supported_features & VacuumEntityFeature.BATTERY: - key = "deprecated_battery_level" - ir.async_create_issue( - hass, - DOMAIN, - f"{key}_{entity_id}", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=key, - translation_placeholders={ - "entity_name": name, - "entity_id": entity_id, - }, - ) - - def validate_segments( entity: AbstractTemplateVacuum, option: str, @@ -266,11 +243,6 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self, CONF_FAN_SPEED, self._attr_fan_speed_list ), ) - self.setup_template( - CONF_BATTERY_LEVEL, - "_attr_battery_level", - template_validators.number(self, CONF_BATTERY_LEVEL, 0.0, 100.0), - ) self.setup_template( CONF_SEGMENTS, @@ -283,9 +255,6 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): VacuumEntityFeature.START | VacuumEntityFeature.STATE ) - if CONF_BATTERY_LEVEL in self._templates: - self._attr_supported_features |= VacuumEntityFeature.BATTERY - for action_id, supported_feature in ( (SERVICE_START, 0), (SERVICE_PAUSE, VacuumEntityFeature.PAUSE), @@ -419,17 +388,6 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): assert name is not None AbstractTemplateVacuum.__init__(self, name, config) - @override - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - create_issue( - self.hass, - self._attr_supported_features, - self._attr_name or DEFAULT_NAME, - self.entity_id, - ) - class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): """Vacuum entity based on trigger data.""" @@ -446,14 +404,3 @@ class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): TriggerEntity.__init__(self, hass, coordinator, config) self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) AbstractTemplateVacuum.__init__(self, name, config) - - @override - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - create_issue( - self.hass, - self._attr_supported_features, - self._attr_name or DEFAULT_NAME, - self.entity_id, - ) diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py index 836bb06d985f..91a13fe176d6 100644 --- a/homeassistant/components/tesla_fleet/button.py +++ b/homeassistant/components/tesla_fleet/button.py @@ -8,9 +8,11 @@ from tesla_fleet_api.const import Scope 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 . import TeslaFleetConfigEntry +from .const import DOMAIN from .entity import TeslaFleetVehicleEntity from .helpers import handle_vehicle_command from .models import TeslaFleetVehicleData @@ -48,10 +50,7 @@ DESCRIPTIONS: tuple[TeslaFleetButtonEntityDescription, ...] = ( ), TeslaFleetButtonEntityDescription( key="homelink", - func=lambda self: self.api.trigger_homelink( - lat=self.coordinator.data["drive_state_latitude"], - lon=self.coordinator.data["drive_state_longitude"], - ), + func=lambda self: self.async_trigger_homelink(), ), ) @@ -89,6 +88,17 @@ class TeslaFleetButtonEntity(TeslaFleetVehicleEntity, ButtonEntity): def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" + async def async_trigger_homelink(self) -> Any: + """Trigger Homelink, which requires the vehicle location.""" + if (lat := self.coordinator.data.get("drive_state_latitude")) is None or ( + lon := self.coordinator.data.get("drive_state_longitude") + ) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="homelink_no_location", + ) + return await self.api.trigger_homelink(lat=lat, lon=lon) + @override async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index 4b4df4754216..825af496c660 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -4,6 +4,7 @@ from typing import override from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -52,8 +53,8 @@ class TeslaFleetDeviceTrackerEntity( and self._attr_latitude is None and self._attr_longitude is None ): - self._attr_latitude = state.attributes.get("latitude") - self._attr_longitude = state.attributes.get("longitude") + self._attr_latitude = state.attributes.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = state.attributes.get(EntityStateAttribute.LONGITUDE) class TeslaFleetDeviceTrackerLocationEntity(TeslaFleetDeviceTrackerEntity): diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 6fdf4a6b6b7f..300c29ad0b3a 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.5.2"] + "requirements": ["tesla-fleet-api==1.7.2"] } diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py index 0df38ebf15b5..3b2ebac8d4e2 100644 --- a/homeassistant/components/tesla_fleet/media_player.py +++ b/homeassistant/components/tesla_fleet/media_player.py @@ -76,14 +76,11 @@ class TeslaFleetMediaEntity(TeslaFleetVehicleEntity, MediaPlayerEntity): self._attr_state = STATES.get( self.get("vehicle_state_media_info_media_playback_status") or "Off", ) + # volume_level is audio_volume / audio_volume_max, so one notch as a + # fraction of range is the per-notch increment divided by the max. self._attr_volume_step = ( - 1.0 - / self._volume_max - / ( - self.get("vehicle_state_media_info_audio_volume_increment") - or VOLUME_STEP - ) - ) + self.get("vehicle_state_media_info_audio_volume_increment") or VOLUME_STEP + ) / self._volume_max if volume := self.get("vehicle_state_media_info_audio_volume"): self._attr_volume_level = volume / self._volume_max diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index a4d08e49c6b6..fa8ccaf9c974 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorEntityStateAttribute, SensorStateClass, ) from homeassistant.const import ( @@ -526,7 +527,12 @@ class TeslaFleetVehicleSensorEntity(TeslaFleetVehicleEntity, RestoreSensor): if ( self.entity_description.key in CHARGE_ENERGY_RESET_KEYS and (last_state := await self.async_get_last_state()) is not None - and (last_reset := last_state.attributes.get("last_reset")) is not None + and ( + last_reset := last_state.attributes.get( + SensorEntityStateAttribute.LAST_RESET + ) + ) + is not None ): self._attr_last_reset = dt_util.parse_datetime(str(last_reset)) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 3e36a827e5c9..8b9cfe120569 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -609,6 +609,9 @@ "command_reason": { "message": "Command was unsuccessful: {reason}" }, + "homelink_no_location": { + "message": "Vehicle location is not available. Ensure the vehicle is awake and that the location scope has been granted." + }, "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature." }, diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 480441bf46b8..c5ca5d0c93b8 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -8,11 +8,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE from .coordinator import ( WallConnectorConfigEntry, WallConnectorCoordinator, WallConnectorData, - get_poll_interval, ) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -24,7 +24,11 @@ async def async_setup_entry( """Set up Tesla Wall Connector from a config entry.""" hostname = entry.data[CONF_HOST] - wall_connector = WallConnector(host=hostname, session=async_get_clientsession(hass)) + wall_connector = WallConnector( + host=hostname, + session=async_get_clientsession(hass), + split_phase=entry.options.get(CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE), + ) try: version_data = await wall_connector.async_get_version() @@ -45,16 +49,9 @@ 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 -async def update_listener(hass: HomeAssistant, entry: WallConnectorConfigEntry) -> None: - """Handle options update.""" - entry.runtime_data.update_coordinator.update_interval = get_poll_interval(entry) - - async def async_unload_entry( hass: HomeAssistant, entry: WallConnectorConfigEntry ) -> bool: diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py index f7965760ca5a..aa1b9c24c0bd 100644 --- a/homeassistant/components/tesla_wall_connector/config_flow.py +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -7,17 +7,57 @@ from tesla_wall_connector import WallConnector from tesla_wall_connector.exceptions import WallConnectorError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import BooleanSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME, WALLCONNECTOR_SERIAL_NUMBER +from .const import ( + CONF_SPLIT_PHASE, + DEFAULT_SPLIT_PHASE, + DOMAIN, + WALLCONNECTOR_DEVICE_NAME, + WALLCONNECTOR_SERIAL_NUMBER, +) +from .coordinator import WallConnectorConfigEntry _LOGGER = logging.getLogger(__name__) +class TeslaWallConnectorOptionsFlow(OptionsFlowWithReload): + """Handle Tesla Wall Connector options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry( + title="", + data={CONF_SPLIT_PHASE: user_input[CONF_SPLIT_PHASE]}, + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SPLIT_PHASE, + default=self.config_entry.options.get( + CONF_SPLIT_PHASE, DEFAULT_SPLIT_PHASE + ), + ): BooleanSelector(), + } + ), + ) + + async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. @@ -45,6 +85,15 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): super().__init__() self.ip_address: str | None = None + @staticmethod + @callback + @override + def async_get_options_flow( + _config_entry: WallConnectorConfigEntry, + ) -> TeslaWallConnectorOptionsFlow: + """Get the options flow.""" + return TeslaWallConnectorOptionsFlow() + @override async def async_step_dhcp( self, discovery_info: DhcpServiceInfo @@ -91,7 +140,12 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" data_schema = vol.Schema( - {vol.Required(CONF_HOST, default=self.ip_address): str} + { + vol.Required(CONF_HOST, default=self.ip_address): str, + vol.Optional( + CONF_SPLIT_PHASE, default=DEFAULT_SPLIT_PHASE + ): BooleanSelector(), + } ) if user_input is None: return self.async_show_form(step_id="user", data_schema=data_schema) @@ -110,11 +164,17 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): unique_id=info[WALLCONNECTOR_SERIAL_NUMBER], raise_on_progress=True ) self._abort_if_unique_id_configured( - updates=user_input, reload_on_update=True + updates={CONF_HOST: user_input[CONF_HOST]}, reload_on_update=True ) - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=info["title"], + data={CONF_HOST: user_input[CONF_HOST]}, + options={CONF_SPLIT_PHASE: user_input[CONF_SPLIT_PHASE]}, + ) return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema(data_schema, user_input), + errors=errors, ) diff --git a/homeassistant/components/tesla_wall_connector/const.py b/homeassistant/components/tesla_wall_connector/const.py index 2a660ee1aae9..4be4a41d6c1f 100644 --- a/homeassistant/components/tesla_wall_connector/const.py +++ b/homeassistant/components/tesla_wall_connector/const.py @@ -2,10 +2,18 @@ DOMAIN = "tesla_wall_connector" DEFAULT_SCAN_INTERVAL = 30 +DEFAULT_SPLIT_PHASE = False + +CONF_SPLIT_PHASE = "split_phase" WALLCONNECTOR_SERIAL_NUMBER = "serial_number" WALLCONNECTOR_DATA_VITALS = "vitals" WALLCONNECTOR_DATA_LIFETIME = "lifetime" +WALLCONNECTOR_DATA_WIFI_STATUS = "wifi_status" -WALLCONNECTOR_DEVICE_NAME = "Tesla Wall Connector" +WALLCONNECTOR_DEVICE_MANUFACTURER = "Tesla" +WALLCONNECTOR_DEVICE_MODEL = "Wall Connector" +WALLCONNECTOR_DEVICE_NAME = ( + f"{WALLCONNECTOR_DEVICE_MANUFACTURER} {WALLCONNECTOR_DEVICE_MODEL}" +) diff --git a/homeassistant/components/tesla_wall_connector/coordinator.py b/homeassistant/components/tesla_wall_connector/coordinator.py index 61e1416f5f6a..9d0080da0824 100644 --- a/homeassistant/components/tesla_wall_connector/coordinator.py +++ b/homeassistant/components/tesla_wall_connector/coordinator.py @@ -1,5 +1,6 @@ """DataUpdateCoordinator for the Tesla Wall Connector integration.""" +import asyncio from dataclasses import dataclass from datetime import timedelta import logging @@ -13,7 +14,6 @@ from tesla_wall_connector.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,6 +21,7 @@ from .const import ( DEFAULT_SCAN_INTERVAL, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS, + WALLCONNECTOR_DATA_WIFI_STATUS, ) _LOGGER = logging.getLogger(__name__) @@ -40,13 +41,6 @@ class WallConnectorData: serial_number: str -def get_poll_interval(entry: ConfigEntry) -> timedelta: - """Get the poll interval from config.""" - return timedelta( - seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) - - class WallConnectorCoordinator(DataUpdateCoordinator[dict]): """Class to manage fetching Tesla Wall Connector data.""" @@ -65,7 +59,7 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]): _LOGGER, config_entry=entry, name="tesla-wallconnector", - update_interval=get_poll_interval(entry), + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) self._hostname = hostname self._wall_connector = wall_connector @@ -74,8 +68,11 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]): async def _async_update_data(self) -> dict: """Fetch new data from the Wall Connector.""" try: - vitals = await self._wall_connector.async_get_vitals() - lifetime = await self._wall_connector.async_get_lifetime() + vitals, lifetime, wifi_status = await asyncio.gather( + self._wall_connector.async_get_vitals(), + self._wall_connector.async_get_lifetime(), + self._wall_connector.async_get_wifi_status(), + ) except WallConnectorConnectionTimeoutError as ex: raise UpdateFailed( f"Could not fetch data from Tesla WallConnector at {self._hostname}:" @@ -95,4 +92,5 @@ class WallConnectorCoordinator(DataUpdateCoordinator[dict]): return { WALLCONNECTOR_DATA_VITALS: vitals, WALLCONNECTOR_DATA_LIFETIME: lifetime, + WALLCONNECTOR_DATA_WIFI_STATUS: wifi_status, } diff --git a/homeassistant/components/tesla_wall_connector/entity.py b/homeassistant/components/tesla_wall_connector/entity.py index da412aeeeac9..5254654274cb 100644 --- a/homeassistant/components/tesla_wall_connector/entity.py +++ b/homeassistant/components/tesla_wall_connector/entity.py @@ -7,7 +7,12 @@ from typing import Any, override from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME +from .const import ( + DOMAIN, + WALLCONNECTOR_DEVICE_MANUFACTURER, + WALLCONNECTOR_DEVICE_MODEL, + WALLCONNECTOR_DEVICE_NAME, +) from .coordinator import WallConnectorCoordinator, WallConnectorData @@ -43,7 +48,9 @@ class WallConnectorEntity(CoordinatorEntity[WallConnectorCoordinator]): return DeviceInfo( identifiers={(DOMAIN, self.wall_connector_data.serial_number)}, name=WALLCONNECTOR_DEVICE_NAME, - model=self.wall_connector_data.part_number, + manufacturer=WALLCONNECTOR_DEVICE_MANUFACTURER, + model=WALLCONNECTOR_DEVICE_MODEL, + model_id=self.wall_connector_data.part_number, + serial_number=self.wall_connector_data.serial_number, sw_version=self.wall_connector_data.firmware_version, - manufacturer="Tesla", ) diff --git a/homeassistant/components/tesla_wall_connector/manifest.json b/homeassistant/components/tesla_wall_connector/manifest.json index d008d99f1c16..223f6e904d6a 100644 --- a/homeassistant/components/tesla_wall_connector/manifest.json +++ b/homeassistant/components/tesla_wall_connector/manifest.json @@ -1,7 +1,7 @@ { "domain": "tesla_wall_connector", "name": "Tesla Wall Connector", - "codeowners": ["@einarhauks"], + "codeowners": ["@einarhauks", "@sarabveer"], "config_flow": true, "dhcp": [ { @@ -21,5 +21,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["tesla_wall_connector"], - "requirements": ["tesla-wall-connector==1.1.0"] + "requirements": ["tesla-wall-connector==1.2.0"] } diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 9105ac493cc4..f76a4088a1a3 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -21,8 +22,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS +from .const import ( + WALLCONNECTOR_DATA_LIFETIME, + WALLCONNECTOR_DATA_VITALS, + WALLCONNECTOR_DATA_WIFI_STATUS, +) from .coordinator import WallConnectorConfigEntry, WallConnectorData from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin @@ -48,6 +54,8 @@ class WallConnectorSensorDescription( ): """Sensor entity description with a function pointer for getting sensor value.""" + suggested_object_id: str | None = None + WALL_CONNECTOR_SENSORS = [ WallConnectorSensorDescription( @@ -111,6 +119,15 @@ WALL_CONNECTOR_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + WallConnectorSensorDescription( + key="vehicle_current_a", + translation_key="vehicle_current_a", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].vehicle_current_a, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), WallConnectorSensorDescription( key="current_a_a", translation_key="current_a_a", @@ -185,12 +202,24 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="energy_kWh", + translation_key="energy_kwh", + suggested_object_id="energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda data: data[WALLCONNECTOR_DATA_LIFETIME].energy_wh, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + WallConnectorSensorDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + suggested_object_id="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + value_fn=lambda data: data[WALLCONNECTOR_DATA_WIFI_STATUS].wifi_rssi, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), ] @@ -226,7 +255,16 @@ class WallConnectorSensorEntity(WallConnectorEntity, SensorEntity): @property @override - def native_value(self): + def suggested_object_id(self) -> str | None: + """Return suggested object id.""" + if self.entity_description.suggested_object_id is not None: + return self.entity_description.suggested_object_id + + return super().suggested_object_id + + @property + @override + def native_value(self) -> StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 0269e85b1123..192d493d91e0 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -1,4 +1,8 @@ { + "common": { + "split_phase": "Single-phase / Split-phase electrical service", + "split_phase_description": "Enable if your Wall Connector is powered by single-phase / split-phase electrical service. This affects the calculation of the Total power sensor. Leave disabled for three-phase supply (default)." + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" @@ -11,10 +15,12 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]" + "host": "[%key:common::config_flow::data::host%]", + "split_phase": "[%key:component::tesla_wall_connector::common::split_phase%]" }, "data_description": { - "host": "Hostname or IP address of your Tesla Wall Connector." + "host": "Hostname or IP address of your Tesla Wall Connector.", + "split_phase": "[%key:component::tesla_wall_connector::common::split_phase_description%]" }, "title": "Configure Tesla Wall Connector" } @@ -39,6 +45,9 @@ "current_c_a": { "name": "Phase C current" }, + "energy_kwh": { + "name": "Lifetime energy" + }, "grid_hz": { "name": "Grid frequency" }, @@ -78,6 +87,9 @@ "total_power_w": { "name": "Total power" }, + "vehicle_current_a": { + "name": "Vehicle current" + }, "voltage_a_v": { "name": "Phase A voltage" }, @@ -86,6 +98,21 @@ }, "voltage_c_v": { "name": "Phase C voltage" + }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "split_phase": "[%key:component::tesla_wall_connector::common::split_phase%]" + }, + "data_description": { + "split_phase": "[%key:component::tesla_wall_connector::common::split_phase_description%]" + } } } } diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 26669ce64bf9..7ae42ab703ea 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -5,11 +5,12 @@ from collections.abc import Callable from functools import partial from typing import Any, Final, cast -from aiohttp import ClientError, ClientResponseError +from aiohttp import ClientError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, InvalidToken, + LoginRequired, SubscriptionRequired, TeslaFleetError, ) @@ -20,10 +21,15 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -89,18 +95,32 @@ async def _get_access_token(oauth_session: OAuth2Session) -> str: oauth_session.valid_token, oauth_session.token.get("expires_at"), ) + setup_in_progress = ( + oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS + ) try: await oauth_session.async_ensure_token_valid() - except ClientResponseError as err: - if err.status == 401: + except OAuth2TokenRequestReauthError as err: + if setup_in_progress: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="auth_failed", ) from err - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="not_ready_connection_error", - ) from err + # Not in setup: let the coordinator's own OAuth2TokenRequestError + # handling stop polling and (re)start reauth without tearing + # down the already-loaded entry. + oauth_session.config_entry.async_start_reauth(oauth_session.hass) + raise + except OAuth2TokenRequestError as err: + # Recoverable (e.g. 429/5xx). During setup this backs off via the + # normal ConfigEntryNotReady retry; once loaded, let it propagate so + # the coordinator treats it as a transient failed update instead. + if setup_in_progress: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="not_ready_connection_error", + ) from err + raise except (KeyError, TypeError) as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -265,6 +285,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - translation_domain=DOMAIN, translation_key="auth_failed_invalid_token", ) from e + except LoginRequired as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed_login_required", + ) from e except SubscriptionRequired as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -402,6 +427,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - translation_domain=DOMAIN, translation_key="auth_failed_invalid_token", ) from e + except LoginRequired as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed_login_required", + ) from e except SubscriptionRequired as e: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -478,10 +508,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - identifier in current_devices for identifier in device_entry.identifiers ): LOGGER.debug("Removing stale device %s", device_entry.id) - device_registry.async_update_device( - device_id=device_entry.id, - remove_config_entry_id=entry.entry_id, - ) + device_registry.async_remove_device(device_entry.id) entry.runtime_data = TeslemetryData( vehicles=vehicles, diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 05a92944e65e..e64af7b6e078 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast, override +from tesla_fleet_api import firmware_at_least from teslemetry_stream.vehicle import TeslemetryStreamVehicle from homeassistant.components.binary_sensor import ( @@ -557,7 +558,7 @@ async def async_setup_entry( if ( not vehicle.poll and description.streaming_listener - and vehicle.firmware >= description.streaming_firmware + and firmware_at_least(vehicle.firmware, description.streaming_firmware) ): entities.append( TeslemetryVehicleStreamingBinarySensorEntity(vehicle, description) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 3890e029cd1d..a06b9a9610bb 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -3,6 +3,7 @@ from itertools import chain from typing import Any, cast, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope from tesla_fleet_api.teslemetry import Vehicle @@ -11,6 +12,7 @@ from homeassistant.components.climate import ( HVAC_MODES, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACMode, ) from homeassistant.const import ( @@ -65,7 +67,7 @@ async def async_setup_entry( TeslemetryVehiclePollingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.44.25" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25") else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) @@ -75,7 +77,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.44.25" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25") else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) @@ -212,7 +214,7 @@ class TeslemetryVehiclePollingClimateEntity( value = self.get("climate_state_is_climate_on") if value is None: self._attr_hvac_mode = None - if value: + elif value: self._attr_hvac_mode = HVACMode.HEAT_COOL else: self._attr_hvac_mode = HVACMode.OFF @@ -286,9 +288,15 @@ class TeslemetryStreamingClimateEntity( self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("current_temperature") - self._attr_target_temperature = state.attributes.get("temperature") - self._attr_preset_mode = state.attributes.get("preset_mode") + self._attr_current_temperature = state.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) + self._attr_target_temperature = state.attributes.get( + ClimateEntityStateAttribute.TEMPERATURE + ) + self._attr_preset_mode = state.attributes.get( + ClimateEntityStateAttribute.PRESET_MODE + ) self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( @@ -530,8 +538,12 @@ class TeslemetryStreamingCabinOverheatProtectionEntity( self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("temperature") - self._attr_target_temperature = state.attributes.get("target_temperature") + self._attr_current_temperature = state.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) + self._attr_target_temperature = state.attributes.get( + ClimateEntityStateAttribute.TEMPERATURE + ) self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 0dffd2b73f6b..fa80e55ddb6f 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, override from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( GatewayTimeout, + InsufficientCredits, InvalidResponse, InvalidToken, LoginRequired, @@ -49,6 +50,10 @@ ENERGY_INFO_INTERVAL = timedelta(seconds=30) ENERGY_HISTORY_INTERVAL = timedelta(seconds=60) METADATA_INTERVAL = timedelta(hours=1) +# Insufficient credits will not resolve themselves quickly, so back off polling +# instead of hammering the API at the coordinator's normal interval. +INSUFFICIENT_CREDITS_RETRY_AFTER = timedelta(hours=1).total_seconds() + ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, VehicleDataEndpoint.CLIMATE_STATE, @@ -139,6 +144,12 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e + except InsufficientCredits as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed_insufficient_credits", + retry_after=INSUFFICIENT_CREDITS_RETRY_AFTER, + ) from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 55340c92e416..c718f3db8ffb 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -3,6 +3,7 @@ from itertools import chain from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import Signal @@ -43,7 +44,7 @@ async def async_setup_entry( chain( ( TeslemetryVehiclePollingWindowEntity(vehicle, entry.runtime_data.scopes) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ), @@ -51,7 +52,7 @@ async def async_setup_entry( TeslemetryVehiclePollingChargePortEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.44.25" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25") else TeslemetryStreamingChargePortEntity( vehicle, entry.runtime_data.scopes ) @@ -61,7 +62,7 @@ async def async_setup_entry( TeslemetryVehiclePollingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -71,7 +72,7 @@ async def async_setup_entry( TeslemetryVehiclePollingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -282,7 +283,8 @@ class TeslemetryVehiclePollingChargePortEntity( @override def _async_update_attrs(self) -> None: """Update the entity attributes.""" - self._attr_is_closed = not self._value + value = self._value + self._attr_is_closed = None if value is None else not value class TeslemetryStreamingChargePortEntity( @@ -355,7 +357,8 @@ class TeslemetryVehiclePollingFrontTrunkEntity( @override def _async_update_attrs(self) -> None: """Update the entity attributes.""" - self._attr_is_closed = self._value == CLOSED + value = self._value + self._attr_is_closed = None if value is None else value == CLOSED class TeslemetryStreamingFrontTrunkEntity( @@ -429,7 +432,8 @@ class TeslemetryVehiclePollingRearTrunkEntity( @override def _async_update_attrs(self) -> None: """Update the entity attributes.""" - self._attr_is_closed = self._value == CLOSED + value = self._value + self._attr_is_closed = None if value is None else value == CLOSED class TeslemetryStreamingRearTrunkEntity( diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 4dfeec2776f1..97dcd8b1515d 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -4,6 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from teslemetry_stream import TeslemetryStreamVehicle from teslemetry_stream.const import TeslaLocation @@ -12,6 +13,7 @@ from homeassistant.components.device_tracker import ( TrackerEntity, TrackerEntityDescription, ) +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -78,7 +80,9 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in DESCRIPTIONS: - if vehicle.poll or vehicle.firmware < description.streaming_firmware: + if vehicle.poll or not firmware_at_least( + vehicle.firmware, description.streaming_firmware + ): if description.polling_prefix: entities.append( TeslemetryVehiclePollingDeviceTrackerEntity( @@ -144,8 +148,8 @@ class TeslemetryStreamingDeviceTrackerEntity( """Handle entity which will be added.""" await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: - self._attr_latitude = state.attributes.get("latitude") - self._attr_longitude = state.attributes.get("longitude") + self._attr_latitude = state.attributes.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = state.attributes.get(EntityStateAttribute.LONGITUDE) self.async_on_remove( self.entity_description.value_listener( self.vehicle.stream_vehicle, self._location_callback diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index c86220f45202..cf578db2ff95 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -3,6 +3,7 @@ from itertools import chain from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import Vehicle @@ -40,7 +41,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) @@ -50,7 +51,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index d56b3c6a30e0..a446a43e20bf 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "integration_type": "hub", "iot_class": "cloud_polling", - "loggers": ["tesla-fleet-api"], + "loggers": ["tesla_fleet_api", "teslemetry_stream"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==1.5.2", "teslemetry-stream==0.9.1"] + "requirements": ["tesla-fleet-api==1.7.2", "teslemetry-stream==0.9.1"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 561ae8251835..5c1d332c103d 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -2,6 +2,7 @@ from typing import override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import Vehicle @@ -9,6 +10,7 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, MediaPlayerState, ) from homeassistant.core import HomeAssistant @@ -53,7 +55,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes) - if vehicle.poll or vehicle.firmware < "2025.2.6" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2025.2.6") else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) @@ -200,14 +202,30 @@ class TeslemetryStreamingMediaEntity( self._attr_state = MediaPlayerState(state.state) except ValueError: self._attr_state = None - self._attr_volume_level = state.attributes.get("volume_level") - self._attr_media_title = state.attributes.get("media_title") - self._attr_media_artist = state.attributes.get("media_artist") - self._attr_media_album_name = state.attributes.get("media_album_name") - self._attr_media_playlist = state.attributes.get("media_playlist") - self._attr_media_duration = state.attributes.get("media_duration") - self._attr_media_position = state.attributes.get("media_position") - self._attr_source = state.attributes.get("source") + self._attr_volume_level = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL + ) + self._attr_media_title = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_TITLE + ) + self._attr_media_artist = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_ARTIST + ) + self._attr_media_album_name = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_ALBUM_NAME + ) + self._attr_media_playlist = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_PLAYLIST + ) + self._attr_media_duration = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_DURATION + ) + self._attr_media_position = state.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_POSITION + ) + self._attr_source = state.attributes.get( + MediaPlayerEntityStateAttribute.INPUT_SOURCE + ) self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 88143c3e5d0c..1588bbe83e78 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -142,7 +143,7 @@ async def async_setup_entry( description, entry.runtime_data.scopes, ) - if vehicle.poll or vehicle.firmware < "2024.26" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.26") else TeslemetryStreamingNumberEntity( vehicle, description, diff --git a/homeassistant/components/teslemetry/quality_scale.yaml b/homeassistant/components/teslemetry/quality_scale.yaml index 941b7ba6779a..f62afec7093b 100644 --- a/homeassistant/components/teslemetry/quality_scale.yaml +++ b/homeassistant/components/teslemetry/quality_scale.yaml @@ -59,9 +59,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: No issues to repair + repair-issues: done stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 2803c71ccf6b..3601c2d353ff 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -176,6 +177,33 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( HIGH, ], ), + TeslemetrySelectEntityDescription( + # remote_seat_cooler_request uses 1-indexed positions (front-left=1, + # front-right=2), unlike the 0-indexed Seat enum used for heaters. + # Polled state comes from the seat_fan_front_* vehicle_data fields. + key="climate_state_seat_fan_front_left", + select_fn=lambda api, level: api.remote_seat_cooler_request(1, level), + supported_fn=lambda data: bool(data.get("has_seat_cooling")), + streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontLeft(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), + TeslemetrySelectEntityDescription( + key="climate_state_seat_fan_front_right", + select_fn=lambda api, level: api.remote_seat_cooler_request(2, level), + supported_fn=lambda data: bool(data.get("has_seat_cooling")), + streaming_listener=lambda x, y: x.listen_ClimateSeatCoolingFrontRight(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), ) @@ -193,7 +221,7 @@ async def async_setup_entry( vehicle, description, entry.runtime_data.scopes ) if vehicle.poll - or vehicle.firmware < "2024.26" + or not firmware_at_least(vehicle.firmware, "2024.26") or description.streaming_listener is None else TeslemetryStreamingSelectEntity( vehicle, description, entry.runtime_data.scopes @@ -261,10 +289,14 @@ class TeslemetryVehiclePollingSelectEntity( def _async_update_attrs(self) -> None: """Handle updated data from the coordinator.""" self._climate = bool(self.get("climate_state_is_climate_on")) - if not isinstance(self._value, int): - self._attr_current_option = None + value = self._value + # Defensive clamp: Tesla could report a level outside the modeled + # range, so map it to the nearest known option rather than erroring. + if isinstance(value, int): + options = self.entity_description.options + self._attr_current_option = options[max(0, min(value, len(options) - 1))] else: - self._attr_current_option = self.entity_description.options[self._value] + self._attr_current_option = None class TeslemetryStreamingSelectEntity( @@ -308,10 +340,13 @@ class TeslemetryStreamingSelectEntity( def _value_callback(self, value: int | None) -> None: """Update the value of the entity.""" - if value is None: - self._attr_current_option = None + # Defensive clamp: Tesla could report a level outside the modeled + # range, so map it to the nearest known option rather than erroring. + if isinstance(value, int): + options = self.entity_description.options + self._attr_current_option = options[max(0, min(value, len(options) - 1))] else: - self._attr_current_option = self.entity_description.options[value] + self._attr_current_option = None self.async_write_ha_state() def _climate_callback(self, value: bool | None) -> None: diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index d35c30863537..070f87c7848e 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, override +from tesla_fleet_api import firmware_at_least from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle from homeassistant.components.sensor import ( @@ -1605,16 +1606,15 @@ async def async_setup_entry( if ( not vehicle.poll and description.streaming_listener - and vehicle.firmware >= description.streaming_firmware + and firmware_at_least(vehicle.firmware, description.streaming_firmware) ): entities.append(TeslemetryStreamSensorEntity(vehicle, description)) elif description.polling: entities.append(TeslemetryVehicleSensorEntity(vehicle, description)) for time_description in VEHICLE_TIME_DESCRIPTIONS: - if ( - not vehicle.poll - and vehicle.firmware >= time_description.streaming_firmware + if not vehicle.poll and firmware_at_least( + vehicle.firmware, time_description.streaming_firmware ): entities.append( TeslemetryStreamTimeSensorEntity(vehicle, time_description) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index ffb9e9d9ccbd..84957296f254 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -363,6 +363,24 @@ } }, "select": { + "climate_state_seat_fan_front_left": { + "name": "Seat cooler front left", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "climate_state_seat_fan_front_right": { + "name": "Seat cooler front right", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, "climate_state_seat_heater_left": { "name": "Seat heater front left", "state": { @@ -1113,6 +1131,9 @@ "auth_failed_invalid_token": { "message": "Access token is invalid, please reauthenticate" }, + "auth_failed_login_required": { + "message": "Login is no longer valid, please reauthenticate" + }, "auth_failed_migration": { "message": "Failed to migrate to OAuth, please reauthenticate" }, @@ -1179,6 +1200,9 @@ "update_failed": { "message": "Error fetching data from Teslemetry API: {message}" }, + "update_failed_insufficient_credits": { + "message": "Teslemetry account has insufficient command credits, pausing updates until credits are added" + }, "update_failed_invalid_data": { "message": "Received invalid data from API" }, diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index a9ac7a0ec3ea..05b3ea75c696 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import AutoSeat, Scope from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle @@ -162,7 +163,9 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: - if vehicle.poll or vehicle.firmware < description.streaming_firmware: + if vehicle.poll or not firmware_at_least( + vehicle.firmware, description.streaming_firmware + ): if description.polling: entities.append( TeslemetryVehiclePollingVehicleSwitchEntity( diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 2a61df1fd095..c592070bba43 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -2,10 +2,15 @@ from typing import Any, override +from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import Scope from tesla_fleet_api.teslemetry import Vehicle -from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityFeature, + UpdateEntityStateAttribute, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -37,7 +42,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes) - if vehicle.poll or vehicle.firmware < "2024.44.25" + if vehicle.poll or not firmware_at_least(vehicle.firmware, "2024.44.25") else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) @@ -134,6 +139,7 @@ class TeslemetryStreamingUpdateEntity( _download_percentage: int = 0 _install_percentage: int = 0 + _scheduled: bool = False def __init__( self, @@ -152,15 +158,24 @@ class TeslemetryStreamingUpdateEntity( """Handle entity which will be added.""" await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: - self._attr_in_progress = state.attributes.get("in_progress", False) - self._install_percentage = state.attributes.get("install_percentage", False) - self._attr_installed_version = state.attributes.get("installed_version") - self._attr_latest_version = state.attributes.get("latest_version") + self._attr_in_progress = state.attributes.get( + UpdateEntityStateAttribute.IN_PROGRESS, False + ) + self._attr_update_percentage = state.attributes.get( + UpdateEntityStateAttribute.UPDATE_PERCENTAGE + ) + self._attr_installed_version = state.attributes.get( + UpdateEntityStateAttribute.INSTALLED_VERSION + ) + self._attr_latest_version = state.attributes.get( + UpdateEntityStateAttribute.LATEST_VERSION + ) self._attr_supported_features = UpdateEntityFeature( state.attributes.get( "supported_features", self._attr_supported_features ) ) + self._scheduled = self._attr_in_progress self.async_write_ha_state() self.async_on_remove( @@ -216,7 +231,8 @@ class TeslemetryStreamingUpdateEntity( ) -> None: """Handle software update scheduled start time.""" - self._attr_in_progress = value is not None + self._scheduled = value is not None + self._async_update_progress() self.async_write_ha_state() def _async_handle_software_update_version(self, value: str | None) -> None: @@ -237,12 +253,12 @@ class TeslemetryStreamingUpdateEntity( def _async_update_progress(self) -> None: """Update the progress of the update.""" - if 1 < self._download_percentage < 100: + if 0 < self._download_percentage < 100: self._attr_in_progress = True self._attr_update_percentage = self._download_percentage - elif self._install_percentage > 10: + elif 10 < self._install_percentage < 100: self._attr_in_progress = True self._attr_update_percentage = self._install_percentage else: - self._attr_in_progress = False + self._attr_in_progress = self._scheduled self._attr_update_percentage = None diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 7237255375fa..30042482cb1b 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -50,6 +50,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TEXT, Platform.UPDATE, ] diff --git a/homeassistant/components/tessie/icons.json b/homeassistant/components/tessie/icons.json index b90af3ddff03..4ba74f3eb330 100644 --- a/homeassistant/components/tessie/icons.json +++ b/homeassistant/components/tessie/icons.json @@ -291,6 +291,11 @@ "vehicle_state_valet_mode": { "default": "mdi:bow-tie" } + }, + "text": { + "navigation_destination": { + "default": "mdi:map-marker" + } } } } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 7d653e6d4019..f47f3a18f8dc 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "silver", - "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.5.2"] + "requirements": ["tessie-api==0.1.3", "tesla-fleet-api==1.7.2"] } diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 9be7124a85c8..36cce2ef326c 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -612,6 +612,11 @@ "name": "Valet mode" } }, + "text": { + "navigation_destination": { + "name": "Navigation destination" + } + }, "update": { "update": { "name": "[%key:component::update::title%]" diff --git a/homeassistant/components/tessie/text.py b/homeassistant/components/tessie/text.py new file mode 100644 index 000000000000..0168c579a48b --- /dev/null +++ b/homeassistant/components/tessie/text.py @@ -0,0 +1,42 @@ +"""Text platform for Tessie integration.""" + +from typing import override + +from homeassistant.components.text import TextEntity, TextMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TessieConfigEntry +from .entity import TessieEntity +from .models import TessieVehicleData + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tessie Text platform from a config entry.""" + async_add_entities( + TessieNavigationTextEntity(vehicle) for vehicle in entry.runtime_data.vehicles + ) + + +class TessieNavigationTextEntity(TessieEntity, TextEntity): + """Text entity to send a navigation destination to the vehicle.""" + + _attr_mode = TextMode.TEXT + _attr_native_max = 255 + _attr_native_min = 1 + _attr_native_value: str | None = None + + def __init__(self, vehicle: TessieVehicleData) -> None: + """Initialize the navigation text entity.""" + super().__init__(vehicle, "navigation_destination") + + @override + async def async_set_value(self, value: str) -> None: + """Send a navigation destination to the vehicle.""" + await self.run(self.api.navigation_request(value)) diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index eebfd29452b8..911f3c6aa391 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -41,7 +41,13 @@ SCAN_INTERVAL = timedelta(seconds=30) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) -__all__ = ["DOMAIN", "TextEntity", "TextEntityDescription", "TextMode"] +__all__ = [ + "DOMAIN", + "TextEntity", + "TextEntityCapabilityAttribute", + "TextEntityDescription", + "TextMode", +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py index 9a7467bdcd55..dc56b848e72e 100644 --- a/homeassistant/components/thermopro/__init__.py +++ b/homeassistant/components/thermopro/__init__.py @@ -1,4 +1,5 @@ """The ThermoPro Bluetooth integration.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from functools import partial import logging diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 850c5ec37ccc..a6ddb29099b5 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -36,6 +36,7 @@ KNOWN_BRANDS: dict[str | None, str] = { "OpenThread": "openthread", "Samsung": "samsung", "SmartThings": "smartthings", + "SMLIGHT": "smlight", "Yeelight": "yeelight", } THREAD_TYPE = "_meshcop._udp.local." diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 695d73859603..1be37133e03e 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 8bca15ad6978..de19c3cf1e71 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -7,7 +7,7 @@ from typing import Any, Self, override import voluptuous as vol -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_ENTITY_ID, CONF_ICON, @@ -26,6 +26,8 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.util import dt as dt_util +from .const import TimerEntityStateAttribute + _LOGGER = logging.getLogger(__name__) DOMAIN = "timer" @@ -256,16 +258,20 @@ class Timer(collection.CollectionEntity, RestoreEntity): def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" attrs: dict[str, Any] = { - ATTR_DURATION: _format_timedelta(self._running_duration), - ATTR_EDITABLE: self.editable, - ATTR_LAST_TRANSITION: self._last_transition, + TimerEntityStateAttribute.DURATION: _format_timedelta( + self._running_duration + ), + TimerEntityStateAttribute.EDITABLE: self.editable, + TimerEntityStateAttribute.LAST_TRANSITION: self._last_transition, } if self._end is not None: - attrs[ATTR_FINISHES_AT] = self._end.isoformat() + attrs[TimerEntityStateAttribute.FINISHES_AT] = self._end.isoformat() if self._remaining is not None: - attrs[ATTR_REMAINING] = _format_timedelta(self._remaining) + attrs[TimerEntityStateAttribute.REMAINING] = _format_timedelta( + self._remaining + ) if self._restore: - attrs[ATTR_RESTORE] = self._restore + attrs[TimerEntityStateAttribute.RESTORE] = self._restore return attrs @@ -286,20 +292,26 @@ class Timer(collection.CollectionEntity, RestoreEntity): # Begin restoring state self._state = state.state - self._last_transition = state.attributes.get(ATTR_LAST_TRANSITION) + self._last_transition = state.attributes.get( + TimerEntityStateAttribute.LAST_TRANSITION + ) # Nothing more to do if the timer is idle if self._state == STATUS_IDLE: return - self._running_duration = cv.time_period(state.attributes[ATTR_DURATION]) + self._running_duration = cv.time_period( + state.attributes[TimerEntityStateAttribute.DURATION] + ) # If the timer was paused, we restore the remaining time if self._state == STATUS_PAUSED: - self._remaining = cv.time_period(state.attributes[ATTR_REMAINING]) + self._remaining = cv.time_period( + state.attributes[TimerEntityStateAttribute.REMAINING] + ) return # If we get here, the timer must have been active so we need to decide what # to do based on end time and the current time - end = cv.datetime(state.attributes[ATTR_FINISHES_AT]) + end = cv.datetime(state.attributes[TimerEntityStateAttribute.FINISHES_AT]) # If there is time remaining in the timer, restore the remaining time then # start the timer if (remaining := end - dt_util.utcnow().replace(microsecond=0)) > timedelta(0): diff --git a/homeassistant/components/timer/const.py b/homeassistant/components/timer/const.py new file mode 100644 index 000000000000..93480189f2dc --- /dev/null +++ b/homeassistant/components/timer/const.py @@ -0,0 +1,14 @@ +"""Constants for the timer integration.""" + +from enum import StrEnum + + +class TimerEntityStateAttribute(StrEnum): + """State attributes for timer entities.""" + + DURATION = "duration" + EDITABLE = "editable" + LAST_TRANSITION = "last_transition" + FINISHES_AT = "finishes_at" + REMAINING = "remaining" + RESTORE = "restore" diff --git a/homeassistant/components/timer/reproduce_state.py b/homeassistant/components/timer/reproduce_state.py index 95cec586c3d9..10bef2a774d1 100644 --- a/homeassistant/components/timer/reproduce_state.py +++ b/homeassistant/components/timer/reproduce_state.py @@ -17,6 +17,7 @@ from . import ( STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED, + TimerEntityStateAttribute, ) _LOGGER = logging.getLogger(__name__) @@ -45,15 +46,17 @@ async def _async_reproduce_state( # Return if we are already at the right state. if cur_state.state == state.state and cur_state.attributes.get( ATTR_DURATION - ) == state.attributes.get(ATTR_DURATION): + ) == state.attributes.get(TimerEntityStateAttribute.DURATION): return service_data = {ATTR_ENTITY_ID: state.entity_id} if state.state == STATUS_ACTIVE: service = SERVICE_START - if ATTR_DURATION in state.attributes: - service_data[ATTR_DURATION] = state.attributes[ATTR_DURATION] + if TimerEntityStateAttribute.DURATION in state.attributes: + service_data[ATTR_DURATION] = state.attributes[ + TimerEntityStateAttribute.DURATION + ] elif state.state == STATUS_PAUSED: service = SERVICE_PAUSE elif state.state == STATUS_IDLE: diff --git a/homeassistant/components/timer/trigger.py b/homeassistant/components/timer/trigger.py index 8f4152ce507f..28349cff3b2a 100644 --- a/homeassistant/components/timer/trigger.py +++ b/homeassistant/components/timer/trigger.py @@ -26,7 +26,8 @@ from homeassistant.helpers.trigger import ( from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE +from . import DOMAIN, STATUS_ACTIVE +from .const import TimerEntityStateAttribute CONF_REMAINING = "remaining" @@ -86,7 +87,9 @@ class TimeRemainingTrigger(Trigger): if to_state.state != STATUS_ACTIVE: return - finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT) + finishes_at_str = to_state.attributes.get( + TimerEntityStateAttribute.FINISHES_AT + ) if finishes_at_str is None: return @@ -166,19 +169,24 @@ class TimeRemainingTrigger(Trigger): TRIGGERS: dict[str, type[Trigger]] = { "cancelled": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "cancelled" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "cancelled", ), "finished": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "finished" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "finished", ), "paused": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "paused" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "paused", ), "restarted": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "restarted" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "restarted", ), "started": make_entity_target_state_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started" + {DOMAIN: DomainSpec(value_source=TimerEntityStateAttribute.LAST_TRANSITION)}, + "started", ), "remaining_time_reached": TimeRemainingTrigger, } diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py new file mode 100644 index 000000000000..189c03040fb2 --- /dev/null +++ b/homeassistant/components/todo/llm.py @@ -0,0 +1,122 @@ +"""LLM tools for the todo integration.""" + +from operator import attrgetter +from typing import Any, cast, override + +import voluptuous as vol + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + IntentTool, + LLMContext, + Tool, + ToolInput, +) +from homeassistant.util.json import JsonObjectType + +from .const import DOMAIN, TodoServices +from .intent import ( + INTENT_LIST_ADD_ITEM, + INTENT_LIST_COMPLETE_ITEM, + INTENT_LIST_REMOVE_ITEM, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = (INTENT_LIST_ADD_ITEM, INTENT_LIST_COMPLETE_ITEM, INTENT_LIST_REMOVE_ITEM) + + +class TodoGetItemsTool(Tool): + """LLM Tool allowing querying a to-do list.""" + + name = "todo_get_items" + description = ( + "Query a to-do list to find out what items are on it. " + "Use this to answer questions like " + "'What's on my task list?' or " + "'Read my grocery list'. " + "Filters items by status (needs_action, completed, all)." + ) + + def __init__(self, todo_lists: list[str]) -> None: + """Init the get items tool.""" + self.parameters = vol.Schema( + { + vol.Required("todo_list"): vol.In(todo_lists), + vol.Optional( + "status", + description=( + "Filter returned items by status," + " by default returns incomplete" + " items" + ), + default="needs_action", + ): vol.In(["needs_action", "completed", "all"]), + } + ) + + @override + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Query a to-do list.""" + data = self.parameters(tool_input.tool_args) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name=data["todo_list"], + domains=[DOMAIN], + assistant=llm_context.assistant, + ), + ) + if not result.is_match: + return {"success": False, "error": "To-do list not found"} + entity_id = result.states[0].entity_id + service_data: dict[str, Any] = {"entity_id": entity_id} + status = data["status"] + # "all" means no status filter, which returns every item. + if status != "all": + service_data["status"] = status + service_result = await hass.services.async_call( + DOMAIN, + TodoServices.GET_ITEMS, + service_data, + context=llm_context.context, + blocking=True, + return_response=True, + ) + if not service_result: + return {"success": False, "error": "To-do list not found"} + items = cast(dict, service_result)[entity_id]["items"] + return {"success": True, "result": items} + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return the todo LLM tools when a to-do list is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + entity_registry = er.async_get(hass) + names: list[str] = [] + for state in sorted(hass.states.async_all(DOMAIN), key=attrgetter("name")): + if not async_should_expose(hass, llm_context.assistant, state.entity_id): + continue + entity_entry = entity_registry.async_get(state.entity_id) + names.extend(intent.async_get_entity_aliases(hass, entity_entry, state=state)) + + if not names: + return None + + tools: list[Tool] = [TodoGetItemsTool(names)] + tools.extend( + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ) + return LLMTools(tools=tools) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index d03dc77a62f1..7067858ad264 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -21,11 +21,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import ( CONF_API_KEY, - CONF_FRIENDLY_NAME, CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -141,7 +141,9 @@ class TomorrowioConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_NAME] = DEFAULT_NAME # Append zone name if it exists and we are using the default name if zone_state := async_active_zone(self.hass, latitude, longitude): - zone_name = zone_state.attributes[CONF_FRIENDLY_NAME] + zone_name = zone_state.attributes[ + EntityStateAttribute.FRIENDLY_NAME + ] user_input[CONF_NAME] += f" - {zone_name}" try: await TomorrowioV4( diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 699bb8a7d762..af29c09a021a 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2025.12.2"] + "requirements": ["total-connect-client==2026.7"] } diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py index c77237fca686..c1d64ce0cf9e 100644 --- a/homeassistant/components/tplink/vacuum.py +++ b/homeassistant/components/tplink/vacuum.py @@ -94,7 +94,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): _attr_supported_features = ( VacuumEntityFeature.STATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.START | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME @@ -152,12 +151,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): """Locate the device.""" await self._speaker_module.locate() - @property - @override - def battery_level(self) -> int | None: - """Return battery level.""" - return self._vacuum_module.battery - @override def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 559f9eab1851..a782ae004538 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -98,9 +98,7 @@ def _remove_old_devices( (i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None ) if mac and mac not in omada_devices: - device_registry.async_update_device( - registered_device.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(registered_device.id) async def async_migrate_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool: diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 27ad50855a7d..8aa8663670ab 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["tplink-omada-client==1.5.8"] + "requirements": ["tplink-omada-client==1.5.9"] } diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index e8c151179ce5..9126e0d9db5d 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -1,4 +1,5 @@ """Support for Traccar Client.""" +# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from http import HTTPStatus from json import JSONDecodeError diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 45faad54767f..d260410f4338 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -5,8 +5,12 @@ from datetime import timedelta import logging from typing import override -from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -16,12 +20,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, TRACKER_UPDATE from .const import ( - ATTR_ACCURACY, ATTR_ALTITUDE, - ATTR_BATTERY, ATTR_BEARING, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_SPEED, EVENT_ALARM, EVENT_ALL_EVENTS, @@ -162,15 +162,17 @@ class TraccarEntity(TrackerEntity, RestoreEntity): return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) - self._attr_location_accuracy = attr.get(ATTR_ACCURACY, 0) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) + self._attr_location_accuracy = attr.get( + TrackerEntityStateAttribute.GPS_ACCURACY, 0 + ) self._attr_extra_state_attributes = { ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), ATTR_BEARING: attr.get(ATTR_BEARING), ATTR_SPEED: attr.get(ATTR_SPEED), } - self._battery = attr.get(ATTR_BATTERY) + self._battery = attr.get(ATTR_BATTERY_LEVEL) @override async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index c5a8549e91c0..a3f721fe6689 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -34,7 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 48a7e5212f00..1dc7709a84a1 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -78,9 +78,7 @@ async def cleanup_device_registry( ): for item in device_entry.identifiers: if item[0] == DOMAIN and item[1] not in device_manager.device_map: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) break diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py index 7579b803286e..ba3f7115438e 100644 --- a/homeassistant/components/tuya/coordinator.py +++ b/homeassistant/components/tuya/coordinator.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Any +import requests from tuya_device_handlers import TUYA_QUIRKS_REGISTRY from tuya_device_handlers.devices import register_tuya_quirks from tuya_sharing import ( @@ -14,7 +15,7 @@ from tuya_sharing import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send @@ -80,6 +81,9 @@ class DeviceListener(SharingDeviceListener): # Get all devices from Tuya, makes blocking web calls try: manager.update_device_cache() + except requests.exceptions.ConnectionError as exc: + msg = "Unable to connect to Tuya" + raise ConfigEntryNotReady(msg) from exc except Exception as exc: # While in general, we should avoid catching broad exceptions, # we have no other way of detecting this case. diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 18f5551f7fbd..5e9076c213ec 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -44,7 +44,7 @@ "iot_class": "cloud_push", "loggers": ["tuya_sharing"], "requirements": [ - "tuya-device-handlers==0.0.24", + "tuya-device-handlers==0.0.25", "tuya-device-sharing-sdk==0.2.10" ] } diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index f3d088b42b1c..7f39d1207920 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -1,5 +1,6 @@ """Constants for the UniFi Network integration.""" +from datetime import timedelta import logging from aiounifi.models.device import DeviceState @@ -9,6 +10,12 @@ from homeassistant.const import Platform LOGGER = logging.getLogger(__package__) DOMAIN = "unifi" +# The UniFi controller keeps a record of every client it has ever seen. On busy +# or guest networks that is easily tens of thousands of drive-by devices. +# Only inactive clients seen within this window are restored on startup, older +# ones are pruned together with their device so the registry stops growing. +CLIENT_RESTORE_MAX_AGE = timedelta(days=30) + PLATFORMS = [ Platform.BUTTON, Platform.DEVICE_TRACKER, diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 067b8a44865f..14a1375a73b2 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -6,19 +6,21 @@ Make sure expected clients are available for platforms. import asyncio from collections.abc import Callable, Coroutine, Sequence -from datetime import timedelta +from datetime import datetime, timedelta from functools import partial from typing import TYPE_CHECKING, Any from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent +from aiounifi.models.client import Client from homeassistant.const import Platform from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util -from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS +from ..const import CLIENT_RESTORE_MAX_AGE, LOGGER, UNIFI_WIRELESS_CLIENTS from ..coordinator import UnifiDataUpdateCoordinator from ..entity import UnifiEntity, UnifiEntityDescription @@ -102,24 +104,77 @@ class UnifiEntityLoader: @callback def _restore_inactive_clients(self) -> None: - """Restore inactive clients. + """Restore recently seen inactive clients and prune stale ones. - Provide inactive clients to device tracker and switch platform. + The UniFi controller keeps a record of every client it has ever seen. + Only clients seen within the retention window, or explicitly selected + or blocked, are restored. Trackers falling outside that window are + removed together with their device so the registry does not grow + unbounded. """ config = self.hub.config - entity_registry = er.async_get(self.hub.hass) - macs: list[str] = [ - entry.unique_id.split("-", 1)[1] - for entry in er.async_entries_for_config_entry( - entity_registry, config.entry.entry_id - ) - if entry.domain == Platform.DEVICE_TRACKER and "-" in entry.unique_id - ] api = self.hub.api - for mac in config.option_supported_clients + config.option_block_clients + macs: + entity_registry = er.async_get(self.hub.hass) + device_registry = dr.async_get(self.hub.hass) + + now = dt_util.utcnow() + always_restore = set(config.option_supported_clients) + always_restore.update(config.option_block_clients) + + pruned = 0 + for entry in er.async_entries_for_config_entry( + entity_registry, config.entry.entry_id + ): + if entry.domain != Platform.DEVICE_TRACKER or "-" not in entry.unique_id: + continue + + mac = entry.unique_id.split("-", 1)[1] + if mac in api.clients or mac in always_restore: + continue + + # Absent means the controller no longer reports it or the + # clients_all fetch failed this cycle. Never prune on that, a failed + # fetch would wipe every tracker and its device. + if (client := api.clients_all.get(mac)) is None: + continue + + if not self._client_is_stale(client, now): + api.clients.process_raw([dict(client.raw)]) + continue + + self._remove_client(entity_registry, device_registry, entry.entity_id, mac) + pruned += 1 + + if pruned: + LOGGER.debug("Pruned %s stale UniFi client device(s)", pruned) + + for mac in always_restore: if mac not in api.clients and mac in api.clients_all: api.clients.process_raw([dict(api.clients_all[mac].raw)]) + @callback + def _client_is_stale(self, client: Client, now: datetime) -> bool: + """Return if a client has not been seen within the retention window.""" + last_seen = dt_util.utc_from_timestamp(client.last_seen or 0) + return now - last_seen > CLIENT_RESTORE_MAX_AGE + + @callback + def _remove_client( + self, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + entity_id: str, + mac: str, + ) -> None: + """Remove a stale client's tracker entity and its device.""" + entity_registry.async_remove(entity_id) + if device := device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ): + device_registry.async_update_device( + device.id, remove_config_entry_id=self.hub.config.entry.entry_id + ) + @callback def register_platform( self, diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index dae93b0a9c33..cc8e0fc234e5 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -50,6 +50,7 @@ from homeassistant.util import dt as dt_util, slugify from . import UnifiConfigEntry from .const import DEVICE_STATES +from .device_tracker import async_client_allowed_fn from .entity import ( UnifiEntity, UnifiEntityDescription, @@ -106,11 +107,16 @@ def async_client_uptime_value_fn(hub: UnifiHub, client: Client) -> datetime: @callback def async_wired_client_allowed_fn(hub: UnifiHub, obj_id: str) -> bool: - """Check if client is wired and allowed.""" + """Check if client is wired, tracked and reports a link speed. + + Gate on the tracking options so the sensor (and its client device) is only + created for clients the user actually tracks, instead of every wired client + the controller has ever seen. + """ client = hub.api.clients[obj_id] if not client.is_wired or client.wired_rate_mbps <= 0: return False - return True + return async_client_allowed_fn(hub, obj_id) @callback diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index adb67b35d27b..a17989b44871 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -297,10 +297,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): for identifier in device.identifiers ): continue - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) + device_registry.async_remove_device(device.id) def _on_ws_connect(self) -> None: """Handle WebSocket connection established.""" diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 86f379fd5521..9b66204c64ea 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -15,14 +15,18 @@ from uiprotect.data import ( SmartDetectObjectType, ) from uiprotect.data.nvr import UOSDisk -from uiprotect.data.public_devices import PublicDeviceModel, PublicSensor +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicSensor, + SensorFeatureCapability, +) from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -37,6 +41,7 @@ from .entity import ( ProtectIsOnEntity, ProtectNVREntity, async_all_device_entities, + async_remove_unsupported_sense_entities, ) _KEY_DOOR = "door" @@ -49,6 +54,26 @@ def _async_motion_sensor_enabled_public(obj: PublicDeviceModel) -> bool: return sensor.mount_type is not MountType.LEAK and sensor.motion_settings.is_enabled +def _async_contact_sensor_enabled_public(obj: PublicDeviceModel) -> bool: + # Mirrors Sensor.is_contact_sensor_enabled over the public API. + return cast(PublicSensor, obj).is_contact_sensor_enabled + + +def _async_leak_sensor_enabled_public(obj: PublicDeviceModel) -> bool: + # Leak-mounted (UP Sense), or the capability map advertises water_leak with a + # leak channel enabled — the USL family detects leaks without a leak mount. + # Settings alone are not a valid gate: sensors without the capability report + # inert default leak settings. + sensor = cast(PublicSensor, obj) + return sensor.is_leak_sensor_enabled or ( + sensor.supports(SensorFeatureCapability.WATER_LEAK) + and ( + sensor.leak_settings.is_internal_enabled + or sensor.leak_settings.is_external_enabled + ) + ) + + @dataclasses.dataclass(frozen=True, kw_only=True) class ProtectBinaryEntityDescription( ProtectEntityDescription, BinarySensorEntityDescription @@ -277,18 +302,18 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", translation_key="is_dark", - ufp_value="is_dark", + ufp_public_value="is_dark", ), ProtectBinaryEntityDescription( key="motion", device_class=BinarySensorDeviceClass.MOTION, - ufp_value="is_pir_motion_detected", + ufp_public_value="is_pir_motion_detected", ), ProtectBinaryEntityDescription( key="light", translation_key="flood_light", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="is_light_on", + ufp_public_value="is_light_on", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( @@ -303,7 +328,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key="status_light", translation_key="status_light", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="light_device_settings.is_indicator_enabled", + ufp_public_value="light_device_settings.is_indicator_enabled", ufp_perm=PermRequired.NO_WRITE, ), ) @@ -315,8 +340,9 @@ MOUNTABLE_SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( key=_KEY_DOOR, translation_key="contact", device_class=BinarySensorDeviceClass.DOOR, - ufp_value="is_opened", - ufp_enabled="is_contact_sensor_enabled", + ufp_public_value="is_opened", + ufp_public_enabled_fn=_async_contact_sensor_enabled_public, + ufp_capability=SensorFeatureCapability.OPEN, ), ) @@ -324,8 +350,9 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="leak", device_class=BinarySensorDeviceClass.MOISTURE, - ufp_value="is_leak_detected", - ufp_enabled="is_leak_sensor_enabled", + ufp_public_value="is_leak_detected", + ufp_public_enabled_fn=_async_leak_sensor_enabled_public, + ufp_capability=SensorFeatureCapability.WATER_LEAK, ), ProtectBinaryEntityDescription( key="battery_low", @@ -338,11 +365,13 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.MOTION, ufp_public_value="is_motion_detected", ufp_public_enabled_fn=_async_motion_sensor_enabled_public, + ufp_capability=SensorFeatureCapability.MOTION, ), ProtectBinaryEntityDescription( key="tampering", device_class=BinarySensorDeviceClass.TAMPER, - ufp_value="is_tampering_detected", + ufp_public_value="is_tampering_detected", + ufp_capability=SensorFeatureCapability.TAMPER, ), ProtectBinaryEntityDescription( key="status_light", @@ -356,6 +385,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( translation_key="motion_detection_enabled", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.is_enabled", + ufp_capability=SensorFeatureCapability.MOTION, ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( @@ -568,8 +598,13 @@ class MountableProtectDeviceBinarySensor(ProtectDeviceBinarySensor): def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) # UP Sense can be any of the 3 contact sensor device classes + mount_type = ( + cast(PublicSensor, public).mount_type + if (public := self._ufp_public_obj) is not None + else self.device.mount_type + ) self._attr_device_class = MOUNT_DEVICE_CLASS_MAP.get( - self.device.mount_type, BinarySensorDeviceClass.DOOR + mount_type, BinarySensorDeviceClass.DOOR ) @@ -733,6 +768,9 @@ async def async_setup_entry( ) -> None: """Set up binary sensors for UniFi Protect integration.""" data = entry.runtime_data + async_remove_unsupported_sense_entities( + hass, Platform.BINARY_SENSOR, data, (*SENSE_SENSORS, *MOUNTABLE_SENSE_SENSORS) + ) @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 32f6238ee21f..c735bf00963d 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -1,19 +1,30 @@ """Support for Ubiquiti's UniFi Protect NVR.""" +from collections.abc import Iterable import logging -from typing import override +from typing import cast, override from uiprotect.data import ( Camera as UFPCamera, - CameraChannel, + ChannelQuality, + DeviceState, ModelType, ProtectAdoptableDeviceModel, + PublicDeviceModel, StateType, + channel_id_for_quality, ) +from uiprotect.data.public_devices import PublicCamera from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + device_registry as dr, + entity_platform, + issue_registry as ir, +) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity @@ -24,6 +35,7 @@ from .const import ( ATTR_FPS, ATTR_HEIGHT, ATTR_WIDTH, + DEFAULT_BRAND, DOMAIN, ) from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -33,22 +45,31 @@ from .utils import async_ufp_instance_command, get_camera_base_name _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +# Main (non-package) RTSPS quality tiers, in default-preference order. +_MAIN_QUALITIES = ( + ChannelQuality.HIGH, + ChannelQuality.MEDIUM, + ChannelQuality.LOW, +) + @callback def _create_rtsp_repair( - hass: HomeAssistant, entry: UFPConfigEntry, camera: UFPCamera + hass: HomeAssistant, entry: UFPConfigEntry, public: PublicCamera ) -> None: + # Keyed on the public camera: the fix flow verifies and creates the stream + # through the public API, so it works without a private session too. ir.async_create_issue( hass, DOMAIN, - f"rtsp_disabled_{camera.id}", + f"rtsp_disabled_{public.id}", is_fixable=True, is_persistent=False, learn_more_url="https://www.home-assistant.io/integrations/unifiprotect/#camera-streams", severity=IssueSeverity.WARNING, translation_key="rtsp_disabled", - translation_placeholders={"camera": camera.display_name}, - data={"entry_id": entry.entry_id, "camera_id": camera.id}, + translation_placeholders={"camera": public.display_name}, + data={"entry_id": entry.entry_id, "camera_id": public.id}, ) @@ -58,74 +79,115 @@ def _async_camera_entities( entry: UFPConfigEntry, data: ProtectData, ufp_device: UFPCamera | None = None, + public_device: PublicCamera | None = None, ) -> list[ProtectDeviceEntity]: - """Create camera entities with stream URLs sourced from the public API. + """Create camera entities, enumerated public-master from ``PublicCamera``. - One entity per *active* RTSPS quality (the first is enabled by default). The - package channel is a snapshot-first view and is always exposed (disabled by - default), streaming only when its quality is active. When no main quality is - active the first non-package channel is still created so snapshots work, and - a repair offers to activate its stream. RTSPS URLs come from the public API - (the authoritative per-camera host, so stacked consoles resolve correctly) - with SRTP stripped for go2rtc. + Stream URLs come from the public API because it carries the authoritative + per-camera host (stacked consoles resolve correctly), SRTP-stripped for + go2rtc. """ disable_stream = data.disable_stream entities: list[ProtectDeviceEntity] = [] - cameras = data.get_cameras() if ufp_device is None else [ufp_device] - for camera in cameras: - if not camera.channels: - if ufp_device is None: - # only warn on startup - _LOGGER.warning( - "Camera does not have any channels: %s (id: %s)", + + # Public-master enumeration: iterate the public camera list; the private + # camera is paired by shared id (fill) and is None in public-only mode. + pairs: Iterable[tuple[PublicCamera | None, UFPCamera | None]] + if public_device is not None: + private = ( + None + if data.api.is_public_only + else data.api.bootstrap.cameras.get(public_device.id) + ) + # mirror the startup enumeration's adopted filter + if private is not None and not private.is_adopted_by_us: + return entities + pairs = [(public_device, private)] + elif ufp_device is None: + pairs = data.get_public_cameras() + else: + adopted = data.async_get_public_device(ufp_device) + pairs = [(adopted if isinstance(adopted, PublicCamera) else None, ufp_device)] + + for public, camera in pairs: + # A just-adopted camera not yet mirrored into the public bootstrap is + # deferred and picked up when enumeration re-runs. + if public is None: + if camera is not None: + _LOGGER.debug( + "Deferring camera %s until its public mirror arrives", camera.display_name, - camera.id, ) - data.async_add_pending_camera_id(camera.id) + data.async_add_pending_camera_id(camera.id) continue - streams = data.get_rtsps_streams(camera.id) - active = set(streams.get_active_stream_qualities()) if streams else set() - issue_id = f"rtsp_disabled_{camera.id}" + # Hybrid: a camera not yet in the private bootstrap (adopt race) is + # skipped rather than built private-less — the adopt dispatch creates + # it with its private fill, which would otherwise collide on unique_id. + if camera is None and not data.api.is_public_only: + _LOGGER.debug( + "Deferring camera %s until its private object is adopted", + public.display_name, + ) + continue + streams = data.get_rtsps_streams(public.id) + issue_id = f"rtsp_disabled_{public.id}" + tiers = public.hardware_stream_qualities() + main_qualities = [q for q in _MAIN_QUALITIES if q in tiers] + has_package = ChannelQuality.PACKAGE in tiers + if not main_qualities: + # The library guarantees the three main tiers; a camera without any + # is a broken contract — surface it loudly, but do not let one + # camera abort enumeration for the rest. + _LOGGER.warning( + "Camera %s reports no main stream tiers (%s); skipping", + public.display_name, + tiers, + ) + continue + + # Active stream tiers come from the public ``rtsps_streams`` object. + active = set(streams.get_active_stream_qualities()) if streams else set() has_stream = False - package_channel: CameraChannel | None = None - for channel in camera.channels: - if channel.is_package: - package_channel = channel - continue - if channel.rtsps_quality in active: + for quality in main_qualities: + if quality in active: entities.append( - ProtectCamera(data, camera, channel, not has_stream, disable_stream) + ProtectCamera( + data, public, camera, quality, not has_stream, disable_stream + ) ) has_stream = True # the package channel is a snapshot-first view (very low FPS); always # expose it (disabled by default), streaming only when its quality is active - if package_channel is not None: + if has_package: entities.append( - ProtectCamera(data, camera, package_channel, False, disable_stream) + ProtectCamera( + data, public, camera, ChannelQuality.PACKAGE, False, disable_stream + ) ) if has_stream: ir.async_delete_issue(hass, DOMAIN, issue_id) continue - # no active main stream: expose the first non-package channel for snapshots - fallback = next((c for c in camera.channels if not c.is_package), None) - if fallback is None: - continue - entities.append(ProtectCamera(data, camera, fallback, True, disable_stream)) + # no active main stream: expose the first main tier for snapshots + entities.append( + ProtectCamera(data, public, camera, main_qualities[0], True, disable_stream) + ) # no repair when the stream can't be enabled anyway: a disconnected - # camera is streamless because it is offline, not because it needs one + # camera is streamless because it is offline, not because it needs one. + # The fix flow runs entirely on the public API, so public-only cameras + # get the repair too; third-party is only knowable with a private fill. if ( disable_stream - or camera.is_third_party_camera - or camera.state is not StateType.CONNECTED + or public.state is not DeviceState.CONNECTED + or (camera is not None and camera.is_third_party_camera) ): ir.async_delete_issue(hass, DOMAIN, issue_id) else: - _create_rtsp_repair(hass, entry, camera) + _create_rtsp_repair(hass, entry, public) return entities @@ -136,23 +198,28 @@ async def async_setup_entry( ) -> None: """Discover cameras on a UniFi Protect NVR.""" data = entry.runtime_data + platform = entity_platform.async_get_current_platform() @callback - def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - # AiPort inherits from Camera but should not create camera entities - if not isinstance(device, UFPCamera) or device.model is ModelType.AIPORT: - return - async_add_entities(_async_camera_entities(hass, entry, data, ufp_device=device)) + def _add_new_device(device: ProtectAdoptableDeviceModel | PublicCamera) -> None: + if isinstance(device, PublicCamera): + entities = _async_camera_entities(hass, entry, data, public_device=device) + else: + # AiPort inherits from Camera but should not create camera entities + if not isinstance(device, UFPCamera) or device.model is ModelType.AIPORT: + return + entities = _async_camera_entities(hass, entry, data, ufp_device=device) + # A re-enumeration (deferred mirror, RTSPS prime) overlaps entities + # that already exist; the platform errors on live duplicates rather + # than deduplicating, so add only the missing ones. + live = {e.unique_id for e in platform.entities.values()} + async_add_entities([e for e in entities if e.unique_id not in live]) data.async_subscribe_adopt(_add_new_device) entry.async_on_unload( async_dispatcher_connect(hass, data.channels_signal, _add_new_device) ) - # Clean up any erroneously created RTSP issues for AI Ports - for device in data.get_by_types({ModelType.AIPORT}): - ir.async_delete_issue(hass, DOMAIN, f"rtsp_disabled_{device.id}") - async_add_entities(_async_camera_entities(hass, entry, data)) @@ -168,24 +235,38 @@ class ProtectCamera(ProtectDeviceEntity, Camera): "_attr_available", "_attr_is_recording", "_attr_motion_detection_enabled", + # flips with the stream source (an RTSPS prime can be the only change) + "_attr_supported_features", ) def __init__( self, data: ProtectData, - camera: UFPCamera, - channel: CameraChannel, + public: PublicCamera, + private: UFPCamera | None, + quality: ChannelQuality, is_default: bool, disable_stream: bool, ) -> None: - """Initialize an UniFi camera.""" - self.channel = channel + """Initialize an UniFi camera. + + The public camera is the master; the private camera fills gaps the + public API does not cover and is ``None`` in public-only mode. + """ + self._public = public + self._public_missing = False + self._private = private + self._quality = quality + self._is_package = quality is ChannelQuality.PACKAGE + self._channel_id = channel_id_for_quality(quality) self._disable_stream = disable_stream self._last_image: bytes | None = None - super().__init__(data, camera) - self._attr_unique_id = f"{self.device.mac}_{channel.id}" - self._attr_name = get_camera_base_name(channel) - # only the default (first active) channel is enabled by default + # The base tracks the private device in hybrid (unchanged behaviour) and + # the public device in public-only, so it always has a mac to key on. + super().__init__(data, cast(ProtectDeviceType, private or public)) + self._attr_unique_id = f"{self.device.mac}_{self._channel_id}" + self._attr_name = get_camera_base_name(quality) + # only the default (first active) quality channel is enabled by default self._attr_entity_registry_enabled_default = is_default # Set the stream source before finishing the init # because async_added_to_hass is too late and camera @@ -196,21 +277,17 @@ class ProtectCamera(ProtectDeviceEntity, Camera): @callback def _async_set_stream_source(self) -> None: """Set the public-API RTSPS stream URL (SRTP stripped for go2rtc).""" - quality = self.channel.rtsps_quality - streams = self.data.get_rtsps_streams(self.device.id) - if self._disable_stream or quality is None or streams is None: + quality = self._quality + streams = self.data.get_rtsps_streams(self._public.id) + if self._disable_stream or streams is None: source = None - if ( - streams is None - and not self._disable_stream - and not self.channel.is_package - ): + if streams is None and not self._disable_stream and not self._is_package: # online camera unexpectedly absent from the public bootstrap; # log so this is distinguishable from an intentionally off stream _LOGGER.debug( "No public RTSPS data for camera %s (%s); using snapshots", - self.device.display_name, - self.device.id, + self._public.name, + self._public.id, ) else: source = streams.get_stream_url(quality, srtp=False) @@ -219,43 +296,162 @@ class ProtectCamera(ProtectDeviceEntity, Camera): @callback @override - def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: - super()._async_update_device_from_protect(device) - updated_device = self.device - channel = updated_device.channels[self.channel.id] - self.channel = channel - motion_enabled = updated_device.recording_settings.enable_motion_detection - self._attr_motion_detection_enabled = ( - motion_enabled if motion_enabled is not None else True + def _async_set_device_info(self) -> None: + if self._private is not None: + super()._async_set_device_info() + return + # public-only: no market_name/firmware_version/protect_url, and + # ``type`` only on newer firmware, so device identity is limited. The + # NVR link is omitted — an API-key-only client has no private + # bootstrap to read the NVR mac from, and resolving it publicly is + # async; the public-only config mode wires it at setup instead. + public = self._public + self._attr_device_info = DeviceInfo( + name=public.display_name, + model=public.type, + manufacturer=DEFAULT_BRAND, + connections={(dr.CONNECTION_NETWORK_MAC, public.mac)}, ) - state_type_is_connected = updated_device.state is StateType.CONNECTED - self._attr_is_recording = ( - state_type_is_connected and updated_device.is_recording - ) - is_connected = self.data.last_update_success and state_type_is_connected - # some cameras have detachable lens that could cause the camera to be offline - self._attr_available = is_connected and updated_device.is_video_ready + @callback + @override + def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: + if self._private is not None: + super()._async_update_device_from_protect(device) + updated_device = self.device + # A poll/resync can replace the bootstrap objects; follow them so + # commands and reads never act on a detached model. + self._private = updated_device + if isinstance( + public := self.data.async_get_public_device(updated_device), + PublicCamera, + ): + self._public = public + else: + # keep the last object for identity, but log so a vanished + # public mirror is observable rather than a silent no-op + _LOGGER.debug( + "Camera %s has no public mirror; keeping the last known one", + updated_device.display_name, + ) + channel_id = self._channel_id + channel = ( + updated_device.channels[channel_id] + if channel_id is not None and channel_id < len(updated_device.channels) + else None + ) + if channel is None: + # A tier without its private channel blanks the diagnostics; + # log so a camera reconfiguration (or a quality that maps to no + # channel) is distinguishable from a bug. + _LOGGER.debug( + "Camera %s has no private channel %s; diagnostic attributes" + " unavailable", + updated_device.display_name, + channel_id, + ) + motion_enabled = updated_device.recording_settings.enable_motion_detection + self._attr_motion_detection_enabled = ( + motion_enabled if motion_enabled is not None else True + ) + state_type_is_connected = updated_device.state is StateType.CONNECTED + self._attr_is_recording = ( + state_type_is_connected and updated_device.is_recording + ) + is_connected = self.data.last_update_success and state_type_is_connected + # some cameras have detachable lens that could make them offline + self._attr_available = is_connected and updated_device.is_video_ready + + self._async_set_stream_source() + self._attr_extra_state_attributes = { + ATTR_WIDTH: channel.width if channel else None, + ATTR_HEIGHT: channel.height if channel else None, + ATTR_FPS: channel.fps if channel else None, + ATTR_BITRATE: channel.bitrate if channel else None, + ATTR_CHANNEL_ID: channel_id, + } + return + + # public-only: recording/motion state and the per-stream diagnostics + # have no public equivalent and degrade; availability tracks the public + # devices websocket health and the public camera state. + public = self._public + self._attr_motion_detection_enabled = False + self._attr_is_recording = False + self._attr_available = ( + self.data.last_public_update_success + and not self._public_missing + and public.state is DeviceState.CONNECTED + ) self._async_set_stream_source() self._attr_extra_state_attributes = { - ATTR_WIDTH: channel.width, - ATTR_HEIGHT: channel.height, - ATTR_FPS: channel.fps, - ATTR_BITRATE: channel.bitrate, - ATTR_CHANNEL_ID: channel.id, + ATTR_WIDTH: None, + ATTR_HEIGHT: None, + ATTR_FPS: None, + ATTR_BITRATE: None, + ATTR_CHANNEL_ID: self._channel_id, } + @callback + def _async_public_camera_updated(self, obj: PublicDeviceModel | None) -> None: + """Handle a public devices websocket update for this camera. + + ``obj`` is the refreshed public object, or ``None`` for a websocket + state change or an unmergeable frame, in which case it is re-read from + the public bootstrap. A camera missing from the bootstrap on re-read + has been removed and reads as unavailable until it reappears. + """ + if obj is None: + obj = self.data.async_get_public_device(self._public) + if isinstance(obj, PublicCamera): + self._public = obj + self._public_missing = False + else: + self._public_missing = True + device = ( + self._private + if self._private is not None + else cast(ProtectDeviceType, self._public) + ) + self._async_updated_event(device) + + @override + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + # The stream URLs live on the public camera and change outside the + # private websocket (a background RTSPS prime announces itself on the + # public channel), so every camera tracks its public mirror; in + # public-only mode this is also the only state source. + self.async_on_remove( + self.data.async_subscribe_public( + self._public.mac, self._async_public_camera_updated + ) + ) + # A public update or delete can land between entity construction and + # this subscription; re-read so the entity does not start stale. + self._async_public_camera_updated(None) + @override async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: - """Return the Camera Image.""" - # Without a stream the camera is rendered by rapidly polling snapshots; - # request low quality then to avoid hammering the console with large - # images. width/height are unused (the public endpoint has no resize). - high_quality = None if self._stream_source else False - self._last_image = await self.device.get_public_api_snapshot( - high_quality=high_quality, package=self.channel.is_package + """Return the Camera Image. + + While snapshot-polling (no stream) request low quality to avoid + hammering the console. width/height are unused (the public endpoint + has no resize). + """ + # Inlines the library's device-level default (support_full_hd_snapshot + # when streaming, low otherwise) since public-only has no private + # device object; the resolved value is unchanged. + high_quality = bool( + self._stream_source and self._public.feature_flags.support_full_hd_snapshot + ) + self._last_image = await self.data.api.get_public_api_camera_snapshot( + camera_id=self._public.id, + high_quality=high_quality, + package=self._is_package, ) return self._last_image @@ -268,10 +464,20 @@ class ProtectCamera(ProtectDeviceEntity, Camera): @override async def async_enable_motion_detection(self) -> None: """Call the job and enable motion detection.""" - await self.device.set_motion_detection(True) + await self._async_set_motion_detection(True) @async_ufp_instance_command @override async def async_disable_motion_detection(self) -> None: """Call the job and disable motion detection.""" - await self.device.set_motion_detection(False) + await self._async_set_motion_detection(False) + + async def _async_set_motion_detection(self, enabled: bool) -> None: + # the public API has no motion-detection setter; without a private + # session the command cannot be sent and must not report success. + if (private := self._private) is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="motion_detection_public_only", + ) + await private.set_motion_detection(enabled) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index beaf24d29eda..717f960ca2f9 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -41,7 +41,6 @@ DEFAULT_VERIFY_SSL = False DEFAULT_MAX_MEDIA = 1000 DEVICES_THAT_ADOPT = { - ModelType.AIPORT, ModelType.CAMERA, ModelType.LIGHT, ModelType.VIEWPORT, diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 577b47fd1867..7c68fd4b2809 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -8,6 +8,7 @@ from functools import partial import logging from typing import TYPE_CHECKING, Any, cast +from aiohttp.client_exceptions import ServerDisconnectedError from uiprotect import EventChange, ProtectApiClient, ProtectEvent from uiprotect.api import RTSPSStreams from uiprotect.data import ( @@ -19,8 +20,10 @@ from uiprotect.data import ( ProtectAdoptableDeviceModel, PTZPatrol, PublicDeviceModel, + WSAction, WSSubscriptionMessage, ) +from uiprotect.data.public_devices import PublicCamera from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.utils import log_event from uiprotect.websocket import WebsocketState @@ -152,6 +155,37 @@ class ProtectData: Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted) ) + def get_public_cameras( + self, + ) -> Generator[tuple[PublicCamera | None, Camera | None]]: + """Iterate cameras public-master with private-fill. + + The public bootstrap is the master list; the matching private camera is + paired by shared id when present (hybrid) and ``None`` in public-only + mode. An adopted private camera not (yet) mirrored into the public + bootstrap is yielded as ``(None, private)`` so the caller can defer it. + Adopted-filtering mirrors ``get_cameras`` whenever a private object is + available. + """ + api = self.api + if not api.has_public_bootstrap: + return + # An API-key-only client never initializes the private bootstrap; + # accessing it would raise. + private_cameras: dict[str, Camera] = ( + {} if api.is_public_only else api.bootstrap.cameras + ) + public_cameras = api.public_bootstrap.cameras + for camera_id, public in public_cameras.items(): + private = private_cameras.get(camera_id) + if private is not None and not private.is_adopted_by_us: + continue + yield public, private + for camera_id, private in private_cameras.items(): + if camera_id in public_cameras or not private.is_adopted_by_us: + continue + yield None, private + async def async_load_ptz_patrols(self) -> None: """Load PTZ patrols for all PTZ cameras.""" await asyncio.gather( @@ -232,11 +266,44 @@ class ProtectData: self._async_signal_public_update(old_obj.mac, None) return if new_obj.model is ModelType.NVR: - self._async_signal_device_update(self.api.bootstrap.nvr) + # An API-key-only client has no private NVR (reading it would raise). + if not self.api.is_public_only: + self._async_signal_device_update(self.api.bootstrap.nvr) return if isinstance(new_obj, PublicDeviceModel): + if new_obj.model is ModelType.CAMERA: + self._async_reenumerate_camera_on_public_change(new_obj, message) self._async_signal_public_update(new_obj.mac, new_obj) + @callback + def _async_reenumerate_camera_on_public_change( + self, new_obj: PublicDeviceModel, message: WSSubscriptionMessage + ) -> None: + """Re-run camera enumeration when a public frame can add entities. + + Three cases dispatch the public camera to the channels signal: + + - A camera deferred at enumeration because its public mirror had not + arrived yet (the private channels-update path cannot be relied on to + fire again). + - A camera whose RTSPS streams the library primes in the background + after it comes online or is added, announced by an ``rtsps_streams`` + change: the quality tiers that just became active still need their + entities. + - In public-only mode, a newly added camera — there is no private + adopt path that could discover it. + + The platform adds only entities that do not exist yet, so overlapping + re-enumerations are safe. + """ + if new_obj.id in self._pending_camera_ids: + self._pending_camera_ids.remove(new_obj.id) + elif "rtsps_streams" not in message.changed_data and not ( + self.api.is_public_only and message.action is WSAction.ADD + ): + return + async_dispatcher_send(self._hass, self.channels_signal, new_obj) + @callback def _async_process_public_event( self, event: ProtectEvent, change: EventChange @@ -245,18 +312,16 @@ class ProtectData: Only the start of an event is dispatched, routed to the subscribers that registered for this device and event type; an entity that cares about a - sub-type (e.g. a smart-detect object type) filters further itself. The - device is resolved by ``device_id`` (the stable cross-API join key), not - the public ``device_mac``, so the key comes from the same store the - entities derive ``self.device.mac`` from and matches without assuming - both mac strings are byte-identical. + sub-type (e.g. a smart-detect object type) filters further itself. + Subscriptions are keyed by ``device_id`` (the stable cross-API join key, + shared by the private and public bootstraps), so the event routes + directly without a bootstrap lookup. """ if change is not EventChange.STARTED: return - device = self.api.bootstrap.get_device_from_id(event.device_id) - if device is None or not ( + if not ( subscriptions := self._public_event_subscriptions.get( - (device.mac, event.type) + (event.device_id, event.type) ) ): return @@ -276,6 +341,37 @@ class ProtectData: return self.last_public_update_success = success self._async_process_public_updates() + if success: + # The library resyncs its public bootstrap on reconnect, but the + # resync applies silently and races this callback, so the re-read + # above may see the pre-disconnect cache. Refresh again behind a + # guaranteed-fresh snapshot (``update_public`` is serialized) so a + # change from the disconnect gap cannot stay stale. + self._entry.async_create_background_task( + self._hass, + self._async_resignal_after_public_resync(), + "unifiprotect public reconnect refresh", + ) + + async def _async_resignal_after_public_resync(self) -> None: + """Re-signal public entities once a fresh public snapshot is applied.""" + try: + await self.api.update_public() + except NotAuthorized: + # A revoked API key cannot self-recover. + self._entry.async_start_reauth(self._hass) + return + except (TimeoutError, ClientError, ServerDisconnectedError) as err: + # Transport errors retry on the next reconnect. + _LOGGER.debug("Public refresh after reconnect failed: %s", err) + return + self._async_process_public_updates() + # Existing subscriptions are refreshed above, but a camera that + # appeared (or gained streams) during the gap still needs its + # entities; the platform adds only the missing ones. + if self.api.has_public_bootstrap: + for public in list(self.api.public_bootstrap.cameras.values()): + async_dispatcher_send(self._hass, self.channels_signal, public) @callback def _async_process_public_updates(self) -> None: @@ -284,7 +380,9 @@ class ProtectData: if not api.has_public_bootstrap: return # The NVR alarm panel reads the public arm_mode, so refresh it too. - self._async_signal_device_update(api.bootstrap.nvr) + # An API-key-only client has no private NVR (reading it would raise). + if not api.is_public_only: + self._async_signal_device_update(api.bootstrap.nvr) # Subscribers recompute from the public bootstrap on ``None``. for subscriptions in self._public_subscriptions.values(): for update_callback in subscriptions: @@ -492,12 +590,12 @@ class ProtectData: @callback def async_subscribe_public_event( self, - mac: str, + device_id: str, event_type: EventType, update_callback: Callable[[ProtectEvent], None], ) -> CALLBACK_TYPE: - """Add a callback subscriber for public events of a type by device mac.""" - key = (mac, event_type) + """Add a callback subscriber for public events of a type by device id.""" + key = (device_id, event_type) self._public_event_subscriptions[key].add(update_callback) return partial(self._async_unsubscribe_public_event, key, update_callback) @@ -531,7 +629,7 @@ class ProtectData: @callback def async_get_public_device( - self, device: ProtectDeviceType + self, device: ProtectDeviceType | PublicDeviceModel ) -> PublicDeviceModel | None: """Return the public-API object matching a device, if available.""" api = self.api diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index ebb74046d2cd..06b07cf51bf1 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -20,9 +20,11 @@ from uiprotect.data import ( SmartDetectObjectType, StateType, ) +from uiprotect.data.public_devices import PublicSensor, SensorFeatureCapability -from homeassistant.core import callback -from homeassistant.helpers import device_registry as dr +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -48,6 +50,46 @@ class PermRequired(int, Enum): DELETE = 3 +@callback +def _async_capability_supported( + data: ProtectData, + device: ProtectAdoptableDeviceModel, + description: ProtectEntityDescription, +) -> bool: + """Whether the device advertises the description's required sensor capability.""" + if (capability := description.ufp_capability) is None: + return True + public = data.async_get_public_device(device) + if not isinstance(public, PublicSensor) or not public.has_feature_flags: + return True + return public.supports(capability) + + +@callback +def async_remove_unsupported_sense_entities( + hass: HomeAssistant, + platform: Platform, + data: ProtectData, + descs: Sequence[ProtectEntityDescription], +) -> None: + """Remove registry entries for sense entities the device cannot support. + + Only acts when a public capability map is present (newer firmware); a console + upgrade then drops the never-functional entities created before the map existed. + """ + entity_registry = er.async_get(hass) + for device in data.get_by_types({ModelType.SENSOR}): + for description in descs: + if description.ufp_capability is None or _async_capability_supported( + data, device, description + ): + continue + if entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, f"{device.mac}_{description.key}" + ): + entity_registry.async_remove(entity_id) + + @callback def _async_device_entities( data: ProtectData, @@ -101,6 +143,9 @@ def _async_device_entities( if not description.has_required(device): continue + if not _async_capability_supported(data, device, description): + continue + entities.append( klass( data, @@ -119,7 +164,6 @@ def _async_device_entities( _ALL_MODEL_TYPES = ( - ModelType.AIPORT, ModelType.CAMERA, ModelType.LIGHT, ModelType.SENSOR, @@ -163,6 +207,10 @@ def async_all_device_entities( device_model_type = ufp_device.model assert device_model_type is not None + # Runtime adoption must honor the same model-type allowlist as initial setup, + # so unsupported devices (e.g. AI Port) get no entities when adopted live. + if device_model_type not in _ALL_MODEL_TYPES: + return [] descs = _combine_model_descs(device_model_type, model_descriptions, all_descs) return _async_device_entities( data, klass, device_model_type, descs, unadopted_descs, ufp_device @@ -440,6 +488,10 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046 # Public counterpart of ``ufp_enabled``; a callable because public enablement # is often compound (e.g. mount type plus a settings flag). ufp_public_enabled_fn: Callable[[PublicDeviceModel], bool] | None = None + # Sensor capability required to create the entity, checked against the public + # capability map. Without a capability map (older firmware) every description + # is created, matching the pre-capability behavior. + ufp_capability: SensorFeatureCapability | None = None ufp_perm: PermRequired | None = None # The below are set in __post_init__ diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 29d824de65fc..4875c4b9697f 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -86,7 +86,7 @@ class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventE await super().async_added_to_hass() self.async_on_remove( self.data.async_subscribe_public_event( - self.device.mac, EventType.RING, self._async_ring_event + self.device.id, EventType.RING, self._async_ring_event ) ) @@ -382,7 +382,7 @@ class ProtectDeviceSmartDetectEventEntity( await super().async_added_to_hass() self.async_on_remove( self.data.async_subscribe_public_event( - self.device.mac, EventType.SMART_DETECT, self._async_smart_detect_event + self.device.id, EventType.SMART_DETECT, self._async_smart_detect_event ) ) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 1dd90b65079d..e5e42b5bb65e 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,10 +1,11 @@ """Component providing Lights for UniFi Protect.""" import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel from uiprotect.data.devices import LightDeviceSettings +from uiprotect.data.public_devices import PublicLight from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback @@ -61,14 +62,29 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _state_attrs = ("_attr_available", "_attr_is_on", "_attr_brightness") + @override + async def async_added_to_hass(self) -> None: + """Read state from the public API (primed before the first update).""" + self._ufp_uses_public = True + self._ufp_public_obj = self.data.async_get_public_device(self.device) + self.async_on_remove( + self.data.async_subscribe_public( + self.device.mac, self._async_public_updated + ) + ) + await super().async_added_to_hass() + @callback @override def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: super()._async_update_device_from_protect(device) - updated_device = self.device - self._attr_is_on = updated_device.is_light_on - self._attr_brightness = unifi_brightness_to_hass( - updated_device.light_device_settings.led_level + if (public := self._ufp_public_obj) is None: + return + light = cast(PublicLight, public) + self._attr_is_on = light.is_light_on + led_level = light.light_device_settings.led_level + self._attr_brightness = ( + None if led_level is None else unifi_brightness_to_hass(led_level) ) @async_ufp_instance_command diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 3161443e367a..5d2673998cef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.4.0"] + "requirements": ["uiprotect==15.14.2"] } diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 3e1804f6d19d..d2a94eb53b38 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -11,7 +11,11 @@ from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.issue_registry import IssueSeverity from .const import DOMAIN @@ -109,6 +113,10 @@ async def async_migrate_data( async_deprecate_hdr(hass, entry) _LOGGER.debug("Completed Migrate: async_deprecate_hdr") + _LOGGER.debug("Start Migrate: async_remove_aiport_devices") + async_remove_aiport_devices(hass, entry) + _LOGGER.debug("Completed Migrate: async_remove_aiport_devices") + _LOGGER.debug("Start Migrate: async_migrate_insecure_cameras") async_migrate_insecure_cameras(hass, entry) _LOGGER.debug("Completed Migrate: async_migrate_insecure_cameras") @@ -118,6 +126,31 @@ async def async_migrate_data( _LOGGER.debug("Completed Migrate: async_remove_package_binary_sensor") +# Device type (``ProtectAdoptableDeviceModel.type``) reported by AI Ports. Matched +# in the registry so cleanup does not depend on the bundled library still exposing +# the AI Port model. +_AIPORT_DEVICE_TYPE = "AI Port" + + +@callback +def async_remove_aiport_devices(hass: HomeAssistant, entry: UFPConfigEntry) -> None: + """Remove AI Port devices and their diagnostic-only entities. + + AI Ports only ever exposed diagnostic sensors (no automation-relevant + functionality) and behave transparently, extending the camera they back. + They have no public API representation, so support is dropped. Devices are + matched from the registry (by device type) rather than the live bootstrap, so + cleanup works even once the library drops the AI Port model. + + Added in 2026.7.0 + """ + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + if device.model_id != _AIPORT_DEVICE_TYPE: + continue + device_registry.async_remove_device(device.id) + + @callback def async_migrate_insecure_cameras(hass: HomeAssistant, entry: UFPConfigEntry) -> None: """Migrate the legacy plain-RTSP "(insecure)" camera entities. diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index e8ee0d0b09d8..2f7cf224e209 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -7,10 +7,14 @@ import logging from typing import cast, override from uiprotect.data import Camera, Chime, Light, ModelType, ProtectAdoptableDeviceModel -from uiprotect.data.public_devices import PublicDeviceModel, PublicLight +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicLight, + SensorFeatureCapability, +) from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, Platform, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -22,6 +26,7 @@ from .entity import ( ProtectSettableKeysMixin, T, async_all_device_entities, + async_remove_unsupported_sense_entities, ) from .utils import async_ufp_instance_command @@ -85,8 +90,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_max=100, ufp_step=1, ufp_required_field="has_mic", - ufp_value="mic_volume", - ufp_enabled="feature_flags.has_mic", + ufp_public_value="mic_volume", ufp_set_method="set_mic_volume_public", ufp_perm=PermRequired.WRITE, ), @@ -169,8 +173,8 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=0, ufp_max=100, ufp_step=1, - ufp_value="light_device_settings.pir_sensitivity", - ufp_set_method="set_sensitivity", + ufp_public_value="light_device_settings.pir_sensitivity", + ufp_set_method="set_sensitivity_public", ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( @@ -196,8 +200,9 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_min=0, ufp_max=100, ufp_step=1, - ufp_value="motion_settings.sensitivity", - ufp_set_method="set_motion_sensitivity", + ufp_public_value="motion_settings.sensitivity", + ufp_set_method="set_motion_sensitivity_public", + ufp_capability=SensorFeatureCapability.MOTION, ufp_perm=PermRequired.WRITE, ), ) @@ -276,6 +281,7 @@ async def async_setup_entry( ) -> None: """Set up number entities for UniFi Protect integration.""" data = entry.runtime_data + async_remove_unsupported_sense_entities(hass, Platform.NUMBER, data, SENSE_NUMBERS) @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index fe9fa80f5073..888267d346a5 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass from enum import Enum import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.api import ProtectApiClient from uiprotect.data import ( @@ -25,10 +25,15 @@ from uiprotect.data import ( Sensor, Viewer, ) +from uiprotect.data.public_devices import ( + PublicCamera, + PublicDeviceModel, + SensorFeatureCapability, +) from uiprotect.exceptions import GlobalAlarmManagerError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityDescription @@ -44,8 +49,9 @@ from .entity import ( ProtectSettableKeysMixin, T, async_all_device_entities, + async_remove_unsupported_sense_entities, ) -from .utils import async_get_light_motion_current, async_ufp_instance_command +from .utils import async_get_light_motion_current_public, async_ufp_instance_command _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -167,7 +173,7 @@ def _get_doorbell_current(obj: Camera) -> str | None: async def _set_light_mode(obj: Light, mode: str) -> None: lightmode, timing = LIGHT_MODE_TO_SETTINGS[mode] - await obj.set_light_settings( + await obj.set_light_mode_public( LightModeType(lightmode), enable_at=None if timing is None else LightModeEnableType(timing), ) @@ -215,6 +221,16 @@ _HDR_MODE_MAP = { "always": PublicHdrMode.ON, "off": PublicHdrMode.OFF, } +_HDR_MODE_MAP_INVERSE = {v: k for k, v in _HDR_MODE_MAP.items()} + + +def _get_hdr_mode_public(obj: PublicDeviceModel) -> str | None: + """Return the HDR option id from the public camera's ``hdr_type``. + + ``hdr_type`` is non-optional on the public model; ``.get`` still yields + ``None`` for any value missing from the map. + """ + return _HDR_MODE_MAP_INVERSE.get(cast(PublicCamera, obj).hdr_type) async def _set_hdr_mode(obj: Camera, mode: str) -> None: @@ -280,7 +296,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_hdr", ufp_options=HDR_MODES, - ufp_value="hdr_mode_display", + ufp_public_value_fn=_get_hdr_mode_public, ufp_set_method_fn=_set_hdr_mode, ufp_perm=PermRequired.WRITE, ), @@ -292,7 +308,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( translation_key="light_mode", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, - ufp_value_fn=async_get_light_motion_current, + ufp_public_value_fn=async_get_light_motion_current_public, ufp_set_method_fn=_set_light_mode, ufp_perm=PermRequired.WRITE, ), @@ -316,6 +332,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=MountType, ufp_value="mount_type", ufp_set_method="set_mount_type", + ufp_capability=SensorFeatureCapability.OPEN, ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Sensor]( @@ -355,6 +372,7 @@ async def async_setup_entry( ) -> None: """Set up number entities for UniFi Protect integration.""" data = entry.runtime_data + async_remove_unsupported_sense_entities(hass, Platform.SELECT, data, SENSE_SELECTS) @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 127891a2ba18..cb3896cbd2c4 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from functools import partial import logging -from typing import Any, override +from typing import Any, cast, override from uiprotect.data import ( NVR, @@ -16,6 +16,12 @@ from uiprotect.data import ( ProtectDeviceModel, Sensor, ) +from uiprotect.data.public_devices import ( + PublicDeviceModel, + PublicLight, + SensorFeatureCapability, +) +from uiprotect.utils import convert_to_datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -28,6 +34,7 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + Platform, UnitOfDataRate, UnitOfElectricPotential, UnitOfInformation, @@ -48,8 +55,9 @@ from .entity import ( ProtectNVREntity, T, async_all_device_entities, + async_remove_unsupported_sense_entities, ) -from .utils import async_get_light_motion_current +from .utils import async_get_light_motion_current_public _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @@ -87,6 +95,11 @@ class ProtectSensorEventEntityDescription( """Describes UniFi Protect Sensor entity.""" +def _get_last_motion_public(obj: PublicDeviceModel) -> datetime | None: + # Public API reports last motion as a JS epoch (ms); private side a datetime. + return convert_to_datetime(cast(PublicLight, obj).last_motion) + + def _get_uptime(obj: ProtectDeviceModel) -> datetime | None: if obj.up_since is None: return None @@ -310,6 +323,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.light.value", ufp_enabled="is_light_sensor_enabled", + ufp_capability=SensorFeatureCapability.LIGHT, ), ProtectSensorEntityDescription( key="humidity_level", @@ -318,6 +332,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.humidity.value", ufp_enabled="is_humidity_sensor_enabled", + ufp_capability=SensorFeatureCapability.HUMIDITY, ), ProtectSensorEntityDescription( key="temperature_level", @@ -326,18 +341,21 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.temperature.value", ufp_enabled="is_temperature_sensor_enabled", + ufp_capability=SensorFeatureCapability.TEMPERATURE, ), ProtectSensorEntityDescription[Sensor]( key="alarm_sound", translation_key="alarm_sound_detected", ufp_value_fn=_get_alarm_sound, ufp_enabled="is_alarm_sensor_enabled", + ufp_capability=SensorFeatureCapability.SMOKE, ), ProtectSensorEntityDescription( key="door_last_trip_time", translation_key="last_open", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="open_status_changed_at", + ufp_capability=SensorFeatureCapability.OPEN, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( @@ -345,11 +363,13 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="motion_detected_at", + ufp_capability=SensorFeatureCapability.MOTION, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="tampering_last_trip_time", translation_key="last_tampering_detected", + ufp_capability=SensorFeatureCapability.TAMPER, device_class=SensorDeviceClass.TIMESTAMP, ufp_value="tampering_detected_at", entity_registry_enabled_default=False, @@ -360,6 +380,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.sensitivity", + ufp_capability=SensorFeatureCapability.MOTION, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( @@ -367,6 +388,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( translation_key="mount_type", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="mount_type", + ufp_capability=SensorFeatureCapability.OPEN, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( @@ -496,7 +518,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( key="motion_last_trip_time", translation_key="last_motion_detected", device_class=SensorDeviceClass.TIMESTAMP, - ufp_value="last_motion", + ufp_public_value_fn=_get_last_motion_public, entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( @@ -504,14 +526,14 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( translation_key="motion_sensitivity", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="light_device_settings.pir_sensitivity", + ufp_public_value="light_device_settings.pir_sensitivity", ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription[Light]( key="light_motion", translation_key="light_mode", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value_fn=async_get_light_motion_current, + ufp_public_value_fn=async_get_light_motion_current_public, ufp_perm=PermRequired.NO_WRITE, ), ProtectSensorEntityDescription( @@ -576,6 +598,7 @@ async def async_setup_entry( ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data + async_remove_unsupported_sense_entities(hass, Platform.SENSOR, data, SENSE_SENSORS) @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 80bbb142cdbf..eb7b65358f73 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -692,6 +692,9 @@ "global_alarm_manager": { "message": "The alarm manager on this UniFi Protect NVR is set to Global mode and cannot be controlled locally." }, + "motion_detection_public_only": { + "message": "Motion detection cannot be changed over the public API; configure it in the UniFi Protect app" + }, "no_users_found": { "message": "No users found, please check Protect permissions" }, diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f7f664c9d92c..54812f75882e 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -387,8 +387,8 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( key="status_light", translation_key="status_light", entity_category=EntityCategory.CONFIG, - ufp_value="light_device_settings.is_indicator_enabled", - ufp_set_method="set_status_light", + ufp_public_value="light_device_settings.is_indicator_enabled", + ufp_set_method="set_status_light_public", ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 9e5f7af14c0b..fc411102f607 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -5,18 +5,18 @@ import contextlib from functools import wraps from pathlib import Path import socket -from typing import TYPE_CHECKING, Any, Concatenate +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import CookieJar from uiprotect import ProtectApiClient from uiprotect.data import ( Bootstrap, - CameraChannel, - Light, + ChannelQuality, LightModeEnableType, LightModeType, ProtectAdoptableDeviceModel, ) +from uiprotect.data.public_devices import PublicDeviceModel, PublicLight from uiprotect.exceptions import ClientError, NotAuthorized from homeassistant.const import ( @@ -56,7 +56,7 @@ def _async_short_mac(mac: str) -> str: return _async_unifi_mac_from_hass(mac)[-6:] -async def _async_resolve(hass: HomeAssistant, host: str) -> str | None: +async def _async_resolve(hass: HomeAssistant, host: str) -> str | int | None: """Resolve a hostname to an ip.""" with contextlib.suppress(OSError): return next( @@ -95,15 +95,14 @@ def async_get_devices( @callback -def async_get_light_motion_current(obj: Light) -> str: - """Get light motion mode for Flood Light.""" - - if ( - obj.light_mode_settings.mode is LightModeType.MOTION - and obj.light_mode_settings.enable_at is LightModeEnableType.DARK - ): +def async_get_light_motion_current_public(obj: PublicDeviceModel) -> str | None: + """Get light motion mode for a Flood Light from the public API.""" + settings = cast(PublicLight, obj).light_mode_settings + if (mode := settings.mode) is None: + return None + if mode is LightModeType.MOTION and settings.enable_at is LightModeEnableType.DARK: return f"{LightModeType.MOTION.value}_dark" - return obj.light_mode_settings.mode.value + return mode.value @callback @@ -134,14 +133,12 @@ def async_create_api_client( @callback -def get_camera_base_name(channel: CameraChannel) -> str: - """Get base name for cameras channel.""" +def get_camera_base_name(quality: ChannelQuality) -> str: + """Get base name for a camera's RTSPS quality channel.""" - camera_name = channel.name - if channel.name != "Package Camera": - camera_name = f"{channel.name} resolution channel" - - return camera_name + if quality is ChannelQuality.PACKAGE: + return "Package Camera" + return f"{quality.value.title()} resolution channel" def async_ufp_instance_command[_EntityT, **_P]( diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 1b84bbfb7cfd..71ffe80206f8 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -6,28 +6,13 @@ from typing import Any, override import voluptuous as vol from homeassistant.components.media_player import ( - ATTR_APP_ID, - ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_ALBUM_ARTIST, - ATTR_MEDIA_ALBUM_NAME, - ATTR_MEDIA_ARTIST, - ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - ATTR_MEDIA_DURATION, - ATTR_MEDIA_EPISODE, - ATTR_MEDIA_PLAYLIST, - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_REPEAT, - ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, - ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_SHUFFLE, - ATTR_MEDIA_TITLE, - ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, @@ -41,16 +26,15 @@ from homeassistant.components.media_player import ( SERVICE_SELECT_SOURCE, BrowseMedia, MediaPlayerEntity, + MediaPlayerEntityCapabilityAttribute, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, MediaPlayerState, MediaType, RepeatMode, ) from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, - ATTR_ENTITY_PICTURE, - ATTR_SUPPORTED_FEATURES, CONF_DEVICE_CLASS, CONF_NAME, CONF_STATE, @@ -76,6 +60,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback @@ -318,7 +303,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): @override def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" - return self._child_attr(ATTR_ASSUMED_STATE) + return self._child_attr(EntityStateAttribute.ASSUMED_STATE) @property @override @@ -343,7 +328,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): def volume_level(self): """Volume level of entity specified in attributes or active child.""" try: - return float(self._override_or_child_attr(ATTR_MEDIA_VOLUME_LEVEL)) + return float( + self._override_or_child_attr( + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL + ) + ) except TypeError, ValueError: return None @@ -351,31 +340,33 @@ class UniversalMediaPlayer(MediaPlayerEntity): @override def is_volume_muted(self): """Boolean if volume is muted.""" - return self._override_or_child_attr(ATTR_MEDIA_VOLUME_MUTED) in [True, STATE_ON] + return self._override_or_child_attr( + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED + ) in [True, STATE_ON] @property @override def media_content_id(self): """Return the content ID of current playing media.""" - return self._child_attr(ATTR_MEDIA_CONTENT_ID) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID) @property @override def media_content_type(self): """Return the content type of current playing media.""" - return self._child_attr(ATTR_MEDIA_CONTENT_TYPE) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE) @property @override def media_duration(self): """Return the duration of current playing media in seconds.""" - return self._child_attr(ATTR_MEDIA_DURATION) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_DURATION) @property @override def media_image_url(self): """Image url of current playing media.""" - return self._override_or_child_attr(ATTR_ENTITY_PICTURE) + return self._override_or_child_attr(EntityStateAttribute.ENTITY_PICTURE) @property @override @@ -392,116 +383,126 @@ class UniversalMediaPlayer(MediaPlayerEntity): @override def media_title(self): """Title of current playing media.""" - return self._child_attr(ATTR_MEDIA_TITLE) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_TITLE) @property @override def media_artist(self): """Artist of current playing media (Music track only).""" - return self._child_attr(ATTR_MEDIA_ARTIST) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ARTIST) @property @override def media_album_name(self): """Album name of current playing media (Music track only).""" - return self._child_attr(ATTR_MEDIA_ALBUM_NAME) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ALBUM_NAME) @property @override def media_album_artist(self): """Album artist of current playing media (Music track only).""" - return self._child_attr(ATTR_MEDIA_ALBUM_ARTIST) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_ALBUM_ARTIST) @property @override def media_track(self): """Track number of current playing media (Music track only).""" - return self._child_attr(ATTR_MEDIA_TRACK) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_TRACK) @property @override def media_series_title(self): """Return the title of the series of current playing media (TV).""" - return self._child_attr(ATTR_MEDIA_SERIES_TITLE) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_SERIES_TITLE) @property @override def media_season(self): """Season of current playing media (TV Show only).""" - return self._child_attr(ATTR_MEDIA_SEASON) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_SEASON) @property @override def media_episode(self): """Episode of current playing media (TV Show only).""" - return self._child_attr(ATTR_MEDIA_EPISODE) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_EPISODE) @property @override def media_channel(self): """Channel currently playing.""" - return self._child_attr(ATTR_MEDIA_CHANNEL) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_CHANNEL) @property @override def media_playlist(self): """Title of Playlist currently playing.""" - return self._child_attr(ATTR_MEDIA_PLAYLIST) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_PLAYLIST) @property @override def app_id(self): """ID of the current running app.""" - return self._child_attr(ATTR_APP_ID) + return self._child_attr(MediaPlayerEntityStateAttribute.APP_ID) @property @override def app_name(self): """Name of the current running app.""" - return self._child_attr(ATTR_APP_NAME) + return self._child_attr(MediaPlayerEntityStateAttribute.APP_NAME) @property @override def sound_mode(self): """Return the current sound mode of the device.""" - return self._override_or_child_attr(ATTR_SOUND_MODE) + return self._override_or_child_attr(MediaPlayerEntityStateAttribute.SOUND_MODE) @property @override def sound_mode_list(self): """List of available sound modes.""" - return self._override_or_child_attr(ATTR_SOUND_MODE_LIST) + return self._override_or_child_attr( + MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST + ) @property @override def source(self): """Return the current input source of the device.""" - return self._override_or_child_attr(ATTR_INPUT_SOURCE) + return self._override_or_child_attr( + MediaPlayerEntityStateAttribute.INPUT_SOURCE + ) @property @override def source_list(self): """List of available input sources.""" - return self._override_or_child_attr(ATTR_INPUT_SOURCE_LIST) + return self._override_or_child_attr( + MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST + ) @property @override def repeat(self): """Boolean if repeating is enabled.""" - return self._override_or_child_attr(ATTR_MEDIA_REPEAT) + return self._override_or_child_attr( + MediaPlayerEntityStateAttribute.MEDIA_REPEAT + ) @property @override def shuffle(self): """Boolean if shuffling is enabled.""" - return self._override_or_child_attr(ATTR_MEDIA_SHUFFLE) + return self._override_or_child_attr( + MediaPlayerEntityStateAttribute.MEDIA_SHUFFLE + ) @property @override def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" flags: MediaPlayerEntityFeature = self._child_attr( - ATTR_SUPPORTED_FEATURES + EntityStateAttribute.SUPPORTED_FEATURES ) or MediaPlayerEntityFeature(0) if SERVICE_TURN_ON in self._cmds: @@ -573,13 +574,15 @@ class UniversalMediaPlayer(MediaPlayerEntity): @override def media_position(self): """Position of current playing media in seconds.""" - return self._child_attr(ATTR_MEDIA_POSITION) + return self._child_attr(MediaPlayerEntityStateAttribute.MEDIA_POSITION) @property @override def media_position_updated_at(self): """When was the position of the current playing media valid.""" - return self._child_attr(ATTR_MEDIA_POSITION_UPDATED_AT) + return self._child_attr( + MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT + ) @override async def async_turn_on(self) -> None: diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 8eb8e99fd355..dc44e595d988 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -3,6 +3,7 @@ "name": "Universal Powerline Bus (UPB)", "codeowners": ["@gwww"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index d85675d8a4dc..2b9604c42dd0 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -15,8 +15,12 @@ PACKETS_RECEIVED = "packets_received" PACKETS_SENT = "packets_sent" KIBIBYTES_PER_SEC_RECEIVED = "kibibytes_per_sec_received" KIBIBYTES_PER_SEC_SENT = "kibibytes_per_sec_sent" +KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER = "kibibytes_per_sec_received_no_rollover" +KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER = "kibibytes_per_sec_sent_no_rollover" PACKETS_PER_SEC_RECEIVED = "packets_per_sec_received" PACKETS_PER_SEC_SENT = "packets_per_sec_sent" +PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER = "packets_per_sec_received_no_rollover" +PACKETS_PER_SEC_SENT_NO_ROLLOVER = "packets_per_sec_sent_no_rollover" TIMESTAMP = "timestamp" DATA_PACKETS = "packets" DATA_RATE_PACKETS_PER_SECOND = f"{DATA_PACKETS}/{UnitOfTime.SECONDS}" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index e3d9690afb89..a93a7a61f981 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -22,10 +22,14 @@ from .const import ( BYTES_RECEIVED, BYTES_SENT, KIBIBYTES_PER_SEC_RECEIVED, + KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER, KIBIBYTES_PER_SEC_SENT, + KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER, LOGGER as _LOGGER, PACKETS_PER_SEC_RECEIVED, + PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER, PACKETS_PER_SEC_SENT, + PACKETS_PER_SEC_SENT_NO_ROLLOVER, PACKETS_RECEIVED, PACKETS_SENT, PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4, @@ -256,8 +260,12 @@ class Device: ROUTER_IP: get_value(igd_state.external_ip_address), KIBIBYTES_PER_SEC_RECEIVED: igd_state.kibibytes_per_sec_received, KIBIBYTES_PER_SEC_SENT: igd_state.kibibytes_per_sec_sent, + KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER: igd_state.kibibytes_per_sec_received_no_rollover, + KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER: igd_state.kibibytes_per_sec_sent_no_rollover, PACKETS_PER_SEC_RECEIVED: igd_state.packets_per_sec_received, PACKETS_PER_SEC_SENT: igd_state.packets_per_sec_sent, + PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER: igd_state.packets_per_sec_received_no_rollover, + PACKETS_PER_SEC_SENT_NO_ROLLOVER: igd_state.packets_per_sec_sent_no_rollover, PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4: get_value( igd_state.port_mapping_number_of_entries ), diff --git a/homeassistant/components/upnp/icons.json b/homeassistant/components/upnp/icons.json index 8f6b1c493662..ad6abd63ccfa 100644 --- a/homeassistant/components/upnp/icons.json +++ b/homeassistant/components/upnp/icons.json @@ -7,9 +7,15 @@ "packet_download_speed": { "default": "mdi:transmission-tower" }, + "packet_download_speed_no_rollover_handling": { + "default": "mdi:transmission-tower" + }, "packet_upload_speed": { "default": "mdi:transmission-tower" }, + "packet_upload_speed_no_rollover_handling": { + "default": "mdi:transmission-tower" + }, "packets_received": { "default": "mdi:database" }, diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index f1d6a3a18fd5..251a2a9f2c32 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.47.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index be3c25455bd3..379e4b033c6c 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -25,10 +25,14 @@ from .const import ( DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, KIBIBYTES_PER_SEC_RECEIVED, + KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER, KIBIBYTES_PER_SEC_SENT, + KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER, LOGGER, PACKETS_PER_SEC_RECEIVED, + PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER, PACKETS_PER_SEC_SENT, + PACKETS_PER_SEC_SENT_NO_ROLLOVER, PACKETS_RECEIVED, PACKETS_SENT, PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4, @@ -126,6 +130,28 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), + UpnpSensorEntityDescription( + key=BYTES_RECEIVED, + translation_key="download_speed_no_rollover_handling", + value_key=KIBIBYTES_PER_SEC_RECEIVED_NO_ROLLOVER, + unique_id="KiB/sec_received_no_rollover", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + UpnpSensorEntityDescription( + key=BYTES_SENT, + translation_key="upload_speed_no_rollover_handling", + value_key=KIBIBYTES_PER_SEC_SENT_NO_ROLLOVER, + unique_id="KiB/sec_sent_no_rollover", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, translation_key="packet_download_speed", @@ -146,6 +172,26 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), + UpnpSensorEntityDescription( + key=PACKETS_RECEIVED, + translation_key="packet_download_speed_no_rollover_handling", + value_key=PACKETS_PER_SEC_RECEIVED_NO_ROLLOVER, + unique_id="packets/sec_received_no_rollover", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + UpnpSensorEntityDescription( + key=PACKETS_SENT, + translation_key="packet_upload_speed_no_rollover_handling", + value_key=PACKETS_PER_SEC_SENT_NO_ROLLOVER, + unique_id="packets/sec_sent_no_rollover", + native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), ) diff --git a/homeassistant/components/upnp/strings.json b/homeassistant/components/upnp/strings.json index a62c8c4af080..79464d3bc57d 100644 --- a/homeassistant/components/upnp/strings.json +++ b/homeassistant/components/upnp/strings.json @@ -33,15 +33,24 @@ "download_speed": { "name": "Download speed" }, + "download_speed_no_rollover_handling": { + "name": "Download speed (no rollover handling)" + }, "external_ip": { "name": "External IP" }, "packet_download_speed": { "name": "Packet download speed" }, + "packet_download_speed_no_rollover_handling": { + "name": "Packet download speed (no rollover handling)" + }, "packet_upload_speed": { "name": "Packet upload speed" }, + "packet_upload_speed_no_rollover_handling": { + "name": "Packet upload speed (no rollover handling)" + }, "packets_received": { "name": "Packets received" }, @@ -54,6 +63,9 @@ "upload_speed": { "name": "Upload speed" }, + "upload_speed_no_rollover_handling": { + "name": "Upload speed (no rollover handling)" + }, "uptime": { "name": "Uptime" }, diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index a0e2c77341c6..8fb244b18df8 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -205,7 +205,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, - add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ddad80b92f24..903280052d00 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/v2c", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pytrydan==1.0.3"] + "requirements": ["pytrydan==1.0.4"] } diff --git a/homeassistant/components/vacuum/llm.py b/homeassistant/components/vacuum/llm.py new file mode 100644 index 000000000000..eb28c4a9ca25 --- /dev/null +++ b/homeassistant/components/vacuum/llm.py @@ -0,0 +1,46 @@ +"""LLM tools for the vacuum integration.""" + +from homeassistant.components.homeassistant import async_should_expose +from homeassistant.components.llm import LLMTools +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import intent +from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import ( + INTENT_VACUUM_CLEAN_AREA, + INTENT_VACUUM_RETURN_TO_BASE, + INTENT_VACUUM_START, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = ( + INTENT_VACUUM_CLEAN_AREA, + INTENT_VACUUM_RETURN_TO_BASE, + INTENT_VACUUM_START, +) + + +@callback +def async_get_tools( + hass: HomeAssistant, llm_context: LLMContext, api_id: str +) -> LLMTools | None: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if api_id != LLM_API_ASSIST: + return None + + if not llm_context.assistant: + return None + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return None + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 4e8f0f0bfc4f..5cc742925995 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -138,22 +138,18 @@ async def async_remove_config_entry_device( config_entry: VelbusConfigEntry, device_entry: dr.DeviceEntry, ) -> bool: - """Allow removing a Velbus device and detach its sub-devices. + """Allow removing a Velbus device and its sub-devices. - Sub-devices are detached from this config entry when their parent is - removed. If the device is still on the bus, it may be recreated when - the integration is reloaded or started again. + Sub-devices are removed along with their parent. If the device is still + on the bus, it may be recreated when the integration is reloaded or + started again. """ if config_entry.entry_id not in device_entry.config_entries: return False dev_reg = dr.async_get(hass) for sub_device in dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id): if sub_device.via_device_id == device_entry.id: - dev_reg.async_update_device( - sub_device.id, - remove_config_entry_id=config_entry.entry_id, - via_device_id=None, - ) + dev_reg.async_remove_device(sub_device.id) return True diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index b01c5bb48e17..99bd7149d750 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -11,7 +11,8 @@ "velbus-parser", "velbus-module", "velbus-packet", - "velbus-protocol" + "velbus-protocol", + "velbus-handler" ], "quality_scale": "silver", "requirements": ["velbus-aio==2026.4.1"], diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 3adce293632b..2125663e4bf0 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -40,6 +40,7 @@ SENSOR_DESCRIPTIONS: dict[str, VelbusSensorEntityDescription] = { key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda channel: float(channel.get_counter_state()), unit_fn=lambda channel: channel.get_unit(), ), "temperature": VelbusSensorEntityDescription( @@ -58,7 +59,11 @@ SENSOR_DESCRIPTIONS: dict[str, VelbusSensorEntityDescription] = { device_class=SensorDeviceClass.ENERGY, icon="mdi:counter", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda channel: float(channel.get_counter_state()), + value_fn=lambda channel: ( + float(channel.energy) + if hasattr(channel, "energy") and channel.energy is not None + else None + ), unit_fn=lambda channel: channel.get_counter_unit(), unique_id_suffix="-counter", ), diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index b650e1fe46c4..3fd87f7a52a8 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -2,7 +2,7 @@ import dataclasses -from pyvlx import PyVLX, PyVLXException, Window +from pyvlx import OpeningDevice, PyVLX, PyVLXException from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -75,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo limitation_coordinators: dict[int, VeluxLimitationCoordinator] = {} for node in pyvlx.nodes: - if isinstance(node, Window) and node.rain_sensor: + if isinstance(node, OpeningDevice): coordinator = VeluxLimitationCoordinator(hass, entry, node) # do not await coordinator.async_config_entry_first_refresh() here to avoid doing # it for disabled entities, the entities will call it when they are added to hass diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 70517826880c..6216e75aa970 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -60,7 +60,8 @@ class VeluxRainSensor( """Called when the entity is added to Home Assistant.""" await super().async_added_to_hass() # Get initial state as we didn't do it on coordinator initialization to avoid doing it for disabled entities - await self.coordinator.async_request_refresh() + if self.coordinator.data is None: + await self.coordinator.async_request_refresh() @property @override @@ -69,8 +70,8 @@ class VeluxRainSensor( # Velux windows with rain sensors report an opening # limitation when rain is detected. So far we've # seen 89, 91, 93 (most cases) or 100 (Velux GPU). - # It probably makes sense to - # assume that any large enough limitation (we use >=89) means rain is detected. + # It probably makes sense to assume that any large + # enough limitation (we use >=89) means rain is detected. # Documentation on this is non-existent AFAIK. if self.coordinator.data is None: return None diff --git a/homeassistant/components/velux/coordinator.py b/homeassistant/components/velux/coordinator.py index b49b813c1555..7ad9fd2829f0 100644 --- a/homeassistant/components/velux/coordinator.py +++ b/homeassistant/components/velux/coordinator.py @@ -21,6 +21,7 @@ class VeluxLimitationData: """Data for one opening device's limitations.""" limitation_min: Position + limitation_max: Position class VeluxLimitationCoordinator(DataUpdateCoordinator[VeluxLimitationData | None]): @@ -44,9 +45,16 @@ class VeluxLimitationCoordinator(DataUpdateCoordinator[VeluxLimitationData | Non @override async def _async_update_data(self) -> VeluxLimitationData: - """Fetch limitation min data from the device.""" + """Fetch limitation min and max from the device.""" try: min_pos = await self.node.get_limitation_min() + max_pos = await self.node.get_limitation_max() + LOGGER.debug( + "Fetched limitations for %s: pyvlx_min=%s%% pyvlx_max=%s%%", + self.node.name, + min_pos.position_percent, + max_pos.position_percent, + ) except (OSError, PyVLXException) as err: raise UpdateFailed(f"Error fetching limitations: {err}") from err - return VeluxLimitationData(limitation_min=min_pos) + return VeluxLimitationData(limitation_min=min_pos, limitation_max=max_pos) diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 412ae43520a6..0959d22a83a4 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -68,7 +68,6 @@ class VeluxEntity(Entity): def __init__(self, node: Node, config_entry_id: str) -> None: """Initialize the Velux device.""" self.node = node - self._attr_unique_id = velux_unique_id(node, config_entry_id) self._attr_device_info = velux_device_info(node, config_entry_id) diff --git a/homeassistant/components/velux/number.py b/homeassistant/components/velux/number.py index 9e24878027be..e026ef004883 100644 --- a/homeassistant/components/velux/number.py +++ b/homeassistant/components/velux/number.py @@ -1,16 +1,24 @@ -"""Support for Velux exterior heating number entities.""" +"""Support for Velux exterior heating and cover open/closed number entities.""" +from dataclasses import replace from typing import override -from pyvlx import ExteriorHeating, Intensity +from pyvlx import ExteriorHeating, Intensity, OpeningDevice, Position -from homeassistant.components.number import NumberEntity -from homeassistant.const import PERCENTAGE +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfRatio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import VeluxConfigEntry -from .entity import VeluxEntity, wrap_pyvlx_call_exceptions +from .coordinator import VeluxLimitationCoordinator +from .entity import ( + VeluxEntity, + velux_device_info, + velux_unique_id, + wrap_pyvlx_call_exceptions, +) PARALLEL_UPDATES = 1 @@ -22,11 +30,22 @@ async def async_setup_entry( ) -> None: """Set up number entities for the Velux platform.""" pyvlx = config_entry.runtime_data.pyvlx - async_add_entities( + limitation_coordinators = config_entry.runtime_data.limitation_coordinators + entities: list[NumberEntity] = [ VeluxExteriorHeatingNumber(node, config_entry.entry_id) for node in pyvlx.nodes if isinstance(node, ExteriorHeating) - ) + ] + for node in pyvlx.nodes: + if isinstance(node, OpeningDevice): + coordinator = limitation_coordinators[node.node_id] + entities.extend( + [ + VeluxOpenPositionLimitNumber(coordinator, config_entry.entry_id), + VeluxClosedPositionLimitNumber(coordinator, config_entry.entry_id), + ] + ) + async_add_entities(entities) class VeluxExteriorHeatingNumber(VeluxEntity, NumberEntity): @@ -56,3 +75,170 @@ class VeluxExteriorHeatingNumber(VeluxEntity, NumberEntity): Intensity(intensity_percent=round(value)), wait_for_completion=True, ) + + +class VeluxPositionLimitNumber( + CoordinatorEntity[VeluxLimitationCoordinator], NumberEntity +): + """Shared behavior for Velux limitation number entities. + + Home Assistant expresses cover position as opening percentage, while pyvlx + uses the opposite direction. These entities expose HA-side open/closed + position limits and convert to pyvlx positions only at the API boundary. + """ + + _attr_entity_category = EntityCategory.CONFIG + _attr_entity_registry_enabled_default = False + _attr_mode = NumberMode.BOX + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfRatio.PERCENTAGE + _attr_has_entity_name = True + + _limitation_kind: str + + def __init__( + self, coordinator: VeluxLimitationCoordinator, config_entry_id: str + ) -> None: + """Initialize Velux limitation number.""" + super().__init__(coordinator) + node = coordinator.node + unique_id = velux_unique_id(node, config_entry_id) + self._attr_unique_id = f"{unique_id}_{self._limitation_kind}_limitation" + self._attr_translation_key = f"{self._limitation_kind}_position_limitation" + self._attr_device_info = velux_device_info(node, config_entry_id) + + @override + async def async_added_to_hass(self) -> None: + """Request an immediate refresh when the entity is first added.""" + await super().async_added_to_hass() + # Get initial state as we didn't do it on coordinator initialization to avoid doing it for disabled entities + if self.coordinator.data is None: + await self.coordinator.async_request_refresh() + + @property + @override + def available(self) -> bool: + """Return False until coordinator has successfully populated data. + + The entity is only available once the coordinator has successfully + fetched data at least once. + """ + if self.coordinator.data is None: + return False + return super().available + + @property + @override + def native_value(self) -> float | None: + """Return the current limitation in Home Assistant semantics.""" + if position := self._get_pyvlx_limit(): + return 100 - position.position_percent + return None + + @wrap_pyvlx_call_exceptions + @override + async def async_set_native_value(self, value: float) -> None: + """Set the limitation in Home Assistant semantics.""" + # this will only be called if the entity is available, so coordinator.data is not None + + await self._async_set_pyvlx_limitation( + Position(position_percent=100 - round(value)) + ) + + def _get_pyvlx_limit(self) -> Position | None: + """Get the pyvlx limitation backing this HA-side entity.""" + raise NotImplementedError + + def _updated_pyvlx_limits( + self, updated_position: Position, current_min: Position, current_max: Position + ) -> tuple[Position, Position]: + """Return pyvlx min/max values with this entity's side updated.""" + raise NotImplementedError + + async def _async_set_pyvlx_limitation(self, position: Position) -> None: + """Set pyvlx limitations while preserving the unchanged side.""" + assert self.coordinator.data is not None # checked in async_set_native_value + current_min = self.coordinator.data.limitation_min + current_max = self.coordinator.data.limitation_max + position_min, position_max = self._updated_pyvlx_limits( + position, current_min, current_max + ) + await self.coordinator.node.set_position_limitations( + position_min=position_min, + position_max=position_max, + ) + self.coordinator.async_set_updated_data( + replace( + self.coordinator.data, + limitation_min=position_min, + limitation_max=position_max, + ) + ) + + +class VeluxClosedPositionLimitNumber(VeluxPositionLimitNumber): + """Representation of the closed position limit.""" + + _attr_native_min_value = 0 + _limitation_kind = "closed" + + def _sibling_value(self) -> float | None: + """Return the sibling open limit value, or None if unknown.""" + return ( + 100 - self.coordinator.data.limitation_min.position_percent + if self.coordinator.data + else None + ) + + @property + @override + def native_max_value(self) -> float: + """Return the upper bound: the current open limit (or 100 if unknown).""" + sibling_value = self._sibling_value() + return sibling_value if sibling_value is not None else 100 + + @override + def _get_pyvlx_limit(self) -> Position | None: + """Get the pyvlx max limit backing the HA closed position limit.""" + return self.coordinator.data.limitation_max if self.coordinator.data else None + + @override + def _updated_pyvlx_limits( + self, updated_position: Position, current_min: Position, current_max: Position + ) -> tuple[Position, Position]: + """Update pyvlx max and preserve pyvlx min for HA closed limit changes.""" + return current_min, updated_position + + +class VeluxOpenPositionLimitNumber(VeluxPositionLimitNumber): + """Representation of the open position limit.""" + + _attr_native_max_value = 100 + _limitation_kind = "open" + + def _sibling_value(self) -> float | None: + """Return the sibling close limit value, or None if unknown.""" + return ( + 100 - self.coordinator.data.limitation_max.position_percent + if self.coordinator.data + else None + ) + + @property + @override + def native_min_value(self) -> float: + """Return the lower bound: the current closed limit (or 0 if unknown).""" + sibling_value = self._sibling_value() + return sibling_value if sibling_value is not None else 0 + + @override + def _get_pyvlx_limit(self) -> Position | None: + """Get the pyvlx min limit backing the HA open position limit.""" + return self.coordinator.data.limitation_min if self.coordinator.data else None + + @override + def _updated_pyvlx_limits( + self, updated_position: Position, current_min: Position, current_max: Position + ) -> tuple[Position, Position]: + """Update pyvlx min and preserve pyvlx max for HA open limit changes.""" + return updated_position, current_max diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index f833503aaac9..9a7484f820ee 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -53,6 +53,14 @@ "dual_roller_shutter_upper": { "name": "Upper shutter" } + }, + "number": { + "closed_position_limitation": { + "name": "Closed position limit" + }, + "open_position_limitation": { + "name": "Open position limit" + } } }, "exceptions": { diff --git a/homeassistant/components/venstar/entity.py b/homeassistant/components/venstar/entity.py index 845e37cd5b43..5beef4585127 100644 --- a/homeassistant/components/venstar/entity.py +++ b/homeassistant/components/venstar/entity.py @@ -41,5 +41,5 @@ class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): name=self._client.name, manufacturer="Venstar", model=f"{self._client.model}-{self._client.get_type()}", - sw_version=f"{firmware_version[0]}.{firmware_version[1]}", + sw_version=f"{firmware_version[0]}.{firmware_version[1]:02}", ) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index eba5c8a6cd48..ddba18fd9daa 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["venstarcolortouch"], - "requirements": ["venstarcolortouch==0.21"] + "requirements": ["venstarcolortouch==0.22"] } diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py index b8f5a1195404..681eedfef4c9 100644 --- a/homeassistant/components/version/diagnostics.py +++ b/homeassistant/components/version/diagnostics.py @@ -2,9 +2,10 @@ from typing import Any -from attr import asdict - -from homeassistant.components.diagnostics import entity_entry_as_dict +from homeassistant.components.diagnostics import ( + device_entry_as_dict, + entity_entry_as_dict, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -45,7 +46,7 @@ async def async_get_config_entry_diagnostics( {"entry": entity_entry_as_dict(entity), "state": state_dict} ) - devices.append({"device": asdict(device), "entities": entities}) + devices.append({"device": device_entry_as_dict(device), "entities": entities}) return { "entry": config_entry.as_dict(), diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 96ea5feda36a..f803fd121c0b 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -1,7 +1,7 @@ """Support for VeSync fans.""" import logging -from typing import Any, override +from typing import Any, cast, override from pyvesync.base_devices import VeSyncFanBase, VeSyncPurifier @@ -116,7 +116,14 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): ) -> None: """Initialize the fan.""" super().__init__(device, coordinator) - if rgetattr(device, "state.oscillation_status") is not None: + # Tower fans expose a single-axis ``oscillation_status`` state attribute, + # while pedestal fans expose ``vertical_oscillation_status`` and + # ``horizontal_oscillation_status`` separately. The OSCILLATE feature is + # advertised when either form of oscillation is available. + if rgetattr(device, "state.oscillation_status") is not None or ( + rgetattr(device, "state.vertical_oscillation_status") is not None + or rgetattr(device, "state.horizontal_oscillation_status") is not None + ): self._attr_supported_features |= FanEntityFeature.OSCILLATE # Build maps for HA <-> VeSync preset modes self._ha_to_vs_mode_map: dict[str, str] = {} @@ -141,7 +148,14 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): @override def oscillating(self) -> bool: """Return True if device is oscillating.""" - return rgetattr(self.device, "state.oscillation_status") == "on" + # Tower fans report a single-axis oscillation status. + if rgetattr(self.device, "state.oscillation_status") == "on": + return True + # Pedestal fans report vertical and horizontal oscillation separately; + # the fan is considered oscillating when either axis is active. + if rgetattr(self.device, "state.vertical_oscillation_status") == "on": + return True + return rgetattr(self.device, "state.horizontal_oscillation_status") == "on" @property @override @@ -149,15 +163,15 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): """Return the currently set speed.""" current_level = self.device.state.fan_level - if ( - self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL) - and current_level is not None - ): + if self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL): if current_level == 0: return 0 - return ordered_list_item_to_percentage( - self.device.fan_levels, current_level - ) + # The device can report an out-of-range level (e.g. -1) when the + # speed is not applicable; treat it as unknown instead of crashing. + if current_level in self.device.fan_levels: + return ordered_list_item_to_percentage( + self.device.fan_levels, current_level + ) return None @property @@ -341,14 +355,28 @@ class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): @override async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation.""" - if hasattr(self.device, "toggle_oscillation"): - success = await self.device.toggle_oscillation(oscillating) - if not success: + # Pedestal fans expose per-axis oscillation; checked first because + # the inherited ``toggle_oscillation`` is a no-op for them. + if ( + rgetattr(self.device, "state.vertical_oscillation_status") is not None + or rgetattr(self.device, "state.horizontal_oscillation_status") is not None + ): + device = cast(VeSyncFanBase, self.device) + vertical_ok = await device.toggle_vertical_oscillation(oscillating) + horizontal_ok = await device.toggle_horizontal_oscillation(oscillating) + if not vertical_ok or not horizontal_ok: if self.device.last_response: raise HomeAssistantError(self.device.last_response.message) raise HomeAssistantError( "Failed to set oscillation, no response found." ) self.async_write_ha_state() - else: + return + if not hasattr(self.device, "toggle_oscillation"): raise HomeAssistantError("Oscillation not supported by this device.") + success = await self.device.toggle_oscillation(oscillating) + if not success: + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to set oscillation, no response found.") + self.async_write_ha_state() diff --git a/homeassistant/components/vibration/__init__.py b/homeassistant/components/vibration/__init__.py new file mode 100644 index 000000000000..b361746282f3 --- /dev/null +++ b/homeassistant/components/vibration/__init__.py @@ -0,0 +1,15 @@ +"""Integration for vibration triggers.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "vibration" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + +__all__ = [] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + return True diff --git a/homeassistant/components/vibration/condition.py b/homeassistant/components/vibration/condition.py new file mode 100644 index 000000000000..ab43c4593db5 --- /dev/null +++ b/homeassistant/components/vibration/condition.py @@ -0,0 +1,25 @@ +"""Provides conditions for vibration.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.condition import Condition, make_entity_state_condition + +VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION), +} + + +CONDITIONS: dict[str, type[Condition]] = { + "is_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_ON), + "is_not_detected": make_entity_state_condition(VIBRATION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the conditions for vibration.""" + return CONDITIONS diff --git a/homeassistant/components/vibration/conditions.yaml b/homeassistant/components/vibration/conditions.yaml new file mode 100644 index 000000000000..5f5bb66d8aaa --- /dev/null +++ b/homeassistant/components/vibration/conditions.yaml @@ -0,0 +1,26 @@ +.condition_common_fields: &condition_common_fields + behavior: + required: true + default: any + selector: + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: + +is_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration + +is_not_detected: + fields: *condition_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration diff --git a/homeassistant/components/vibration/icons.json b/homeassistant/components/vibration/icons.json new file mode 100644 index 000000000000..d51de741bf87 --- /dev/null +++ b/homeassistant/components/vibration/icons.json @@ -0,0 +1,18 @@ +{ + "conditions": { + "is_detected": { + "condition": "mdi:vibrate" + }, + "is_not_detected": { + "condition": "mdi:vibrate-off" + } + }, + "triggers": { + "cleared": { + "trigger": "mdi:vibrate-off" + }, + "detected": { + "trigger": "mdi:vibrate" + } + } +} diff --git a/homeassistant/components/vibration/manifest.json b/homeassistant/components/vibration/manifest.json new file mode 100644 index 000000000000..e875b7c6c583 --- /dev/null +++ b/homeassistant/components/vibration/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "vibration", + "name": "Vibration", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/vibration", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/vibration/strings.json b/homeassistant/components/vibration/strings.json new file mode 100644 index 000000000000..b1b3898251cf --- /dev/null +++ b/homeassistant/components/vibration/strings.json @@ -0,0 +1,61 @@ +{ + "common": { + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" + }, + "conditions": { + "is_detected": { + "description": "Tests if one or more vibration sensors are detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::condition_for_name%]" + } + }, + "name": "Vibration is detected" + }, + "is_not_detected": { + "description": "Tests if one or more vibration sensors are not detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::condition_for_name%]" + } + }, + "name": "Vibration is not detected" + } + }, + "title": "Vibration", + "triggers": { + "cleared": { + "description": "Triggers when one or more vibration sensors stop detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::trigger_for_name%]" + } + }, + "name": "Vibration cleared" + }, + "detected": { + "description": "Triggers when one or more vibration sensors start detecting vibration.", + "fields": { + "behavior": { + "name": "[%key:component::vibration::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vibration::common::trigger_for_name%]" + } + }, + "name": "Vibration detected" + } + } +} diff --git a/homeassistant/components/vibration/trigger.py b/homeassistant/components/vibration/trigger.py new file mode 100644 index 000000000000..a23a62401660 --- /dev/null +++ b/homeassistant/components/vibration/trigger.py @@ -0,0 +1,24 @@ +"""Provides triggers for vibration.""" + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger + +VIBRATION_DOMAIN_SPECS: dict[str, DomainSpec] = { + BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.VIBRATION), +} + +TRIGGERS: dict[str, type[Trigger]] = { + "detected": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_ON), + "cleared": make_entity_target_state_trigger(VIBRATION_DOMAIN_SPECS, STATE_OFF), +} + + +async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: + """Return the triggers for vibration.""" + return TRIGGERS diff --git a/homeassistant/components/vibration/triggers.yaml b/homeassistant/components/vibration/triggers.yaml new file mode 100644 index 000000000000..0957393172c3 --- /dev/null +++ b/homeassistant/components/vibration/triggers.yaml @@ -0,0 +1,26 @@ +.trigger_common_fields: &trigger_common_fields + behavior: + required: true + default: each + selector: + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: + +detected: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration + +cleared: + fields: *trigger_common_fields + target: + entity: + - domain: binary_sensor + device_class: vibration diff --git a/homeassistant/components/vicare/application_credentials.py b/homeassistant/components/vicare/application_credentials.py index 6d2773d504c6..4e794091ae0b 100644 --- a/homeassistant/components/vicare/application_credentials.py +++ b/homeassistant/components/vicare/application_credentials.py @@ -18,6 +18,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( VICARE_SCOPES = [SCOPE_IOT, SCOPE_OFFLINE_ACCESS] +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "more_info_url": "https://www.home-assistant.io/integrations/vicare/#prerequisites" + } + + async def async_get_auth_implementation( hass: HomeAssistant, auth_domain: str, credential: ClientCredential ) -> ViCareOAuth2Implementation: diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 5ae572c9fffb..99ab2235bf96 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -41,6 +41,7 @@ SERVICE_SET_VICARE_MODE = "set_vicare_mode" SERVICE_SET_VICARE_MODE_ATTR_MODE = "vicare_mode" VICARE_MODE_DHW = "dhw" +VICARE_MODE_COOLING = "cooling" VICARE_MODE_HEATING = "heating" VICARE_MODE_HEATINGCOOLING = "heatingCooling" VICARE_MODE_DHWANDHEATING = "dhwAndHeating" @@ -64,6 +65,7 @@ VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = { VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, VICARE_MODE_HEATINGCOOLING: HVACMode.AUTO, VICARE_MODE_HEATING: HVACMode.AUTO, + VICARE_MODE_COOLING: HVACMode.COOL, VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, } diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 55ba55642566..78e66edf31af 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.60.2"] + "requirements": ["PyViCare==2.61.0"] } diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 41fb2cc34ed3..dd52d40aae22 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -8,6 +8,7 @@ from typing import override from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareFloorHeating import FloorHeating from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) @@ -1274,6 +1275,16 @@ CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( SUPPLY_TEMPERATURE_SENSOR, ) +FLOOR_HEATING_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( + ViCareSensorEntityDescription( + key="active_mode", + translation_key="active_mode", + device_class=SensorDeviceClass.ENUM, + options=["cooling", "heating", "standby"], + value_getter=lambda api: api.getActiveMode(), + ), +) + BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="burner_starts", @@ -1509,6 +1520,18 @@ def _build_entities( for description in GLOBAL_SENSORS if is_supported(description.key, description.value_getter, device.api) ) + # add device-class-specific entities + if isinstance(device.api, FloorHeating): + entities.extend( + ViCareSensor( + description, + get_device_serial(device.api), + device.config, + device.api, + ) + for description in FLOOR_HEATING_SENSORS + if is_supported(description.key, description.value_getter, device.api) + ) # add component entities for component_list, entity_description_list in ( (get_circuits(device.api), CIRCUIT_SENSORS), diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 314dfd44e23f..a971d8d5d6a7 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "The **client secret** is ignored, enter any value. Follow the [setup instructions]({more_info_url}) to obtain the Client ID." + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -161,6 +164,14 @@ } }, "sensor": { + "active_mode": { + "name": "Mode", + "state": { + "cooling": "Cooling", + "heating": "Heating", + "standby": "[%key:common::state::standby%]" + } + }, "boiler_supply_temperature": { "name": "Boiler supply temperature" }, diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json index dc81bb9aeb9b..a14b7b43f7ff 100644 --- a/homeassistant/components/victron_gx/manifest.json +++ b/homeassistant/components/victron_gx/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["victron-mqtt==2026.6.6"], + "requirements": ["victron-mqtt==2026.7.4"], "ssdp": [ { "X_MqttOnLan": "1", diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json index 084bbc8bdc60..dc4cd29e0326 100644 --- a/homeassistant/components/victron_gx/strings.json +++ b/homeassistant/components/victron_gx/strings.json @@ -94,6 +94,7 @@ "temperature": "Temperature", "terminals_overheated": "Terminals overheated", "total_energy": "Total energy", + "total_pv_yield_system": "Total PV yield system", "total_pv_yield_user": "Total PV yield user", "total_yield": "Total yield", "unknown": "Unknown", @@ -325,9 +326,6 @@ "hub4_ac_grid_setpoint": { "name": "AC grid setpoint" }, - "multi_ess_ac_power_setpoint": { - "name": "ESS AC power setpoint" - }, "multi_ess_min_soc_limit": { "name": "ESS minimum SoC limit" }, @@ -405,6 +403,13 @@ "passthrough": "[%key:component::victron_gx::common::passthrough%]" } }, + "battery_bms_mode": { + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "standby": "[%key:common::state::standby%]" + } + }, "evcharger_mode": { "name": "[%key:common::config_flow::data::mode%]", "state": { @@ -493,6 +498,14 @@ "sell": "Sell" } }, + "system_vrm_portal_mode": { + "name": "VRM portal access level", + "state": { + "full": "[%key:common::state::full%]", + "off": "[%key:common::state::off%]", + "read_only": "Read-only" + } + }, "vebus_inverter_mode": { "state": { "charger_only": "[%key:component::victron_gx::common::charger_only%]", @@ -1317,7 +1330,7 @@ } }, "inverter_total_pv_yield_system": { - "name": "Total PV yield system" + "name": "[%key:component::victron_gx::common::total_pv_yield_system%]" }, "inverter_total_pv_yield_user": { "name": "[%key:component::victron_gx::common::total_pv_yield_user%]" @@ -1398,6 +1411,9 @@ "multi_dc_temperature": { "name": "[%key:component::victron_gx::common::dc_temperature%]" }, + "multi_ess_ac_power_setpoint": { + "name": "ESS AC power setpoint" + }, "multi_ess_mode": { "name": "[%key:component::victron_gx::common::ess_mode%]", "state": { @@ -1628,6 +1644,9 @@ "sustain_alt": "[%key:component::victron_gx::common::sustain_alt%]" } }, + "solarcharger_temperature": { + "name": "[%key:component::victron_gx::common::temperature%]" + }, "solarcharger_time_in_absorption_today": { "name": "Time in absorption today" }, @@ -1637,6 +1656,9 @@ "solarcharger_time_in_float_today": { "name": "Time in float today" }, + "solarcharger_total_pv_yield_system": { + "name": "[%key:component::victron_gx::common::total_pv_yield_system%]" + }, "solarcharger_tracker_tracker_current": { "name": "PV tracker {tracker} current" }, @@ -1884,6 +1906,16 @@ "system_heartbeat": { "name": "GX system heartbeat" }, + "system_pv_on_grid_current_phase": { + "name": "PV on grid current {phase}" + }, + "system_pv_on_grid_phases": { + "name": "PV on grid phases", + "unit_of_measurement": "phases" + }, + "system_pv_on_grid_power_phase": { + "name": "PV on grid power {phase}" + }, "system_pv_on_output_current_phase": { "name": "PV on output current {phase}" }, @@ -2254,6 +2286,9 @@ "system_dvcc": { "name": "DVCC" }, + "system_ess_always_peak_shave": { + "name": "ESS always peak shave" + }, "system_ess_battery_use": { "name": "ESS only critical loads from battery" }, @@ -2272,6 +2307,18 @@ "vebus_device_device_number_power_assist_enabled": { "name": "{device_number} PowerAssist enabled" }, + "vebus_hub4_disable_charge": { + "name": "Hub4 disable charge" + }, + "vebus_hub4_do_not_feed_in_overvoltage": { + "name": "Hub4 do not feed in on overvoltage" + }, + "vebus_hub4_fix_solar_offset_100mv": { + "name": "Hub4 fix solar offset to 100mV" + }, + "vebus_hub4_target_power_is_max_feed_in": { + "name": "Hub4 target power is max feed-in" + }, "vebus_inverter_ignoreacin1_onoff_control": { "name": "Control ignore AC-in-1" }, @@ -2280,6 +2327,9 @@ }, "vebus_inverter_setting_alarm_grid_lost": { "name": "Grid lost alarm setting" + }, + "vebus_pvinverter_disable": { + "name": "Vebus PV inverter disable" } }, "time": { diff --git a/homeassistant/components/vistapool/__init__.py b/homeassistant/components/vistapool/__init__.py index 42dc69b4361d..d7a01cbd6578 100644 --- a/homeassistant/components/vistapool/__init__.py +++ b/homeassistant/components/vistapool/__init__.py @@ -1,21 +1,30 @@ """The Vistapool integration.""" +import asyncio from dataclasses import dataclass, field import logging -from aioaquarite import AquariteAuth, AquariteClient, AquariteError, AuthenticationError +from aioaquarite import ( + AquariteAuth, + AquariteClient, + AquariteError, + AuthenticationError, + ResilientUserPoolsSubscription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, ) +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -39,6 +48,7 @@ class VistapoolData: coordinators: dict[str, VistapoolDataUpdateCoordinator] = field( default_factory=dict ) + sync_lock: asyncio.Lock = field(default_factory=asyncio.Lock) type VistapoolConfigEntry = ConfigEntry[VistapoolData] @@ -77,29 +87,148 @@ async def async_setup_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> ) data = VistapoolData(auth=auth, api=api) + entry.runtime_data = data try: for pool_id, pool_name in pools.items(): - coordinator = VistapoolDataUpdateCoordinator( - hass, entry, auth, api, pool_id, pool_name - ) - data.coordinators[pool_id] = coordinator - await coordinator.async_config_entry_first_refresh() - try: - await coordinator.subscribe() - except AquariteError as exc: - raise ConfigEntryNotReady from exc - entry.async_on_unload(coordinator.async_shutdown) + await _async_add_coordinator(hass, entry, pool_id, pool_name, first=True) except Exception: for coordinator in data.coordinators.values(): await coordinator.async_shutdown() raise - entry.runtime_data = data + # Catch pools removed from the account while Home Assistant was offline; the + # first live snapshot is a no-op so it wouldn't clean these up. + _async_remove_stale_devices(hass, entry, set(pools)) + + def _on_user_pools_snapshot(pool_ids: list[str]) -> None: + """Bridge the Firestore snapshot from the watch thread to the HA loop.""" + hass.loop.call_soon_threadsafe(_schedule_reconcile, pool_ids) + + @callback + def _schedule_reconcile(pool_ids: list[str]) -> None: + entry.async_create_background_task( + hass, + _async_reconcile_pools(hass, entry, pool_ids), + name=f"vistapool_reconcile_{entry.entry_id}", + ) + + # Subscribe before forwarding platforms so a failed subscribe doesn't leave + # platforms set up; on retry they would re-forward and raise "already setup". + try: + subscription: ResilientUserPoolsSubscription = ( + await api.subscribe_user_pools_resilient(_on_user_pools_snapshot) + ) + except AquariteError as exc: + for coordinator in data.coordinators.values(): + await coordinator.async_shutdown() + raise ConfigEntryNotReady from exc + entry.async_on_unload(subscription.aclose) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: VistapoolConfigEntry) -> bool: """Unload Vistapool config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + # Hold sync_lock so a reconcile background task can't mutate the + # coordinators dict while we shut it down. + async with entry.runtime_data.sync_lock: + for coordinator in entry.runtime_data.coordinators.values(): + await coordinator.async_shutdown() + return unload_ok + + +@callback +def _async_remove_stale_devices( + hass: HomeAssistant, entry: VistapoolConfigEntry, valid_pool_ids: set[str] +) -> None: + """Remove registry devices for pools no longer present on the account.""" + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + pool_id = next((i[1] for i in device.identifiers if i[0] == DOMAIN), None) + if pool_id is not None and pool_id not in valid_pool_ids: + device_registry.async_remove_device(device.id) + + +async def _async_initial_refresh( + coordinator: VistapoolDataUpdateCoordinator, *, first: bool +) -> None: + """Populate coordinator data for a pool; raise if it would stay empty.""" + if first: + await coordinator.async_config_entry_first_refresh() + return + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_failed", + ) + + +async def _async_add_coordinator( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + pool_id: str, + pool_name: str, + *, + first: bool, +) -> VistapoolDataUpdateCoordinator: + """Create, refresh and subscribe a coordinator for a single pool.""" + coordinator = VistapoolDataUpdateCoordinator( + hass, entry, entry.runtime_data.auth, entry.runtime_data.api, pool_id, pool_name + ) + try: + await _async_initial_refresh(coordinator, first=first) + try: + await coordinator.subscribe() + except AquariteError as exc: + raise ConfigEntryNotReady from exc + except ConfigEntryNotReady: + await coordinator.async_shutdown() + raise + entry.runtime_data.coordinators[pool_id] = coordinator + return coordinator + + +async def _async_reconcile_pools( + hass: HomeAssistant, + entry: VistapoolConfigEntry, + pool_ids: list[str], +) -> None: + """Reconcile the runtime coordinator set against a fresh pool ID list.""" + async with entry.runtime_data.sync_lock: + current = set(entry.runtime_data.coordinators) + fetched = set(pool_ids) + if current == fetched: + return + + new_ids = fetched - current + names: dict[str, str] = {} + if new_ids: + try: + names = await entry.runtime_data.api.get_pools() + except AquariteError as err: + _LOGGER.debug("Pool name lookup failed during reconcile: %s", err) + new_ids = set() + + for pool_id in new_ids: + if pool_id not in names: + continue + try: + coordinator = await _async_add_coordinator( + hass, entry, pool_id, names[pool_id], first=False + ) + except ConfigEntryNotReady as err: + _LOGGER.warning("Failed to add new pool %s: %s", pool_id, err) + continue + async_dispatcher_send( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", coordinator + ) + + if stale := current - fetched: + for pool_id in stale: + await entry.runtime_data.coordinators.pop(pool_id).async_shutdown() + _async_remove_stale_devices(hass, entry, fetched) diff --git a/homeassistant/components/vistapool/binary_sensor.py b/homeassistant/components/vistapool/binary_sensor.py index 864c6f89d4f1..dc72edd624e3 100644 --- a/homeassistant/components/vistapool/binary_sensor.py +++ b/homeassistant/components/vistapool/binary_sensor.py @@ -9,7 +9,8 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VistapoolConfigEntry @@ -20,6 +21,7 @@ from .const import ( PATH_HASIO, PATH_HASPH, PATH_HASRX, + SIGNAL_NEW_POOL, ) from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -162,6 +164,46 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[VistapoolBinarySensorEntityDescription, ...] = ) +def _build_binary_sensor_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[BinarySensorEntity]: + """Build the binary sensor entities for a single pool.""" + entities: list[BinarySensorEntity] = [] + for description in BINARY_SENSOR_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolBinarySensor(coordinator, description)) + + if coordinator.get_value(PATH_HASHIDRO): + is_electrolysis = coordinator.get_value("hidro.is_electrolysis") + entities.append( + VistapoolBinarySensor( + coordinator, + VistapoolBinarySensorEntityDescription( + key="electrolysis_low" if is_electrolysis else "hydrolysis_low", + translation_key=( + "electrolysis_low" if is_electrolysis else "hydrolysis_low" + ), + device_class=BinarySensorDeviceClass.PROBLEM, + value_path="hidro.low", + ), + ) + ) + + if any( + coordinator.get_value(path) + for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX) + ): + entities.append(VistapoolDosingTankBinarySensor(coordinator)) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, @@ -169,43 +211,20 @@ async def async_setup_entry( ) -> None: """Set up Vistapool binary sensors for every pool on the account.""" entities: list[BinarySensorEntity] = [] - for coordinator in entry.runtime_data.coordinators.values(): - for description in BINARY_SENSOR_DESCRIPTIONS: - if description.exists_path is not None: - required = ( - (description.exists_path,) - if isinstance(description.exists_path, str) - else description.exists_path - ) - if not all(coordinator.get_value(path) for path in required): - continue - entities.append(VistapoolBinarySensor(coordinator, description)) - - if coordinator.get_value(PATH_HASHIDRO): - is_electrolysis = coordinator.get_value("hidro.is_electrolysis") - entities.append( - VistapoolBinarySensor( - coordinator, - VistapoolBinarySensorEntityDescription( - key="electrolysis_low" if is_electrolysis else "hydrolysis_low", - translation_key=( - "electrolysis_low" if is_electrolysis else "hydrolysis_low" - ), - device_class=BinarySensorDeviceClass.PROBLEM, - value_path="hidro.low", - ), - ) - ) - - if any( - coordinator.get_value(path) - for path in (PATH_HASCD, PATH_HASCL, PATH_HASPH, PATH_HASRX) - ): - entities.append(VistapoolDosingTankBinarySensor(coordinator)) - + entities.extend(_build_binary_sensor_entities(coordinator)) async_add_entities(entities) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_binary_sensor_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + class VistapoolBinarySensor(VistapoolEntity, BinarySensorEntity): """Generic Vistapool binary sensor driven by an entity description.""" diff --git a/homeassistant/components/vistapool/button.py b/homeassistant/components/vistapool/button.py index bfc825807b97..0524f84226d1 100644 --- a/homeassistant/components/vistapool/button.py +++ b/homeassistant/components/vistapool/button.py @@ -6,12 +6,13 @@ from typing import override from aioaquarite import AquariteError from homeassistant.components.button import ButtonEntity -from homeassistant.core import HomeAssistant +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 . import VistapoolConfigEntry -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -22,16 +23,34 @@ _LIGHT_STATUS_PATH = "light.status" _LED_PULSE_DELAY_SECONDS = 1.0 +def _build_button_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[VistapoolLEDPulseButton]: + """Build the button entities for a single pool.""" + if not coordinator.get_value(_HASLED_PATH): + return [] + return [VistapoolLEDPulseButton(coordinator)] + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vistapool buttons for every pool that has an LED fixture.""" - async_add_entities( - VistapoolLEDPulseButton(coordinator) - for coordinator in entry.runtime_data.coordinators.values() - if coordinator.get_value(_HASLED_PATH) + entities: list[VistapoolLEDPulseButton] = [] + for coordinator in entry.runtime_data.coordinators.values(): + entities.extend(_build_button_entities(coordinator)) + async_add_entities(entities) + + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_button_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) ) diff --git a/homeassistant/components/vistapool/const.py b/homeassistant/components/vistapool/const.py index f7e30b95aaa9..f2897f2f86d4 100644 --- a/homeassistant/components/vistapool/const.py +++ b/homeassistant/components/vistapool/const.py @@ -12,3 +12,5 @@ PATH_HASPH = f"{PATH_PREFIX}hasPH" PATH_HASRX = f"{PATH_PREFIX}hasRX" PATH_HASUV = f"{PATH_PREFIX}hasUV" PATH_HASHIDRO = f"{PATH_PREFIX}hasHidro" + +SIGNAL_NEW_POOL = f"{DOMAIN}_new_pool" diff --git a/homeassistant/components/vistapool/light.py b/homeassistant/components/vistapool/light.py index f4d05a23db19..6967a233d5dd 100644 --- a/homeassistant/components/vistapool/light.py +++ b/homeassistant/components/vistapool/light.py @@ -5,12 +5,13 @@ from typing import Any, override from aioaquarite import AquariteError from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.core import HomeAssistant +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 . import VistapoolConfigEntry -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -30,6 +31,16 @@ async def async_setup_entry( for coordinator in entry.runtime_data.coordinators.values() ) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities([VistapoolLight(coordinator)]) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + class VistapoolLight(VistapoolEntity, LightEntity): """Representation of a Vistapool pool light.""" diff --git a/homeassistant/components/vistapool/manifest.json b/homeassistant/components/vistapool/manifest.json index bc07c75d6683..6e8106720570 100644 --- a/homeassistant/components/vistapool/manifest.json +++ b/homeassistant/components/vistapool/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["aioaquarite"], "quality_scale": "bronze", - "requirements": ["aioaquarite==0.5.1"] + "requirements": ["aioaquarite==0.6.1"] } diff --git a/homeassistant/components/vistapool/number.py b/homeassistant/components/vistapool/number.py index 2c8572ccec41..0e4fa5787625 100644 --- a/homeassistant/components/vistapool/number.py +++ b/homeassistant/components/vistapool/number.py @@ -16,12 +16,13 @@ from homeassistant.const import ( UnitOfElectricPotential, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +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 . import VistapoolConfigEntry -from .const import DOMAIN, PATH_HASHIDRO, PATH_HASPH, PATH_HASRX +from .const import DOMAIN, PATH_HASHIDRO, PATH_HASPH, PATH_HASRX, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -138,6 +139,48 @@ NUMBER_DESCRIPTIONS: tuple[VistapoolNumberEntityDescription, ...] = ( ) +def _build_number_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[NumberEntity]: + """Build the number entities for a single pool.""" + entities: list[NumberEntity] = [] + for description in NUMBER_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolNumber(coordinator, description)) + + if coordinator.get_value(PATH_HASHIDRO): + key = ( + "hydrolysis_setpoint" + if coordinator.get_value("hidro.is_electrolysis") is False + else "electrolysis_setpoint" + ) + entities.append( + VistapoolNumber( + coordinator, + VistapoolNumberEntityDescription( + key=key, + translation_key=key, + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=50.0, + native_step=0.1, + native_unit_of_measurement="g/h", + value_path="hidro.level", + scale=10, + max_value_fn=_max_electrolysis, + ), + ) + ) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, @@ -145,45 +188,20 @@ async def async_setup_entry( ) -> None: """Set up Vistapool number entities for every pool on the account.""" entities: list[NumberEntity] = [] - for coordinator in entry.runtime_data.coordinators.values(): - for description in NUMBER_DESCRIPTIONS: - if description.exists_path is not None: - required = ( - (description.exists_path,) - if isinstance(description.exists_path, str) - else description.exists_path - ) - if not all(coordinator.get_value(path) for path in required): - continue - entities.append(VistapoolNumber(coordinator, description)) - - if coordinator.get_value(PATH_HASHIDRO): - key = ( - "hydrolysis_setpoint" - if coordinator.get_value("hidro.is_electrolysis") is False - else "electrolysis_setpoint" - ) - entities.append( - VistapoolNumber( - coordinator, - VistapoolNumberEntityDescription( - key=key, - translation_key=key, - entity_category=EntityCategory.CONFIG, - native_min_value=0, - native_max_value=50.0, - native_step=0.1, - native_unit_of_measurement="g/h", - value_path="hidro.level", - scale=10, - max_value_fn=_max_electrolysis, - ), - ) - ) - + entities.extend(_build_number_entities(coordinator)) async_add_entities(entities) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_number_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + class VistapoolNumber(VistapoolEntity, NumberEntity): """Generic Vistapool number driven by an entity description.""" diff --git a/homeassistant/components/vistapool/quality_scale.yaml b/homeassistant/components/vistapool/quality_scale.yaml index 4452457150fd..a39a93b7b22b 100644 --- a/homeassistant/components/vistapool/quality_scale.yaml +++ b/homeassistant/components/vistapool/quality_scale.yaml @@ -35,9 +35,11 @@ rules: docs-configuration-parameters: status: exempt comment: No options flow + docs-installation-parameters: todo docs-troubleshooting: done entity-category: done entity-disabled-by-default: done + entity-unavailable: todo integration-owner: done log-when-unavailable: done parallel-updates: done @@ -57,7 +59,8 @@ rules: docs-supported-devices: done docs-supported-functions: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done + entity-device-class: todo entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/vistapool/select.py b/homeassistant/components/vistapool/select.py index 97e98b8429a7..8db7d4ed06ca 100644 --- a/homeassistant/components/vistapool/select.py +++ b/homeassistant/components/vistapool/select.py @@ -7,12 +7,13 @@ from aioaquarite import AquariteError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +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 . import VistapoolConfigEntry -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_NEW_POOL from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -60,6 +61,24 @@ SELECT_DESCRIPTIONS: tuple[VistapoolSelectEntityDescription, ...] = ( ) +def _build_select_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[SelectEntity]: + """Build the select entities for a single pool.""" + entities: list[SelectEntity] = [] + for description in SELECT_DESCRIPTIONS: + if description.exists_path is not None: + required = ( + (description.exists_path,) + if isinstance(description.exists_path, str) + else description.exists_path + ) + if not all(coordinator.get_value(path) for path in required): + continue + entities.append(VistapoolSelect(coordinator, description)) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, @@ -67,21 +86,20 @@ async def async_setup_entry( ) -> None: """Set up Vistapool select entities for every pool on the account.""" entities: list[SelectEntity] = [] - for coordinator in entry.runtime_data.coordinators.values(): - for description in SELECT_DESCRIPTIONS: - if description.exists_path is not None: - required = ( - (description.exists_path,) - if isinstance(description.exists_path, str) - else description.exists_path - ) - if not all(coordinator.get_value(path) for path in required): - continue - entities.append(VistapoolSelect(coordinator, description)) - + entities.extend(_build_select_entities(coordinator)) async_add_entities(entities) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_select_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + def _to_index(raw: Any) -> int | None: """Convert a coordinator value into an options-list index, or None if not possible.""" diff --git a/homeassistant/components/vistapool/sensor.py b/homeassistant/components/vistapool/sensor.py index 21bde1bdf4ce..5ecb8a37622f 100644 --- a/homeassistant/components/vistapool/sensor.py +++ b/homeassistant/components/vistapool/sensor.py @@ -17,7 +17,8 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfTime, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VistapoolConfigEntry @@ -28,6 +29,7 @@ from .const import ( PATH_HASPH, PATH_HASRX, PATH_HASUV, + SIGNAL_NEW_POOL, ) from .coordinator import VistapoolDataUpdateCoordinator from .entity import VistapoolEntity @@ -125,6 +127,39 @@ SENSOR_DESCRIPTIONS: tuple[VistapoolSensorEntityDescription, ...] = ( ) +def _build_sensor_entities( + coordinator: VistapoolDataUpdateCoordinator, +) -> list[VistapoolSensorEntity]: + """Build the sensor entities for a single pool.""" + entities: list[VistapoolSensorEntity] = [] + for description in SENSOR_DESCRIPTIONS: + if description.exists_path is not None and not coordinator.get_value( + description.exists_path + ): + continue + entities.append(VistapoolSensorEntity(coordinator, description)) + + # Electrolysis/hydrolysis: dynamic key based on hardware type + if coordinator.get_value(PATH_HASHIDRO): + is_electrolysis = coordinator.get_value("hidro.is_electrolysis") + entities.append( + VistapoolSensorEntity( + coordinator, + VistapoolSensorEntityDescription( + key="electrolysis" if is_electrolysis else "hydrolysis", + translation_key=( + "electrolysis" if is_electrolysis else "hydrolysis" + ), + native_unit_of_measurement="g/h", + state_class=SensorStateClass.MEASUREMENT, + value_path="hidro.current", + value_fn=_convert_tenths, + ), + ) + ) + return entities + + async def async_setup_entry( hass: HomeAssistant, entry: VistapoolConfigEntry, @@ -132,36 +167,20 @@ async def async_setup_entry( ) -> None: """Set up Vistapool sensors for every pool on the account.""" entities: list[VistapoolSensorEntity] = [] - for coordinator in entry.runtime_data.coordinators.values(): - for description in SENSOR_DESCRIPTIONS: - if description.exists_path is not None and not coordinator.get_value( - description.exists_path - ): - continue - entities.append(VistapoolSensorEntity(coordinator, description)) - - # Electrolysis/hydrolysis: dynamic key based on hardware type - if coordinator.get_value(PATH_HASHIDRO): - is_electrolysis = coordinator.get_value("hidro.is_electrolysis") - entities.append( - VistapoolSensorEntity( - coordinator, - VistapoolSensorEntityDescription( - key="electrolysis" if is_electrolysis else "hydrolysis", - translation_key=( - "electrolysis" if is_electrolysis else "hydrolysis" - ), - native_unit_of_measurement="g/h", - state_class=SensorStateClass.MEASUREMENT, - value_path="hidro.current", - value_fn=_convert_tenths, - ), - ) - ) - + entities.extend(_build_sensor_entities(coordinator)) async_add_entities(entities) + @callback + def _async_add_pool(coordinator: VistapoolDataUpdateCoordinator) -> None: + async_add_entities(_build_sensor_entities(coordinator)) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_NEW_POOL}_{entry.entry_id}", _async_add_pool + ) + ) + class VistapoolSensorEntity(VistapoolEntity, SensorEntity): """Generic Vistapool sensor driven by an entity description.""" diff --git a/homeassistant/components/vizio/remote.py b/homeassistant/components/vizio/remote.py index 3b44dffbd3a4..d5b86fc2c882 100644 --- a/homeassistant/components/vizio/remote.py +++ b/homeassistant/components/vizio/remote.py @@ -23,6 +23,32 @@ from .coordinator import VizioConfigEntry, VizioDeviceCoordinator PARALLEL_UPDATES = 0 +# Maps native pyvizio key names to human-friendly aliases. +# Keys are uppercase native names (e.g. "CC_TOGGLE"), values are lists of lowercase aliases. +REMOTE_KEY_ALIASES: dict[str, list[str]] = { + "CC_TOGGLE": ["closed_captions", "cc"], + "CH_DOWN": ["channel_down"], + "CH_PREV": ["previous_channel"], + "CH_UP": ["channel_up"], + "INPUT_NEXT": ["next_input"], + "MUTE_TOGGLE": ["mute", "toggle_mute"], + "OK": ["enter", "select"], + "PIC_MODE": ["picture_mode"], + "PIC_SIZE": ["picture_size"], + "POW_OFF": ["off", "power_off"], + "POW_ON": ["on", "power_on"], + "POW_TOGGLE": ["power", "power_toggle", "toggle_power"], + "SEEK_BACK": ["reverse", "rewind"], + "SEEK_FWD": ["forward", "fast_forward", "ff"], + "VOL_DOWN": ["volume_down"], + "VOL_UP": ["volume_up"], +} + +# Invert aliases into {alias: native_key} for O(1) lookup +_ALIAS_LOOKUP: dict[str, str] = { + alias: key for key, aliases in REMOTE_KEY_ALIASES.items() for alias in aliases +} + async def async_setup_entry( hass: HomeAssistant, @@ -49,7 +75,12 @@ class VizioRemote(CoordinatorEntity[VizioDeviceCoordinator], RemoteEntity): self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) self._device = coordinator.device valid_keys = set(self._device.get_remote_keys_list()) + # Map lowercased native keys to their original uppercase pyvizio names self._command_map: dict[str, str] = {key.lower(): key for key in valid_keys} + # Add aliases only for native keys this device actually supports + for alias, target in _ALIAS_LOOKUP.items(): + if target in valid_keys: + self._command_map[alias] = target @property @override diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index bf191e5b6c6f..41b60a6bd823 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import CONF_STATION_NUMBER, DOMAIN, SUBENTRY_TYPE_STATION from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator @@ -126,10 +126,10 @@ 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_disabled_by = device.disabled_by + # The device registry will set the disabled_by flag to None when + # moving a device disabled by CONFIG_ENTRY to an enabled config + # entry, but we want to set it to USER instead. + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED if ( device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY and not all_disabled @@ -138,20 +138,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device_registry.async_update_device( device.id, disabled_by=device_disabled_by, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, + new_config_entry_id=parent_entry.entry_id, + new_config_subentry_id=subentry.subentry_id, ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - remove_config_subentry_id=None, - ) if parent_entry.entry_id != entry.entry_id: await hass.config_entries.async_remove(entry.entry_id) diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index bbd310bece50..cb78e17031d0 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -9,10 +9,11 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, + CONF_LATITUDE, + CONF_LONGITUDE, CONF_SHOW_ON_MAP, PERCENTAGE, + EntityStateAttribute, UnitOfMass, ) from homeassistant.core import HomeAssistant @@ -97,11 +98,11 @@ class RealtimeEmissionsSensor(CoordinatorEntity[WattTimeCoordinator], SensorEnti # Conversely, we can hide the location on the map by using other keys, like # "lati" and "long". if self._entry.options.get(CONF_SHOW_ON_MAP) is not False: - attrs[ATTR_LATITUDE] = self._entry.data[ATTR_LATITUDE] - attrs[ATTR_LONGITUDE] = self._entry.data[ATTR_LONGITUDE] + attrs[EntityStateAttribute.LATITUDE] = self._entry.data[CONF_LATITUDE] + attrs[EntityStateAttribute.LONGITUDE] = self._entry.data[CONF_LONGITUDE] else: - attrs["lati"] = self._entry.data[ATTR_LATITUDE] - attrs["long"] = self._entry.data[ATTR_LONGITUDE] + attrs["lati"] = self._entry.data[CONF_LATITUDE] + attrs["long"] = self._entry.data[CONF_LONGITUDE] return attrs diff --git a/homeassistant/components/wattwaechter/diagnostics.py b/homeassistant/components/wattwaechter/diagnostics.py new file mode 100644 index 000000000000..a59cfaa9df29 --- /dev/null +++ b/homeassistant/components/wattwaechter/diagnostics.py @@ -0,0 +1,53 @@ +"""Diagnostics support for the WattWächter Plus integration.""" + +from dataclasses import asdict +from typing import Any + +from aio_wattwaechter import ( + WattwaechterAuthenticationError, + WattwaechterConnectionError, +) +from aio_wattwaechter.models import SystemInfo + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_MAC, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import WattwaechterConfigEntry + +# The device exposes network identifiers as system info values; redact the +# credential and hardware/network identifiers. Local IPs are kept for support. +TO_REDACT = {CONF_TOKEN, CONF_MAC, "ssid", "mac_address", "mdns_name"} + + +def _flatten_system(system: SystemInfo) -> dict[str, dict[str, Any]]: + """Flatten system info sections into {section: {name: value}} mappings.""" + return { + section: {entry["name"]: entry["value"] for entry in entries} + for section, entries in asdict(system).items() + } + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: WattwaechterConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + # System info is only needed on demand here, so it is fetched directly + # instead of in the update loop to avoid coupling meter sensor + # availability to it. Failure still yields the config and meter data. + system: dict[str, dict[str, Any]] | None = None + try: + system = _flatten_system(await coordinator.client.system_info()) + except WattwaechterConnectionError, WattwaechterAuthenticationError: + system = None + + return async_redact_data( + { + "config_entry": dict(entry.data), + "meter": asdict(coordinator.data), + "system": system, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/wattwaechter/quality_scale.yaml b/homeassistant/components/wattwaechter/quality_scale.yaml index 247726f5ade8..a831a42304cf 100644 --- a/homeassistant/components/wattwaechter/quality_scale.yaml +++ b/homeassistant/components/wattwaechter/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done docs-data-update: todo diff --git a/homeassistant/components/weatherflow/icons.json b/homeassistant/components/weatherflow/icons.json index 6b691f41c9b3..3d16c457cc44 100644 --- a/homeassistant/components/weatherflow/icons.json +++ b/homeassistant/components/weatherflow/icons.json @@ -15,6 +15,15 @@ "lightning_count": { "default": "mdi:lightning-bolt" }, + "lightning_strike_last_distance": { + "default": "mdi:lightning-bolt" + }, + "lightning_strike_last_energy": { + "default": "mdi:lightning-bolt" + }, + "lightning_strike_last_epoch": { + "default": "mdi:lightning-bolt" + }, "precipitation_type": { "default": "mdi:weather-rainy" }, diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 46af3699298f..d83eda960baa 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -10,6 +10,7 @@ from pyweatherflowudp.const import EVENT_RAPID_WIND from pyweatherflowudp.device import ( EVENT_OBSERVATION, EVENT_STATUS_UPDATE, + EVENT_STRIKE, WeatherFlowDevice, WeatherFlowSensorDevice, ) @@ -60,12 +61,13 @@ class WeatherFlowSensorEntityDescription(SensorEntityDescription): raw_data_conv_fn: Callable[[Any], datetime | StateType] + device_attr: str | None = None event_subscriptions: list[str] = field(default_factory=lambda: [EVENT_OBSERVATION]) imperial_suggested_unit: str | None = None def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: """Return the parsed sensor value.""" - if (raw_sensor_data := getattr(device, self.key)) is None: + if (raw_sensor_data := getattr(device, self.device_attr or self.key)) is None: return None return self.raw_data_conv_fn(raw_sensor_data) @@ -153,6 +155,33 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, raw_data_conv_fn=lambda raw_data: raw_data, ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_distance", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_distance", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + suggested_display_precision=2, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.distance.magnitude, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_energy", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_energy", + state_class=SensorStateClass.MEASUREMENT, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.energy, + ), + WeatherFlowSensorEntityDescription( + key="lightning_strike_last_epoch", + device_attr="last_lightning_strike_event", + translation_key="lightning_strike_last_epoch", + device_class=SensorDeviceClass.TIMESTAMP, + event_subscriptions=[EVENT_STRIKE], + raw_data_conv_fn=lambda raw_data: raw_data.timestamp, + ), WeatherFlowSensorEntityDescription( key="precipitation_type", translation_key="precipitation_type", @@ -310,7 +339,7 @@ async def async_setup_entry( is_metric=(hass.config.units == METRIC_SYSTEM), ) for description in SENSORS - if hasattr(device, description.key) + if hasattr(device, description.device_attr or description.key) ] async_add_entities(sensors) diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index d2146e883703..eb5621317094 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -48,6 +48,15 @@ "lightning_count": { "name": "Lightning count" }, + "lightning_strike_last_distance": { + "name": "Lightning last distance" + }, + "lightning_strike_last_energy": { + "name": "Lightning last energy" + }, + "lightning_strike_last_epoch": { + "name": "Lightning last strike" + }, "precipitation_type": { "name": "Precipitation type", "state": { diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py index d9860bdb0fe5..5a02f76d163d 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -4,10 +4,13 @@ import asyncio from weatherflow4py.api import WeatherFlowRestAPI from weatherflow4py.ws import WeatherFlowWebsocketAPI +from websockets.exceptions import WebSocketException from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.ssl import client_context from .const import LOGGER from .coordinator import ( @@ -67,11 +70,26 @@ async def async_setup_entry( stations=stations, ) - # Run setup method - await asyncio.gather( - websocket_wind_coordinator.async_setup(), - websocket_observation_coordinator.async_setup(), - ) + async def _async_disconnect_websocket() -> None: + """Disconnect the WeatherFlow websocket.""" + await websocket_api.stop_all_listeners() + await websocket_api.close() + + # Connect once because both websocket coordinators share this API instance. + try: + await websocket_api.connect(client_context()) + except (OSError, WebSocketException) as err: + raise ConfigEntryNotReady("Error connecting to WeatherFlow websocket") from err + + entry.async_on_unload(_async_disconnect_websocket) + + try: + await asyncio.gather( + websocket_wind_coordinator.async_setup(), + websocket_observation_coordinator.async_setup(), + ) + except (OSError, WebSocketException) as err: + raise ConfigEntryNotReady("Error setting up WeatherFlow websocket") from err entry.runtime_data = WeatherFlowCoordinators( rest_data_coordinator, @@ -80,14 +98,6 @@ async def async_setup_entry( ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Websocket disconnect handler - async def _async_disconnect_websocket() -> None: - await websocket_api.stop_all_listeners() - await websocket_api.close() - - # Register a websocket shutdown handler - entry.async_on_unload(_async_disconnect_websocket) - return True diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index 609c00899e98..448d7096aa31 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -26,7 +26,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.ssl import client_context from .const import DOMAIN, LOGGER @@ -148,8 +147,7 @@ class BaseWebsocketCoordinator[T](BaseWeatherFlowCoordinator[dict[int, T | None] } async def async_setup(self) -> None: - """Set up the websocket connection.""" - await self.websocket_api.connect(client_context()) + """Register callbacks and subscribe to device messages.""" self.websocket_api.register_callback( message_type=self._event_type, callback=self._handle_websocket_message, diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 5907865aa091..fdced86518b8 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, ServiceResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -155,7 +155,8 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ): self._supported_features = ( state.attributes.get( - ATTR_SUPPORTED_FEATURES, MediaPlayerEntityFeature(0) + EntityStateAttribute.SUPPORTED_FEATURES, + MediaPlayerEntityFeature(0), ) & ~MediaPlayerEntityFeature.TURN_ON ) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 13f856e66674..a6d3b54f4271 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -131,7 +131,7 @@ def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes: """ return b"".join( ( - _partial_cached_event_message(event)[:-1], + _partial_cached_event_message(event), b',"id":', message_id_as_bytes, b"}", @@ -143,13 +143,14 @@ def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes: def _partial_cached_event_message(event: Event) -> bytes: """Cache and serialize the event to json. - The message is constructed without the id which appended - in cached_event_message. + The message is cached without the trailing "}" and without the id, both of + which are appended in cached_event_message. Trimming here means the slice + happens once per event instead of once per subscriber. """ return ( _message_to_json_bytes_or_none({"type": "event", "event": event.json_fragment}) or INVALID_JSON_PARTIAL_MESSAGE - ) + )[:-1] def cached_state_diff_message( @@ -165,7 +166,7 @@ def cached_state_diff_message( """ return b"".join( ( - _partial_cached_state_diff_message(event)[:-1], + _partial_cached_state_diff_message(event), b',"id":', message_id_as_bytes, b"}", @@ -177,15 +178,16 @@ def cached_state_diff_message( def _partial_cached_state_diff_message(event: Event[EventStateChangedData]) -> bytes: """Cache and serialize the event to json. - The message is constructed without the id which - will be appended in cached_state_diff_message + The message is cached without the trailing "}" and without the id, both of + which are appended in cached_state_diff_message. Trimming here means the + slice happens once per event instead of once per subscriber. """ return ( _message_to_json_bytes_or_none( {"type": "event", "event": _state_diff_event(event)} ) or INVALID_JSON_PARTIAL_MESSAGE - ) + )[:-1] def _state_diff_event( diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 1cdb875549c6..2724a87d3079 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -21,6 +21,8 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.LIGHT, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py index eb6a5759b7fc..ec98253e9716 100644 --- a/homeassistant/components/whirlpool/entity.py +++ b/homeassistant/components/whirlpool/entity.py @@ -75,6 +75,15 @@ class WhirlpoolOvenEntity(WhirlpoolEntity): _appliance: Oven + @staticmethod + def cavity_suffix(oven: Oven, cavity: OvenCavity) -> str: + """Return the unique-id and translation-key suffix for an oven cavity.""" + if oven.get_oven_cavity_exists( + OvenCavity.Upper + ) and oven.get_oven_cavity_exists(OvenCavity.Lower): + return "_upper" if cavity == OvenCavity.Upper else "_lower" + return "" + def __init__( self, appliance: Oven, @@ -84,14 +93,7 @@ class WhirlpoolOvenEntity(WhirlpoolEntity): ) -> None: """Initialize the entity.""" self.cavity = cavity - cavity_suffix = "" - if appliance.get_oven_cavity_exists( - OvenCavity.Upper - ) and appliance.get_oven_cavity_exists(OvenCavity.Lower): - if cavity == OvenCavity.Upper: - cavity_suffix = "_upper" - elif cavity == OvenCavity.Lower: - cavity_suffix = "_lower" + cavity_suffix = self.cavity_suffix(appliance, cavity) super().__init__( appliance, unique_id_suffix=f"{unique_id_suffix}{cavity_suffix}" ) diff --git a/homeassistant/components/whirlpool/light.py b/homeassistant/components/whirlpool/light.py new file mode 100644 index 000000000000..3314c70b39da --- /dev/null +++ b/homeassistant/components/whirlpool/light.py @@ -0,0 +1,62 @@ +"""Light platform for the Whirlpool Appliances integration.""" + +from typing import Any, override + +from whirlpool.oven import Cavity as OvenCavity, Oven + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .entity import WhirlpoolOvenEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the light platform.""" + appliances_manager = config_entry.runtime_data + async_add_entities( + WhirlpoolOvenLight(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + ) + + +class WhirlpoolOvenLight(WhirlpoolOvenEntity, LightEntity): + """Light for an oven cavity.""" + + _appliance: Oven + + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven light.""" + super().__init__(appliance, cavity, "oven_light", "-light") + + @property + @override + def is_on(self) -> bool | None: + """Return whether the light is on.""" + return self._appliance.get_light(self.cavity) + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + WhirlpoolOvenLight._check_service_request( + await self._appliance.set_light(True, self.cavity) + ) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + WhirlpoolOvenLight._check_service_request( + await self._appliance.set_light(False, self.cavity) + ) diff --git a/homeassistant/components/whirlpool/number.py b/homeassistant/components/whirlpool/number.py new file mode 100644 index 000000000000..a76c44014504 --- /dev/null +++ b/homeassistant/components/whirlpool/number.py @@ -0,0 +1,79 @@ +"""Number platform for the Whirlpool Appliances integration.""" + +from typing import override + +from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven + +from homeassistant.components.number import NumberDeviceClass, NumberEntity +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WhirlpoolConfigEntry +from .const import DOMAIN +from .entity import WhirlpoolOvenEntity + +PARALLEL_UPDATES = 1 + +# Oven target temperatures are handled in Celsius. The appliance accepts +# tenth-of-a-degree values, so a 1-degree step gives fine manual control while +# automations can still set any value Home Assistant passes through. +OVEN_MIN_TEMP = 30 +OVEN_MAX_TEMP = 290 +OVEN_TEMP_STEP = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WhirlpoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the number platform.""" + appliances_manager = config_entry.runtime_data + async_add_entities( + WhirlpoolOvenTargetTemperature(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + ) + + +class WhirlpoolOvenTargetTemperature(WhirlpoolOvenEntity, NumberEntity): + """Settable target temperature for an oven cavity.""" + + _attr_device_class = NumberDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_native_min_value = OVEN_MIN_TEMP + _attr_native_max_value = OVEN_MAX_TEMP + _attr_native_step = OVEN_TEMP_STEP + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven target temperature number.""" + super().__init__( + appliance, cavity, "oven_target_temperature", "-target_temperature" + ) + + @override + @property + def native_value(self) -> float | None: + """Return the current target temperature.""" + return self._appliance.get_target_temp(self.cavity) + + @override + async def async_set_native_value(self, value: float) -> None: + """Set a new target temperature, keeping the current cook mode.""" + mode = self._appliance.get_cook_mode(self.cavity) + if mode is None or mode == CookMode.Standby: + mode = CookMode.Bake + try: + WhirlpoolOvenTargetTemperature._check_service_request( + await self._appliance.set_cook( + target_temp=value, mode=mode, cavity=self.cavity + ) + ) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml index 2f75dd42e1aa..1a444ee0f4e5 100644 --- a/homeassistant/components/whirlpool/quality_scale.yaml +++ b/homeassistant/components/whirlpool/quality_scale.yaml @@ -74,9 +74,7 @@ rules: comment: | Time remaining sensor still has hardcoded icon. reconfiguration-flow: todo - repair-issues: - status: exempt - comment: No known use cases for repair issues or flows, yet + repair-issues: done stale-devices: todo # Platinum diff --git a/homeassistant/components/whirlpool/select.py b/homeassistant/components/whirlpool/select.py index 9bac108976a8..6df32716ab69 100644 --- a/homeassistant/components/whirlpool/select.py +++ b/homeassistant/components/whirlpool/select.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Final, override from whirlpool.appliance import Appliance +from whirlpool.oven import Cavity as OvenCavity, CookMode, Oven from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import UnitOfTemperature @@ -14,10 +15,26 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry from .const import DOMAIN -from .entity import WhirlpoolEntity +from .entity import WhirlpoolEntity, WhirlpoolOvenEntity PARALLEL_UPDATES = 1 +OVEN_COOK_MODES: Final[dict[CookMode, str]] = { + CookMode.Standby: "standby", + CookMode.Bake: "bake", + CookMode.ConvectBake: "convection_bake", + CookMode.Broil: "broil", + CookMode.ConvectBroil: "convection_broil", + CookMode.ConvectRoast: "convection_roast", + CookMode.KeepWarm: "keep_warm", + CookMode.AirFry: "air_fry", +} +OPTION_TO_OVEN_COOK_MODE: Final = {v: k for k, v in OVEN_COOK_MODES.items()} + +# Target temperature (Celsius) used when a mode is selected while the oven is +# idle and has no target set yet. +DEFAULT_OVEN_TEMP = 175 + @dataclass(frozen=True, kw_only=True) class WhirlpoolSelectDescription(SelectEntityDescription): @@ -49,11 +66,18 @@ async def async_setup_entry( """Set up the select platform.""" appliances_manager = config_entry.runtime_data - async_add_entities( + entities: list[SelectEntity] = [ WhirlpoolSelectEntity(refrigerator, description) for refrigerator in appliances_manager.refrigerators for description in REFRIGERATOR_DESCRIPTIONS + ] + entities.extend( + WhirlpoolOvenCookModeSelect(oven, cavity) + for oven in appliances_manager.ovens + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) ) + async_add_entities(entities) class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity): @@ -84,3 +108,44 @@ class WhirlpoolSelectEntity(WhirlpoolEntity, SelectEntity): translation_domain=DOMAIN, translation_key="invalid_value_set", ) from err + + +class WhirlpoolOvenCookModeSelect(WhirlpoolOvenEntity, SelectEntity): + """Settable cook mode for an oven cavity.""" + + _attr_options = list(OVEN_COOK_MODES.values()) + + def __init__(self, appliance: Oven, cavity: OvenCavity) -> None: + """Initialize the oven cook mode select.""" + super().__init__(appliance, cavity, "oven_cook_mode", "-cook_mode") + + @override + @property + def current_option(self) -> str | None: + """Return the current cook mode, if it is a selectable one.""" + return OVEN_COOK_MODES.get(self._appliance.get_cook_mode(self.cavity)) + + @override + async def async_select_option(self, option: str) -> None: + """Set the cook mode, keeping the current/last target temperature.""" + mode = OPTION_TO_OVEN_COOK_MODE[option] + try: + if mode == CookMode.Standby: + # Standby is the idle state: the oven reaches it by cancelling + # the current cook, not by starting a "standby" cook. + result = await self._appliance.stop_cook(self.cavity) + else: + target = self._appliance.get_target_temp(self.cavity) + if target is None: + target = DEFAULT_OVEN_TEMP + result = await self._appliance.set_cook( + target_temp=target, + mode=mode, + cavity=self.cavity, + ) + WhirlpoolOvenCookModeSelect._check_service_request(result) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_value_set", + ) from err diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index e7df831bb7d7..14c695db99fb 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -23,14 +23,16 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfTemperature +from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import WhirlpoolConfigEntry from .entity import WhirlpoolEntity, WhirlpoolOvenEntity +from .util import deprecate_entity PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(minutes=5) @@ -256,6 +258,34 @@ OVEN_CAVITY_SENSORS: tuple[WhirlpoolOvenCavitySensorEntityDescription, ...] = ( ), ) +# Sensors replaced by more capable entities (select and number respectively). +DEPRECATED_OVEN_SENSOR_KEYS = ("oven_cook_mode", "oven_target_temperature") + + +def _build_oven_cavity_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + oven: Oven, + cavity: OvenCavity, +) -> list[SensorEntity]: + """Build the sensors for a single oven cavity, handling deprecations.""" + suffix = WhirlpoolOvenEntity.cavity_suffix(oven, cavity) + sensors: list[SensorEntity] = [] + for description in OVEN_CAVITY_SENSORS: + # The oven cook mode and target temperature sensors have been replaced + # by select and number entities respectively. + if description.key in DEPRECATED_OVEN_SENSOR_KEYS and not deprecate_entity( + hass, + entity_registry, + platform_domain=Platform.SENSOR, + entity_unique_id=f"{oven.said}-{description.key}{suffix}", + issue_id=f"deprecated_{description.key}_{oven.said}{suffix}", + translation_key=f"deprecated_{description.key}", + ): + continue + sensors.append(WhirlpoolOvenCavitySensor(oven, cavity, description)) + return sensors + async def async_setup_entry( hass: HomeAssistant, @@ -289,18 +319,13 @@ async def async_setup_entry( for description in WASHER_DRYER_TIME_SENSORS ] - oven_upper_cavity_sensors = [ - WhirlpoolOvenCavitySensor(oven, OvenCavity.Upper, description) + entity_registry = er.async_get(hass) + oven_cavity_sensors = [ + sensor for oven in appliances_manager.ovens - if oven.get_oven_cavity_exists(OvenCavity.Upper) - for description in OVEN_CAVITY_SENSORS - ] - - oven_lower_cavity_sensors = [ - WhirlpoolOvenCavitySensor(oven, OvenCavity.Lower, description) - for oven in appliances_manager.ovens - if oven.get_oven_cavity_exists(OvenCavity.Lower) - for description in OVEN_CAVITY_SENSORS + for cavity in (OvenCavity.Upper, OvenCavity.Lower) + if oven.get_oven_cavity_exists(cavity) + for sensor in _build_oven_cavity_sensors(hass, entity_registry, oven, cavity) ] async_add_entities( @@ -309,8 +334,7 @@ async def async_setup_entry( *washer_time_sensors, *dryer_sensors, *dryer_time_sensors, - *oven_upper_cavity_sensors, - *oven_lower_cavity_sensors, + *oven_cavity_sensors, ] ) @@ -332,29 +356,29 @@ class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): - """Abstract base class for Whirlpool washer/dryer time sensors.""" +class WhirlpoolTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): + """Abstract base class for Whirlpool end-time timestamp sensors.""" _attr_should_poll = True - _appliance: Washer | Dryer - def __init__( - self, appliance: Washer | Dryer, description: SensorEntityDescription - ) -> None: - """Initialize the washer/dryer sensor.""" - super().__init__(appliance, unique_id_suffix=f"-{description.key}") - self.entity_description = description + def __init__(self, appliance: Appliance, unique_id_suffix: str) -> None: + """Initialize the time sensor.""" + super().__init__(appliance, unique_id_suffix=unique_id_suffix) self._running: bool | None = None self._value: datetime | None = None @abstractmethod - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: + """Return true if the timer/cycle is in a finished state.""" @abstractmethod - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: + """Return true if the timer/cycle is in a running state.""" + + @abstractmethod + def _get_seconds_remaining(self) -> int: + """Return the number of seconds remaining.""" @override async def async_added_to_hass(self) -> None: @@ -368,21 +392,19 @@ class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): """Update status of Whirlpool.""" await self._appliance.fetch_data() - @override @property + @override def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" now = utcnow() - if self._is_machine_state_finished() and self._running: + if self._is_finished() and self._running: self._running = False self._value = now - if self._is_machine_state_running(): + if self._is_running(): self._running = True - new_timestamp = now + timedelta( - seconds=self._appliance.get_time_remaining() - ) + new_timestamp = now + timedelta(seconds=self._get_seconds_remaining()) if self._value is None or ( isinstance(self._value, datetime) and abs(new_timestamp - self._value) > timedelta(seconds=60) @@ -391,45 +413,59 @@ class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): return self._value -class WasherTimeSensor(WasherDryerTimeSensorBase): +class WasherTimeSensor(WhirlpoolTimeSensorBase): """A timestamp class for Whirlpool washers.""" _appliance: Washer + def __init__(self, appliance: Washer, description: SensorEntityDescription) -> None: + """Initialize the washer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description = description + @override - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: return self._appliance.get_machine_state() in { WasherMachineState.Complete, WasherMachineState.Standby, } @override - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: return ( self._appliance.get_machine_state() is WasherMachineState.RunningMainCycle ) + @override + def _get_seconds_remaining(self) -> int: + return self._appliance.get_time_remaining() -class DryerTimeSensor(WasherDryerTimeSensorBase): + +class DryerTimeSensor(WhirlpoolTimeSensorBase): """A timestamp class for Whirlpool dryers.""" _appliance: Dryer + def __init__(self, appliance: Dryer, description: SensorEntityDescription) -> None: + """Initialize the dryer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") + self.entity_description = description + @override - def _is_machine_state_finished(self) -> bool: - """Return true if the machine is in a finished state.""" + def _is_finished(self) -> bool: return self._appliance.get_machine_state() in { DryerMachineState.Complete, DryerMachineState.Standby, } @override - def _is_machine_state_running(self) -> bool: - """Return true if the machine is in a running state.""" + def _is_running(self) -> bool: return self._appliance.get_machine_state() is DryerMachineState.RunningMainCycle + @override + def _get_seconds_remaining(self) -> int: + return self._appliance.get_time_remaining() + class WhirlpoolOvenCavitySensor(WhirlpoolOvenEntity, SensorEntity): """A class for Whirlpool oven cavity sensors.""" diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 2a831caf8028..75a70e8e89b4 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -57,7 +57,68 @@ "name": "Upper oven stop" } }, + "light": { + "oven_light": { + "name": "Light" + }, + "oven_light_lower": { + "name": "Lower oven light" + }, + "oven_light_upper": { + "name": "Upper oven light" + } + }, + "number": { + "oven_target_temperature": { + "name": "Target temperature" + }, + "oven_target_temperature_lower": { + "name": "Lower oven target temperature" + }, + "oven_target_temperature_upper": { + "name": "Upper oven target temperature" + } + }, "select": { + "oven_cook_mode": { + "name": "Cook mode", + "state": { + "air_fry": "Air fry", + "bake": "Bake", + "broil": "Broil", + "convection_bake": "Convection bake", + "convection_broil": "Convection broil", + "convection_roast": "Convection roast", + "keep_warm": "Keep warm", + "standby": "[%key:common::state::standby%]" + } + }, + "oven_cook_mode_lower": { + "name": "Lower oven cook mode", + "state": { + "air_fry": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::air_fry%]", + "bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::bake%]", + "broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::broil%]", + "convection_bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_bake%]", + "convection_broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_broil%]", + "convection_roast": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_roast%]", + "keep_warm": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::keep_warm%]", + "standby": "[%key:common::state::standby%]" + } + }, + "oven_cook_mode_upper": { + "name": "Upper oven cook mode", + "state": { + "air_fry": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::air_fry%]", + "bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::bake%]", + "broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::broil%]", + "convection_bake": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_bake%]", + "convection_broil": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_broil%]", + "convection_roast": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::convection_roast%]", + "keep_warm": "[%key:component::whirlpool::entity::select::oven_cook_mode::state::keep_warm%]", + "standby": "[%key:common::state::standby%]" + } + }, "refrigerator_temperature_level": { "name": "Temperature level" } @@ -239,5 +300,23 @@ "request_failed": { "message": "Request failed" } + }, + "issues": { + "deprecated_oven_cook_mode": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nUpdate any dashboards, templates, automations or scripts to use the new select entity, then disable `{entity_id}` to have it removed.", + "title": "The Whirlpool oven cook mode sensor is deprecated" + }, + "deprecated_oven_cook_mode_scripts": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Cook mode** select entity, which can both read and change the oven cook mode.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new select entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::whirlpool::issues::deprecated_oven_cook_mode::title%]" + }, + "deprecated_oven_target_temperature": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nUpdate any dashboards, templates, automations or scripts to use the new number entity, then disable `{entity_id}` to have it removed.", + "title": "The Whirlpool oven target temperature sensor is deprecated" + }, + "deprecated_oven_target_temperature_scripts": { + "description": "The `{entity_id}` ({entity_name}) sensor is deprecated and has been replaced by the **Target temperature** number entity, which can both read and change the oven target temperature.\n\nIt is still used in the following automations or scripts:\n{items}\n\nUpdate them to use the new number entity, then disable `{entity_id}` to have it removed.", + "title": "[%key:component::whirlpool::issues::deprecated_oven_target_temperature::title%]" + } } } diff --git a/homeassistant/components/whirlpool/util.py b/homeassistant/components/whirlpool/util.py new file mode 100644 index 000000000000..a2dc9c6a7ebc --- /dev/null +++ b/homeassistant/components/whirlpool/util.py @@ -0,0 +1,101 @@ +"""Utility helpers for the Whirlpool integration.""" + +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) + +from .const import DOMAIN + +# Version in which deprecated entities will be removed. +DEPRECATED_REMOVAL_VERSION = "2026.12.0" + + +def deprecate_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + *, + platform_domain: str, + entity_unique_id: str, + issue_id: str, + translation_key: str, +) -> bool: + """Handle deprecation of an entity that has been replaced. + + Return True if the deprecated entity should still be set up, which is the + case while it exists in the entity registry. A repair issue informs the user + about the replacement and the removal date; when the entity is still used by + automations or scripts they are listed in the issue. The entity is removed + once the user disables it and nothing references it anymore. New + installations never create the entity. + """ + entity_id = entity_registry.async_get_entity_id( + platform_domain, DOMAIN, entity_unique_id + ) + if entity_id is None: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + entity_entry = entity_registry.async_get(entity_id) + if entity_entry is None: + async_delete_issue(hass, DOMAIN, issue_id) + return False + + items = _automations_and_scripts_using_entity(hass, entity_registry, entity_id) + + if entity_entry.disabled and not items: + entity_registry.async_remove(entity_id) + async_delete_issue(hass, DOMAIN, issue_id) + return False + + placeholders = { + "entity_id": entity_id, + "entity_name": entity_entry.name or entity_entry.original_name or entity_id, + } + if items: + translation_key = f"{translation_key}_scripts" + placeholders["items"] = "\n".join(items) + + async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version=DEPRECATED_REMOVAL_VERSION, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=placeholders, + ) + return True + + +def _automations_and_scripts_using_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entity_id: str, +) -> list[str]: + """Return markdown list items for automations and scripts using an entity.""" + automations = automations_with_entity(hass, entity_id) + scripts = scripts_with_entity(hass, entity_id) + if not automations and not scripts: + return [] + + items: list[str] = [] + for integration, used_entities in ( + ("automation", automations), + ("script", scripts), + ): + for used_entity_id in used_entities: + if entry := entity_registry.async_get(used_entity_id): + items.append( + f"- [{entry.original_name}](/config/{integration}/edit/{entry.unique_id})" + ) + else: + items.append(f"- `{used_entity_id}`") + + return items diff --git a/homeassistant/components/wiim/manifest.json b/homeassistant/components/wiim/manifest.json index a93652c0e978..83d724c308a4 100644 --- a/homeassistant/components/wiim/manifest.json +++ b/homeassistant/components/wiim/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["wiim.sdk", "async_upnp_client"], "quality_scale": "bronze", - "requirements": ["wiim==0.1.4"], + "requirements": ["wiim==0.1.5"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py index f7ce8dd9e952..cb392bc228e8 100644 --- a/homeassistant/components/wiim/media_player.py +++ b/homeassistant/components/wiim/media_player.py @@ -31,6 +31,10 @@ from homeassistant.components.media_player import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow @@ -65,9 +69,15 @@ SUPPORT_WIIM_BASE = ( | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.GROUPING ) +def _group_member_state_signal(member_udn: str) -> str: + """Return the dispatcher signal for a grouped member state refresh.""" + return f"wiim_group_member_state_{member_udn}" + + def media_player_exception_wrap[ _WiimMediaPlayerEntityT: WiimMediaPlayerEntity, **_P, @@ -208,6 +218,44 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): ) return target_device + @callback + def _async_handle_group_member_state_refresh(self) -> None: + """Trigger local follower cache refresh on leader update push.""" + group_snapshot = self._get_group_snapshot() + if group_snapshot.role != WiimGroupRole.FOLLOWER: + LOGGER.debug( + "Ignoring group member refresh for %s because role is %s", + self.entity_id, + group_snapshot.role, + ) + return + + LOGGER.debug( + "Follower %s received propagated group update from leader %s", + self.entity_id, + group_snapshot.leader_udn, + ) + self._update_ha_state_from_sdk_cache() + + @callback + def _async_propagate_group_state_update( + self, group_snapshot: WiimGroupSnapshot + ) -> None: + """Notify grouped followers to refresh from the leader's latest cache.""" + if group_snapshot.role != WiimGroupRole.LEADER: + return + + for member_udn in group_snapshot.member_udns: + if member_udn == self._device.udn: + continue + + LOGGER.debug( + "Leader %s propagating grouped state refresh to follower %s", + self.entity_id, + member_udn, + ) + async_dispatcher_send(self.hass, _group_member_state_signal(member_udn)) + @callback def _update_ha_state_from_sdk_cache( self, @@ -290,6 +338,7 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): if write_state: self.async_write_ha_state() + self._async_propagate_group_state_update(group_snapshot) @callback def _handle_sdk_general_device_update(self, device: WiimDevice) -> None: @@ -475,6 +524,13 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): """Run when entity is added to Home Assistant.""" await super().async_added_to_hass() self._wiim_data.entity_id_to_udn_map[self.entity_id] = self._device.udn + self.async_on_remove( + async_dispatcher_connect( + self.hass, + _group_member_state_signal(self._device.udn), + self._async_handle_group_member_state_refresh, + ) + ) LOGGER.debug( "Added %s (UDN: %s) to entity maps in hass.data", self.entity_id, @@ -806,3 +862,61 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity): media_content_id, ) raise BrowseError(f"Invalid browse path: {media_content_id}") + + @media_player_exception_wrap + @override + async def async_join_players(self, group_members: list[str]) -> None: + """Join group_members (entity_ids) to the group led by the current player.""" + target_device = self._get_command_target_device("join") + follower_udns_to_join: list[str] = [] + for member_entity_id in group_members: + if member_entity_id == self.entity_id: + LOGGER.debug("Skipping joining self to group: %s", member_entity_id) + continue + + follower_udn = self._wiim_data.entity_id_to_udn_map.get(member_entity_id) + if follower_udn is None: + LOGGER.warning( + "Unable to resolve group member entity_id %s to a UDN", + member_entity_id, + ) + continue + + if follower_udn == target_device.udn: + LOGGER.debug( + "Skipping joining command target to its own group: %s", + member_entity_id, + ) + continue + + follower_udns_to_join.append(follower_udn) + + if not follower_udns_to_join: + LOGGER.debug( + "Skipping join for %s because no follower UDNs were resolved from %s", + self.entity_id, + group_members, + ) + return + + LOGGER.debug( + "Player %s (UDN %s) joining follower UDNs: %s from entity_ids: %s", + self.entity_id, + target_device.udn, + follower_udns_to_join, + group_members, + ) + await self._wiim_data.controller.async_join_group( + target_device.udn, follower_udns_to_join + ) + + @media_player_exception_wrap + @override + async def async_unjoin_player(self) -> None: + """Remove this player from any group it is currently in.""" + LOGGER.debug( + "Player %s (UDN %s) attempting to unjoin from group", + self.entity_id, + self._device.udn, + ) + await self._wiim_data.controller.async_ungroup_device(self._device.udn) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 520c89e7f399..73b56a8dae35 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -850,17 +850,22 @@ async def async_setup_entry( if new_devices: device_registry = dr.async_get(hass) for device_id in new_devices: - if device := device_registry.async_get_device({(DOMAIN, device_id)}): - if any( - ( - config_entry := hass.config_entries.async_get_entry( - config_entry_id - ) + # The same sub-device can be reported by several config entries, each + # owning its own device registry entry. Its sensors share a unique id + # across config entries, so only create them if no other loaded config + # entry already provides them. + if any( + ( + config_entry := hass.config_entries.async_get_entry( + device.config_entry_id ) - and config_entry.state is ConfigEntryState.LOADED - for config_entry_id in device.config_entries - ): - continue + ) + and config_entry.state is ConfigEntryState.LOADED + for device in device_registry.devices.get_entries( + identifiers={(DOMAIN, device_id)} + ) + ): + continue async_add_entities( WithingsDeviceSensor(device_coordinator, description, device_id) for description in DEVICE_SENSORS @@ -870,11 +875,17 @@ async def async_setup_entry( if old_devices: device_registry = dr.async_get(hass) for device_id in old_devices: - if device := device_registry.async_get_device({(DOMAIN, device_id)}): - device_registry.async_update_device( - device.id, remove_config_entry_id=entry.entry_id - ) - current_devices.remove(device_id) + # Several config entries can share this identifier, each owning its own + # device registry entry, so only remove this entry's own device. + for device in device_registry.devices.get_entries( + identifiers={(DOMAIN, device_id)} + ): + if device.config_entry_id == entry.entry_id: + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + break + current_devices.remove(device_id) device_coordinator.async_add_listener(_async_device_listener) diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py index 2553cf2a9ca1..71c2310a33f5 100644 --- a/homeassistant/components/wmspro/__init__.py +++ b/homeassistant/components/wmspro/__init__.py @@ -10,12 +10,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import STORAGE_DIR -from homeassistant.helpers.typing import UNDEFINED +from homeassistant.helpers.typing import UNDEFINED, ConfigType from .const import DOMAIN, MANUFACTURER +from .services import async_setup_services PLATFORMS: list[Platform] = [ Platform.BUTTON, @@ -28,6 +29,8 @@ PLATFORMS: list[Platform] = [ type WebControlProConfigEntry = ConfigEntry[WebControlPro] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + def _build_storage_config_dir( hass: HomeAssistant, entry: WebControlProConfigEntry @@ -36,6 +39,12 @@ def _build_storage_config_dir( return Path(hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.entry_id}")) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the component.""" + async_setup_services(hass) + return True + + async def async_setup_entry( hass: HomeAssistant, entry: WebControlProConfigEntry ) -> bool: diff --git a/homeassistant/components/wmspro/const.py b/homeassistant/components/wmspro/const.py index d92534d9e46f..94d15992ab1d 100644 --- a/homeassistant/components/wmspro/const.py +++ b/homeassistant/components/wmspro/const.py @@ -7,3 +7,4 @@ ATTRIBUTION = "Data provided by WMS WebControl pro API" MANUFACTURER = "WAREMA Renkhoff SE" BRIGHTNESS_SCALE = (1, 100) +SERVICE_SET_COVER_POSITION_AND_TILT = "set_cover_position_and_tilt" diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 8b63ca78d030..92d068c8f44b 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -206,3 +206,15 @@ class WebControlProSlatRotate(WebControlProSlat): # with the close position the slat is perpendicular to the ground. # This position will block the light best. await action(rotation=action.maxValue) + + async def async_set_cover_position_and_tilt(self, **kwargs: Any) -> None: + """Handle the service action call to set cover position and tilt.""" + action_drive = self._dest.action(self._drive_action_desc) + action_list = action_drive.prep(percentage=100 - kwargs[ATTR_POSITION]) + action_tilt = self._dest.action(self._tilt_action_desc) + rotation = percentage_to_ranged_value( + (action_tilt.minValue, action_tilt.maxValue), + 100 - kwargs[ATTR_TILT_POSITION], + ) + action_list += action_tilt.prep(rotation=rotation) + await action_list() diff --git a/homeassistant/components/wmspro/icons.json b/homeassistant/components/wmspro/icons.json index d7601026f573..ca5d0810f9ee 100644 --- a/homeassistant/components/wmspro/icons.json +++ b/homeassistant/components/wmspro/icons.json @@ -16,5 +16,10 @@ "default": "mdi:rotate-left" } } + }, + "services": { + "set_cover_position_and_tilt": { + "service": "mdi:blinds-horizontal" + } } } diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index bcdc71e2b336..dbd7b0cae917 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.4.0"] + "requirements": ["pywmspro==0.4.2"] } diff --git a/homeassistant/components/wmspro/services.py b/homeassistant/components/wmspro/services.py new file mode 100644 index 000000000000..389ce0de4304 --- /dev/null +++ b/homeassistant/components/wmspro/services.py @@ -0,0 +1,37 @@ +"""Services for WMS WebControl pro.""" + +import voluptuous as vol + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import service + +from .const import DOMAIN, SERVICE_SET_COVER_POSITION_AND_TILT + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for WMS WebControl pro.""" + service.async_register_platform_entity_service( + hass, + service_domain=DOMAIN, + service_name=SERVICE_SET_COVER_POSITION_AND_TILT, + entity_domain=COVER_DOMAIN, + func="async_set_cover_position_and_tilt", + required_features=[ + CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_TILT_POSITION + ], + schema={ + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Required(ATTR_TILT_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + }, + ) diff --git a/homeassistant/components/wmspro/services.yaml b/homeassistant/components/wmspro/services.yaml new file mode 100644 index 000000000000..fd444e5c7697 --- /dev/null +++ b/homeassistant/components/wmspro/services.yaml @@ -0,0 +1,25 @@ +set_cover_position_and_tilt: + target: + entity: + integration: wmspro + domain: cover + supported_features: + - - cover.CoverEntityFeature.SET_POSITION + - cover.CoverEntityFeature.SET_TILT_POSITION + fields: + position: + required: true + selector: + number: + min: 0 + max: 100 + mode: slider + unit_of_measurement: "%" + tilt_position: + required: true + selector: + number: + min: 0 + max: 100 + mode: slider + unit_of_measurement: "%" diff --git a/homeassistant/components/wmspro/strings.json b/homeassistant/components/wmspro/strings.json index 9d0b2c04a4d4..988d20e1efca 100644 --- a/homeassistant/components/wmspro/strings.json +++ b/homeassistant/components/wmspro/strings.json @@ -25,7 +25,7 @@ "entity": { "button": { "rotation-reset": { - "name": "Reset Rotation" + "name": "Reset rotation" } }, "cover": { @@ -38,14 +38,30 @@ "name": "Rotation" }, "rotation-max": { - "name": "Maximum Rotation" + "name": "Maximum rotation" }, "rotation-min": { - "name": "Minimum Rotation" + "name": "Minimum rotation" }, "rotation-raw": { - "name": "Raw Rotation" + "name": "Raw rotation" } } + }, + "services": { + "set_cover_position_and_tilt": { + "description": "Moves the cover and tilt to the target position simultaneously, preventing cancellation of individual movements.", + "fields": { + "position": { + "description": "Target vertical position. 0 means closed, 100 means fully open.", + "name": "Position" + }, + "tilt_position": { + "description": "Target tilt position.", + "name": "Tilt position" + } + }, + "name": "Set cover position and tilt" + } } } diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index d86047e323dc..1a94fc700194 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .const import DOMAIN, MANUFACTURER from .coordinator import WolflinkConfigEntry, WolfLinkCoordinator @@ -171,22 +172,21 @@ def _reattach_device_to_hub( if device is None: return - device_disabled_by = device.disabled_by - if device_disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY: + # The device registry will set the disabled_by flag to None when moving a + # device disabled by CONFIG_ENTRY to an enabled config entry, but we want + # to set it to USER instead. + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType = UNDEFINED + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and hub_entry.disabled_by is None + ): device_disabled_by = dr.DeviceEntryDisabler.USER - if source_entry.entry_id != hub_entry.entry_id: - device_registry.async_update_device( - device.id, - disabled_by=device_disabled_by, - add_config_entry_id=hub_entry.entry_id, - remove_config_entry_id=source_entry.entry_id, - ) - else: - device_registry.async_update_device( - device.id, - disabled_by=device_disabled_by, - ) + device_registry.async_update_device( + device.id, + disabled_by=device_disabled_by, + new_config_entry_id=hub_entry.entry_id, + ) for entity_entry in er.async_entries_for_device( entity_registry, device.id, include_disabled_entities=True diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index b4df67d61b7c..1d2009138865 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.99"] + "requirements": ["holidays==0.100"] } diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 2176f89e2fe5..a08772e057e1 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -110,6 +110,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._client: AsyncTcpClient | None = None self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) self._is_pipeline_running = False + self._pipeline_error = False self._pipeline_ended_event = asyncio.Event() self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._pipeline_id: str | None = None @@ -316,7 +317,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.ERROR: - # Pipeline error + # Pipeline error. Prevents an "always on" satellite from restarting + # a failing pipeline in a tight loop (e.g. on a config error). + self._pipeline_error = True if event.data: self.config_entry.async_create_background_task( self.hass, @@ -623,12 +626,24 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): # Clear last wake word detection wake_word_phrase = None - if (run_pipeline is not None) and run_pipeline.restart_on_end: + if ( + (run_pipeline is not None) + and run_pipeline.restart_on_end + and not self._pipeline_error + ): # Automatically restart pipeline. # Used with "always on" streaming satellites. self._run_pipeline_once(run_pipeline) continue + if self._pipeline_error: + # Don't restart a failing pipeline in a tight loop; wait + # for the satellite to request a new run. + _LOGGER.debug( + "Not restarting pipeline after error; " + "waiting for next satellite request" + ) + if client_event_task not in done: continue @@ -736,6 +751,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._audio_queue = asyncio.Queue() self._is_pipeline_running = True + self._pipeline_error = False self._pipeline_ended_event.clear() self.config_entry.async_create_background_task( self.hass, diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 368157a22c2b..d9b0766d70eb 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -127,9 +127,7 @@ class XboxConsolesCoordinator(XboxBaseCoordinator[dict[str, SmartglassConsole]]) and not set(device.identifiers) & identifiers ): _LOGGER.debug("Removing stale device %s", device.name) - device_reg.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_reg.async_remove_device(device.id) return {console.id: console for console in consoles.result} diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 67a2d1b77c14..690121850773 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -276,6 +276,16 @@ SERVICE_SET_EXTRA_FEATURES = "fan_set_extra_features" SERVICE_SET_DRY = "set_dry" SERVICE_SET_MOTOR_SPEED = "fan_set_motor_speed" +# Fan/Humidifier data +FAN_DATA_KEY = "fan.xiaomi_miio" + +# Light data +LIGHT_DATA_KEY = "light.xiaomi_miio" +ATTR_SCENE = "scene" + +# Switch data +SWITCH_DATA_KEY = "switch.xiaomi_miio" + # Light Services SERVICE_SET_SCENE = "light_set_scene" SERVICE_SET_DELAYED_TURN_OFF = "light_set_delayed_turn_off" diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index d08f52fdf56d..e15d13fa81e7 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,7 +1,6 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" from abc import abstractmethod -import asyncio import logging import math from typing import Any, override @@ -28,12 +27,10 @@ from miio.integrations.fan.dmaker.fan_miot import FanStatusMiot from miio.integrations.fan.zhimi.zhimi_miot import ( OperationModeFanZA5 as FanZA5OperationMode, ) -import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_DEVICE, CONF_MODEL +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.percentage import ( @@ -43,7 +40,7 @@ from homeassistant.util.percentage import ( from .const import ( CONF_FLOW_TYPE, - DOMAIN, + FAN_DATA_KEY as DATA_KEY, FEATURE_FLAGS_AIRFRESH, FEATURE_FLAGS_AIRFRESH_A1, FEATURE_FLAGS_AIRFRESH_T2017, @@ -89,16 +86,12 @@ from .const import ( MODELS_FAN_MIIO, MODELS_FAN_MIOT, MODELS_PURIFIER_MIOT, - SERVICE_RESET_FILTER, - SERVICE_SET_EXTRA_FEATURES, ) from .entity import XiaomiCoordinatedMiioEntity -from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) -DATA_KEY = "fan.xiaomi_miio" - ATTR_MODE_NATURE = "nature" ATTR_MODE_NORMAL = "normal" @@ -108,7 +101,6 @@ ATTR_FAN_LEVEL = "fan_level" ATTR_SLEEP_TIME = "sleep_time" ATTR_SLEEP_LEARN_COUNT = "sleep_mode_learn_count" ATTR_EXTRA_FEATURES = "extra_features" -ATTR_FEATURES = "features" ATTR_TURBO_MODE_SUPPORTED = "turbo_mode_supported" ATTR_SLEEP_MODE = "sleep_mode" ATTR_USE_TIME = "use_time" @@ -181,20 +173,6 @@ PRESET_MODES_AIRPURIFIER_V3 = [ PRESET_MODES_AIRFRESH = ["Auto", "Interval"] PRESET_MODES_AIRFRESH_A1 = ["Auto", "Sleep", "Favorite"] -AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_FEATURES): cv.positive_int} -) - -SERVICE_TO_METHOD = { - SERVICE_RESET_FILTER: ServiceMethodDetails(method="async_reset_filter"), - SERVICE_SET_EXTRA_FEATURES: ServiceMethodDetails( - method="async_set_extra_features", - schema=SERVICE_SCHEMA_EXTRA_FEATURES, - ), -} - FAN_DIRECTIONS_MAP = { "forward": "right", "reverse": "left", @@ -259,40 +237,6 @@ async def async_setup_entry( entities.append(entity) - async def async_service_handler(service: ServiceCall) -> None: - """Map services to methods on XiaomiAirPurifier.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID - } - if entity_ids := service.data.get(ATTR_ENTITY_ID): - filtered_entities = [ - entity - for entity in hass.data[DATA_KEY].values() - if entity.entity_id in entity_ids - ] - else: - filtered_entities = hass.data[DATA_KEY].values() - - update_tasks = [] - - for entity in filtered_entities: - entity_method = getattr(entity, method.method, None) - if not entity_method: - continue - await entity_method(**params) - update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) - - if update_tasks: - await asyncio.wait(update_tasks) - - for air_purifier_service, method in SERVICE_TO_METHOD.items(): - schema = method.schema or AIRPURIFIER_SERVICE_SCHEMA - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, air_purifier_service, async_service_handler, schema=schema - ) - async_add_entities(entities) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index b3d73141209e..35cf33778e50 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -1,6 +1,5 @@ """Support for Xiaomi Philips Lights.""" -import asyncio import datetime from datetime import timedelta from functools import partial @@ -23,7 +22,6 @@ from miio.gateway.gateway import ( GATEWAY_MODEL_AC_V3, GatewayException, ) -import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -32,44 +30,30 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_DEVICE, - CONF_HOST, - CONF_MODEL, - CONF_TOKEN, -) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util, dt as dt_util from .const import ( + ATTR_SCENE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, + LIGHT_DATA_KEY as DATA_KEY, MODELS_LIGHT_BULB, MODELS_LIGHT_CEILING, MODELS_LIGHT_EYECARE, MODELS_LIGHT_MONO, MODELS_LIGHT_MOON, - SERVICE_EYECARE_MODE_OFF, - SERVICE_EYECARE_MODE_ON, - SERVICE_NIGHT_LIGHT_MODE_OFF, - SERVICE_NIGHT_LIGHT_MODE_ON, - SERVICE_REMINDER_OFF, - SERVICE_REMINDER_ON, - SERVICE_SET_DELAYED_TURN_OFF, - SERVICE_SET_SCENE, ) from .entity import XiaomiGatewayDevice, XiaomiMiioEntity -from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Philips Light" -DATA_KEY = "light.xiaomi_miio" # The light does not accept cct values < 1 CCT_MIN = 1 @@ -79,9 +63,7 @@ DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS = 4 DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES = 1 SUCCESS = ["ok"] -ATTR_SCENE = "scene" ATTR_DELAYED_TURN_OFF = "delayed_turn_off" -ATTR_TIME_PERIOD = "time_period" ATTR_NIGHT_LIGHT_MODE = "night_light_mode" ATTR_AUTOMATIC_COLOR_TEMPERATURE = "automatic_color_temperature" ATTR_REMINDER = "reminder" @@ -94,37 +76,6 @@ ATTR_TOTAL_ASSISTANT_SLEEP_TIME = "total_assistant_sleep_time" ATTR_BAND_SLEEP = "band_sleep" ATTR_BAND = "band" -XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6))} -) - -SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period} -) - -SERVICE_TO_METHOD = { - SERVICE_SET_DELAYED_TURN_OFF: ServiceMethodDetails( - method="async_set_delayed_turn_off", - schema=SERVICE_SCHEMA_SET_DELAYED_TURN_OFF, - ), - SERVICE_SET_SCENE: ServiceMethodDetails( - method="async_set_scene", - schema=SERVICE_SCHEMA_SET_SCENE, - ), - SERVICE_REMINDER_ON: ServiceMethodDetails(method="async_reminder_on"), - SERVICE_REMINDER_OFF: ServiceMethodDetails(method="async_reminder_off"), - SERVICE_NIGHT_LIGHT_MODE_ON: ServiceMethodDetails( - method="async_night_light_mode_on" - ), - SERVICE_NIGHT_LIGHT_MODE_OFF: ServiceMethodDetails( - method="async_night_light_mode_off" - ), - SERVICE_EYECARE_MODE_ON: ServiceMethodDetails(method="async_eyecare_mode_on"), - SERVICE_EYECARE_MODE_OFF: ServiceMethodDetails(method="async_eyecare_mode_off"), -} - async def async_setup_entry( hass: HomeAssistant, @@ -212,42 +163,6 @@ async def async_setup_entry( ) return - async def async_service_handler(service: ServiceCall) -> None: - """Map services to methods on Xiaomi Philips Lights.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value - for key, value in service.data.items() - if key != ATTR_ENTITY_ID - } - if entity_ids := service.data.get(ATTR_ENTITY_ID): - target_devices = [ - dev - for dev in hass.data[DATA_KEY].values() - if dev.entity_id in entity_ids - ] - else: - target_devices = hass.data[DATA_KEY].values() - - update_tasks = [] - for target_device in target_devices: - if not hasattr(target_device, method.method): - continue - await getattr(target_device, method.method)(**params) - update_tasks.append( - asyncio.create_task(target_device.async_update_ha_state(True)) - ) - - if update_tasks: - await asyncio.wait(update_tasks) - - for xiaomi_miio_service, method in SERVICE_TO_METHOD.items(): - schema = method.schema or XIAOMI_MIIO_SERVICE_SCHEMA - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema - ) - async_add_entities(entities, update_before_add=True) diff --git a/homeassistant/components/xiaomi_miio/services.py b/homeassistant/components/xiaomi_miio/services.py index 97397f9feecb..7ae0140202e8 100644 --- a/homeassistant/components/xiaomi_miio/services.py +++ b/homeassistant/components/xiaomi_miio/services.py @@ -1,12 +1,39 @@ """Xiaomi services.""" +import asyncio +import logging + import voluptuous as vol from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, service -from .const import DOMAIN +from .const import ( + ATTR_SCENE, + DOMAIN, + FAN_DATA_KEY, + LIGHT_DATA_KEY, + SERVICE_EYECARE_MODE_OFF, + SERVICE_EYECARE_MODE_ON, + SERVICE_NIGHT_LIGHT_MODE_OFF, + SERVICE_NIGHT_LIGHT_MODE_ON, + SERVICE_REMINDER_OFF, + SERVICE_REMINDER_ON, + SERVICE_RESET_FILTER, + SERVICE_SET_DELAYED_TURN_OFF, + SERVICE_SET_EXTRA_FEATURES, + SERVICE_SET_POWER_MODE, + SERVICE_SET_POWER_PRICE, + SERVICE_SET_SCENE, + SERVICE_SET_WIFI_LED_OFF, + SERVICE_SET_WIFI_LED_ON, + SWITCH_DATA_KEY, +) +from .typing import ServiceMethodDetails + +_LOGGER = logging.getLogger(__name__) ATTR_RC_DURATION = "duration" ATTR_RC_ROTATION = "rotation" @@ -23,11 +50,81 @@ SERVICE_CLEAN_SEGMENT = "vacuum_clean_segment" SERVICE_CLEAN_ZONE = "vacuum_clean_zone" SERVICE_GOTO = "vacuum_goto" +# Light Services +ATTR_TIME_PERIOD = "time_period" +XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) +SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6))} +) +SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period} +) +LIGHT_SERVICE_TO_METHOD = { + SERVICE_SET_DELAYED_TURN_OFF: ServiceMethodDetails( + method="async_set_delayed_turn_off", + schema=SERVICE_SCHEMA_SET_DELAYED_TURN_OFF, + ), + SERVICE_SET_SCENE: ServiceMethodDetails( + method="async_set_scene", + schema=SERVICE_SCHEMA_SET_SCENE, + ), + SERVICE_REMINDER_ON: ServiceMethodDetails(method="async_reminder_on"), + SERVICE_REMINDER_OFF: ServiceMethodDetails(method="async_reminder_off"), + SERVICE_NIGHT_LIGHT_MODE_ON: ServiceMethodDetails( + method="async_night_light_mode_on" + ), + SERVICE_NIGHT_LIGHT_MODE_OFF: ServiceMethodDetails( + method="async_night_light_mode_off" + ), + SERVICE_EYECARE_MODE_ON: ServiceMethodDetails(method="async_eyecare_mode_on"), + SERVICE_EYECARE_MODE_OFF: ServiceMethodDetails(method="async_eyecare_mode_off"), +} + +# Switch Services +ATTR_PRICE = "price" +SWITCH_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) +SWITCH_SERVICE_SCHEMA_POWER_MODE = SWITCH_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_MODE): vol.All(vol.In(["green", "normal"]))} +) +SWITCH_SERVICE_SCHEMA_POWER_PRICE = SWITCH_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_PRICE): cv.positive_float} +) +SWITCH_SERVICE_TO_METHOD = { + SERVICE_SET_WIFI_LED_ON: ServiceMethodDetails(method="async_set_wifi_led_on"), + SERVICE_SET_WIFI_LED_OFF: ServiceMethodDetails(method="async_set_wifi_led_off"), + SERVICE_SET_POWER_MODE: ServiceMethodDetails( + method="async_set_power_mode", + schema=SWITCH_SERVICE_SCHEMA_POWER_MODE, + ), + SERVICE_SET_POWER_PRICE: ServiceMethodDetails( + method="async_set_power_price", + schema=SWITCH_SERVICE_SCHEMA_POWER_PRICE, + ), +} + +# Fan Services +ATTR_FEATURES = "features" +FAN_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) +FAN_SERVICE_SCHEMA_EXTRA_FEATURES = FAN_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_FEATURES): cv.positive_int} +) +FAN_SERVICE_TO_METHOD = { + SERVICE_RESET_FILTER: ServiceMethodDetails(method="async_reset_filter"), + SERVICE_SET_EXTRA_FEATURES: ServiceMethodDetails( + method="async_set_extra_features", + schema=FAN_SERVICE_SCHEMA_EXTRA_FEATURES, + ), +} + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up services.""" + _async_setup_fan_services(hass) + _async_setup_light_services(hass) + _async_setup_switch_services(hass) + # Vacuum Services service.async_register_platform_entity_service( hass, @@ -126,3 +223,115 @@ def async_setup_services(hass: HomeAssistant) -> None: schema={vol.Required("segments"): vol.Any(vol.Coerce(int), [vol.Coerce(int)])}, func="async_clean_segment", ) + + +def _async_setup_light_services(hass: HomeAssistant) -> None: + """Set up Xiaomi Miio light services.""" + hass.data.setdefault(LIGHT_DATA_KEY, {}) + + async def async_service_handler(call: ServiceCall) -> None: + """Map services to methods on Xiaomi Philips Lights.""" + method = LIGHT_SERVICE_TO_METHOD[call.service] + params = { + key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID + } + if entity_ids := call.data.get(ATTR_ENTITY_ID): + target_devices = [ + dev + for dev in hass.data[LIGHT_DATA_KEY].values() + if dev.entity_id in entity_ids + ] + else: + target_devices = hass.data[LIGHT_DATA_KEY].values() + + update_tasks = [] + for target_device in target_devices: + if not hasattr(target_device, method.method): + continue + await getattr(target_device, method.method)(**params) + update_tasks.append( + asyncio.create_task(target_device.async_update_ha_state(True)) + ) + + if update_tasks: + await asyncio.wait(update_tasks) + + for xiaomi_miio_service, method in LIGHT_SERVICE_TO_METHOD.items(): + schema = method.schema or XIAOMI_MIIO_SERVICE_SCHEMA + hass.services.async_register( + DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema + ) + + +def _async_setup_switch_services(hass: HomeAssistant) -> None: + """Set up Xiaomi Miio switch services.""" + hass.data.setdefault(SWITCH_DATA_KEY, {}) + + async def async_service_handler(call: ServiceCall) -> None: + """Map services to methods on XiaomiPlugGenericSwitch.""" + method = SWITCH_SERVICE_TO_METHOD[call.service] + params = { + key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID + } + if entity_ids := call.data.get(ATTR_ENTITY_ID): + devices = [ + device + for device in hass.data[SWITCH_DATA_KEY].values() + if device.entity_id in entity_ids + ] + else: + devices = hass.data[SWITCH_DATA_KEY].values() + + update_tasks = [] + for device in devices: + if not hasattr(device, method.method): + continue + await getattr(device, method.method)(**params) + update_tasks.append(asyncio.create_task(device.async_update_ha_state(True))) + + if update_tasks: + await asyncio.wait(update_tasks) + + for plug_service, method in SWITCH_SERVICE_TO_METHOD.items(): + schema = method.schema or SWITCH_SERVICE_SCHEMA + hass.services.async_register( + DOMAIN, plug_service, async_service_handler, schema=schema + ) + + +def _async_setup_fan_services(hass: HomeAssistant) -> None: + """Set up Xiaomi Miio fan services.""" + hass.data.setdefault(FAN_DATA_KEY, {}) + + async def async_service_handler(call: ServiceCall) -> None: + """Map services to methods on XiaomiAirPurifier.""" + method = FAN_SERVICE_TO_METHOD[call.service] + params = { + key: value for key, value in call.data.items() if key != ATTR_ENTITY_ID + } + if entity_ids := call.data.get(ATTR_ENTITY_ID): + filtered_entities = [ + entity + for entity in hass.data[FAN_DATA_KEY].values() + if entity.entity_id in entity_ids + ] + else: + filtered_entities = hass.data[FAN_DATA_KEY].values() + + update_tasks = [] + + for entity in filtered_entities: + entity_method = getattr(entity, method.method, None) + if not entity_method: + continue + await entity_method(**params) + update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) + + if update_tasks: + await asyncio.wait(update_tasks) + + for air_purifier_service, method in FAN_SERVICE_TO_METHOD.items(): + schema = method.schema or FAN_SERVICE_SCHEMA + hass.services.async_register( + DOMAIN, air_purifier_service, async_service_handler, schema=schema + ) diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index f4fc2babc646..f379add73ba0 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -1,6 +1,5 @@ """Support for Xiaomi Smart WiFi Socket and Smart Power Strip.""" -import asyncio from dataclasses import dataclass from functools import partial import logging @@ -15,7 +14,6 @@ from miio import ( ) from miio.gateway.devices.switch import Switch from miio.powerstrip import PowerMode -import voluptuous as vol from homeassistant.components.switch import ( SwitchDeviceClass, @@ -23,8 +21,6 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_MODE, ATTR_MODEL, ATTR_TEMPERATURE, CONF_DEVICE, @@ -33,15 +29,13 @@ from homeassistant.const import ( CONF_TOKEN, EntityCategory, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_FLOW_TYPE, CONF_GATEWAY, - DOMAIN, FEATURE_FLAGS_AIRFRESH, FEATURE_FLAGS_AIRFRESH_A1, FEATURE_FLAGS_AIRFRESH_T2017, @@ -113,20 +107,16 @@ from .const import ( MODELS_HUMIDIFIER_MJJSQ, MODELS_PURIFIER_MIIO, MODELS_PURIFIER_MIOT, - SERVICE_SET_POWER_MODE, - SERVICE_SET_POWER_PRICE, - SERVICE_SET_WIFI_LED_OFF, - SERVICE_SET_WIFI_LED_ON, SUCCESS, + SWITCH_DATA_KEY as DATA_KEY, ) from .coordinator import GatewayDeviceCoordinator from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity -from .typing import ServiceMethodDetails, XiaomiMiioConfigEntry +from .typing import XiaomiMiioConfigEntry _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Xiaomi Miio Switch" -DATA_KEY = "switch.xiaomi_miio" MODEL_POWER_STRIP_V2 = "zimi.powerstrip.v2" MODEL_PLUG_V3 = "chuangmi.plug.v3" @@ -153,7 +143,6 @@ ATTR_LOAD_POWER = "load_power" ATTR_POWER = "power" ATTR_POWER_MODE = "power_mode" ATTR_POWER_PRICE = "power_price" -ATTR_PRICE = "price" ATTR_PTC = "ptc" ATTR_WIFI_LED = "wifi_led" @@ -171,29 +160,6 @@ FEATURE_FLAGS_POWER_STRIP_V2 = FEATURE_SET_WIFI_LED | FEATURE_SET_POWER_PRICE FEATURE_FLAGS_PLUG_V3 = FEATURE_SET_WIFI_LED -SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -SERVICE_SCHEMA_POWER_MODE = SERVICE_SCHEMA.extend( - {vol.Required(ATTR_MODE): vol.All(vol.In(["green", "normal"]))} -) - -SERVICE_SCHEMA_POWER_PRICE = SERVICE_SCHEMA.extend( - {vol.Required(ATTR_PRICE): cv.positive_float} -) - -SERVICE_TO_METHOD = { - SERVICE_SET_WIFI_LED_ON: ServiceMethodDetails(method="async_set_wifi_led_on"), - SERVICE_SET_WIFI_LED_OFF: ServiceMethodDetails(method="async_set_wifi_led_off"), - SERVICE_SET_POWER_MODE: ServiceMethodDetails( - method="async_set_power_mode", - schema=SERVICE_SCHEMA_POWER_MODE, - ), - SERVICE_SET_POWER_PRICE: ServiceMethodDetails( - method="async_set_power_price", - schema=SERVICE_SCHEMA_POWER_PRICE, - ), -} - MODEL_TO_FEATURES_MAP = { MODEL_AIRFRESH_A1: FEATURE_FLAGS_AIRFRESH_A1, MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH, @@ -486,42 +452,6 @@ async def async_setup_other_entry( model, ) - async def async_service_handler(service: ServiceCall) -> None: - """Map services to methods on XiaomiPlugGenericSwitch.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value - for key, value in service.data.items() - if key != ATTR_ENTITY_ID - } - if entity_ids := service.data.get(ATTR_ENTITY_ID): - devices = [ - device - for device in hass.data[DATA_KEY].values() - if device.entity_id in entity_ids - ] - else: - devices = hass.data[DATA_KEY].values() - - update_tasks = [] - for device in devices: - if not hasattr(device, method.method): - continue - await getattr(device, method.method)(**params) - update_tasks.append( - asyncio.create_task(device.async_update_ha_state(True)) - ) - - if update_tasks: - await asyncio.wait(update_tasks) - - for plug_service, method in SERVICE_TO_METHOD.items(): - schema = method.schema or SERVICE_SCHEMA - # pylint: disable-next=home-assistant-service-registered-in-setup-entry - hass.services.async_register( - DOMAIN, plug_service, async_service_handler, schema=schema - ) - async_add_entities(entities) diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 4146a59cfb31..87d428b9caef 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -92,7 +92,6 @@ class MiroboVacuum( | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.START ) @@ -114,12 +113,6 @@ class MiroboVacuum( return super().activity - @property - @override - def battery_level(self) -> int: - """Return the battery level of the vacuum cleaner.""" - return self.coordinator.data.status.battery - @property @override def fan_speed(self) -> str: diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index fbdbd69285de..8ef2f179b5cf 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -8,7 +8,11 @@ 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 +from homeassistant.components.lock import ( + LockEntity, + LockEntityFeature, + LockEntityStateAttribute, +) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -140,5 +144,7 @@ class YaleLock(YaleEntity, RestoreEntity, LockEntity): if not (last_state := await self.async_get_last_state()): return - if ATTR_CHANGED_BY in last_state.attributes: - self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY] + if LockEntityStateAttribute.CHANGED_BY in last_state.attributes: + self._attr_changed_by = last_state.attributes[ + LockEntityStateAttribute.CHANGED_BY + ] diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py index 96daa42561bb..b6b7ffcbef0d 100644 --- a/homeassistant/components/yardian/__init__.py +++ b/homeassistant/components/yardian/__init__.py @@ -10,6 +10,7 @@ from .coordinator import YardianConfigEntry, YardianUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/yardian/button.py b/homeassistant/components/yardian/button.py new file mode 100644 index 000000000000..379fa2c17df2 --- /dev/null +++ b/homeassistant/components/yardian/button.py @@ -0,0 +1,44 @@ +"""Support for Yardian buttons.""" + +import asyncio +from typing import override + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import BUTTON_REFRESH_DELAY +from .coordinator import YardianConfigEntry, YardianUpdateCoordinator +from .entity import YardianEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: YardianConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Yardian button platform.""" + coordinator = entry.runtime_data + + async_add_entities([YardianStopButton(coordinator)]) + + +class YardianStopButton(YardianEntity, ButtonEntity): + """Representation of a Yardian Stop All Irrigation button.""" + + _attr_translation_key = "stop_irrigation" + + def __init__(self, coordinator: YardianUpdateCoordinator) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self.client = coordinator.controller + + self._attr_unique_id = f"{coordinator.yid}_stop_all" + + @override + async def async_press(self) -> None: + """Handle the button press.""" + await self.client.stop_irrigation() + + await asyncio.sleep(BUTTON_REFRESH_DELAY) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/yardian/const.py b/homeassistant/components/yardian/const.py index 4b77d8d8b79b..fcbedaed7ee9 100644 --- a/homeassistant/components/yardian/const.py +++ b/homeassistant/components/yardian/const.py @@ -6,3 +6,4 @@ PRODUCT_NAME = "Yardian Smart Sprinkler Controller" DEFAULT_WATERING_DURATION = 6 SWITCH_REFRESH_DELAY = 2 +BUTTON_REFRESH_DELAY = 3 diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index 50db7dd91cf6..b9687238016b 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -35,6 +35,11 @@ "name": "Enabled" } }, + "button": { + "stop_irrigation": { + "name": "Stop irrigation" + } + }, "sensor": { "active_zone_count": { "name": "Active zones", diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index 853d455844c8..760fc99246c6 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -79,6 +79,6 @@ class YardianSwitch(YardianZoneEntity, SwitchEntity): @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.coordinator.controller.stop_irrigation() + await self.coordinator.controller.stop_zone(self._zone_id) await asyncio.sleep(SWITCH_REFRESH_DELAY) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 26c776975cd8..26bc44411bc3 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.2"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.47.0"], "zeroconf": [ { "name": "yeelink-*", diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index a1917c847870..c2404ea1419c 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -169,9 +169,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: YoLinkConfigEntry) -> bo identifier[0] == DOMAIN and device_coordinators.get(identifier[1]) is None ): - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(device_entry.id) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 4c0d4e6464b5..2842714454dd 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -62,6 +62,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( device.device_type == ATTR_DEVICE_WATER_METER_CONTROLLER and not device.device_model_name.startswith(DEV_MODEL_WATER_METER_YS5007) ), + should_update_entity=lambda value: value is not None, ), YoLinkValveEntityDescription( key="valve_1_state", @@ -71,6 +72,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( exists_fn=lambda device: ( device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER ), + should_update_entity=lambda value: value is not None, channel_index=0, ), YoLinkValveEntityDescription( @@ -81,6 +83,7 @@ DEVICE_TYPES: tuple[YoLinkValveEntityDescription, ...] = ( exists_fn=lambda device: ( device.device_type == ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER ), + should_update_entity=lambda value: value is not None, channel_index=1, ), YoLinkValveEntityDescription( diff --git a/homeassistant/components/yoto/coordinator.py b/homeassistant/components/yoto/coordinator.py index 026b00eb11ed..b3d94b1bb4af 100644 --- a/homeassistant/components/yoto/coordinator.py +++ b/homeassistant/components/yoto/coordinator.py @@ -161,9 +161,7 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]): (ident[1] for ident in device.identifiers if ident[0] == DOMAIN), None ) if player_id is not None and player_id not in self.client.players: - device_registry.async_update_device( - device.id, remove_config_entry_id=self.config_entry.entry_id - ) + device_registry.async_remove_device(device.id) async def _async_load_library(self) -> None: """Load the card library and groups; failures only affect browsing.""" diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index dff9652398d7..b6f12c7efc9c 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -71,6 +71,4 @@ async def delete_devices( dev_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for dev_entry in dev_entries: if any(identifier[1] in channel_ids for identifier in dev_entry.identifiers): - device_registry.async_update_device( - dev_entry.id, remove_config_entry_id=entry.entry_id - ) + device_registry.async_remove_device(dev_entry.id) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 388d22664ea2..952496bdce62 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -9,6 +9,7 @@ from yarl import URL from zha.application.const import BAUD_RATES, RadioType from zha.application.gateway import Gateway from zha.application.helpers import ZHAData +from zha.quirks import DEVICE_REGISTRY from zha.zigbee.device import get_device_automation_triggers from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError @@ -158,11 +159,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_gateway = await Gateway.async_from_config(zha_lib_data) - # Load and cache device trigger information early + # Load and cache device trigger information early. Quirks were registered by + # `Gateway.async_from_config` above, so pass the resolver to quirk devices + # and surface quirk-defined triggers (e.g. remote button presses). device_registry = dr.async_get(hass) radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - async with radio_mgr.create_zigpy_app(connect=False) as app: + async with radio_mgr.create_zigpy_app( + connect=False, device_resolver=DEVICE_REGISTRY.resolve + ) 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/light.py b/homeassistant/components/zha/light.py index 69ec6a03fa2d..43d4f7c421f3 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -12,7 +12,6 @@ from zha.application.platforms.light.const import ( from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, @@ -21,6 +20,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + LightEntityStateAttribute, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform @@ -215,20 +215,27 @@ class Light(LightEntity, ZHAEntity): """Restore entity state.""" color_temp = ( color_util.color_temperature_kelvin_to_mired(color_temp_k) - if (color_temp_k := state.attributes.get(ATTR_COLOR_TEMP_KELVIN)) + if ( + color_temp_k := state.attributes.get( + LightEntityStateAttribute.COLOR_TEMP_KELVIN + ) + ) else None ) self.entity_data.entity.restore_external_state_attributes( state=(state.state == STATE_ON), off_with_transition=state.attributes.get(OFF_WITH_TRANSITION), off_brightness=state.attributes.get(OFF_BRIGHTNESS), - brightness=state.attributes.get(ATTR_BRIGHTNESS), + brightness=state.attributes.get(LightEntityStateAttribute.BRIGHTNESS), color_temp=color_temp, - xy_color=state.attributes.get(ATTR_XY_COLOR), + xy_color=state.attributes.get(LightEntityStateAttribute.XY_COLOR), color_mode=( - HA_TO_ZHA_COLOR_MODE[ColorMode(state.attributes[ATTR_COLOR_MODE])] - if state.attributes.get(ATTR_COLOR_MODE) is not None + HA_TO_ZHA_COLOR_MODE[ + ColorMode(state.attributes[LightEntityStateAttribute.COLOR_MODE]) + ] + if state.attributes.get(LightEntityStateAttribute.COLOR_MODE) + is not None else None ), - effect=state.attributes.get(ATTR_EFFECT), + effect=state.attributes.get(LightEntityStateAttribute.EFFECT), ) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index c6db84d60e77..7112914208f5 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -1,10 +1,10 @@ { "domain": "zha", "name": "Zigbee Home Automation", - "after_dependencies": ["hassio", "onboarding", "usb"], + "after_dependencies": ["hassio", "onboarding"], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"], "config_flow": true, - "dependencies": ["file_upload", "homeassistant_hardware"], + "dependencies": ["file_upload", "homeassistant_hardware", "usb"], "documentation": "https://www.home-assistant.io/integrations/zha", "integration_type": "hub", "iot_class": "local_polling", @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==2.0.0", "zha-quirks==2.1.0"], + "requirements": ["zha==2.0.1", "zha-quirks==2.1.1"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index cdae1558e8e1..6ef2e4013880 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -1,7 +1,7 @@ """ZHA radio manager.""" import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable import contextlib from contextlib import suppress import copy @@ -22,6 +22,7 @@ from zigpy.config import ( CONF_NWK_BACKUP_ENABLED, SCHEMA_DEVICE, ) +import zigpy.device from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries @@ -174,9 +175,17 @@ class ZhaRadioManager: @contextlib.asynccontextmanager async def create_zigpy_app( - self, *, connect: bool = True + self, + *, + connect: bool = True, + device_resolver: Callable[[zigpy.device.Device], zigpy.device.Device] + | None = None, ) -> AsyncGenerator[ControllerApplication]: - """Connect to the radio with the current config and then clean up.""" + """Connect to the radio with the current config and then clean up. + + `device_resolver` is forwarded to zigpy so devices loaded from the + database are quirk-resolved to get quirk-defined device triggers. + """ assert self.radio_type is not None config = get_zha_data(self.hass).yaml_config @@ -201,7 +210,10 @@ class ZhaRadioManager: app_config[CONF_USE_THREAD] = False app = await self.radio_type.controller.new( - app_config, auto_form=False, start_radio=False + app_config, + auto_form=False, + start_radio=False, + device_resolver=device_resolver, ) try: diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 18f7e6c34ad5..f53a4226fa6a 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -9,7 +9,7 @@ from typing import Any, Self, cast, override import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 ATTR_EDITABLE, ATTR_LATITUDE, ATTR_LONGITUDE, @@ -20,9 +20,11 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, + DEFAULT_RADIUS, EVENT_CORE_CONFIG_UPDATE, SERVICE_RELOAD, STATE_UNAVAILABLE, + EntityStateAttribute, ) from homeassistant.core import ( Event, @@ -44,12 +46,18 @@ from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.util.hass_dict import HassKey from homeassistant.util.location import distance -from .const import ATTR_PASSIVE, ATTR_RADIUS, CONF_PASSIVE, DOMAIN, HOME_ZONE +from .const import ( # noqa: F401 + ATTR_PASSIVE, + ATTR_RADIUS, + CONF_PASSIVE, + DOMAIN, + HOME_ZONE, + ZoneEntityStateAttribute, +) _LOGGER = logging.getLogger(__name__) DEFAULT_PASSIVE = False -DEFAULT_RADIUS = 100 ENTITY_ID_FORMAT = "zone.{}" ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE) @@ -142,21 +150,24 @@ def async_in_zones( zone_dist := distance( latitude, longitude, - zone_attrs[ATTR_LATITUDE], - zone_attrs[ATTR_LONGITUDE], + zone_attrs[EntityStateAttribute.LATITUDE], + zone_attrs[EntityStateAttribute.LONGITUDE], ) ) is None # Skip zone that are outside the radius aka the # lat/long is outside the zone - or not (zone_dist - (zone_radius := zone_attrs[ATTR_RADIUS]) < radius) + or not ( + zone_dist - (zone_radius := zone_attrs[ZoneEntityStateAttribute.RADIUS]) + < radius + ) ): continue zones.append((zone.entity_id, zone_dist, zone_radius)) # Skip passive zones - if zone_attrs.get(ATTR_PASSIVE): + if zone_attrs.get(ZoneEntityStateAttribute.PASSIVE): continue # Prefer the smallest zone, using distance to its center as a tie @@ -198,9 +209,9 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[ ): return [] input_attrs = input_zone.attributes - input_latitude: float = input_attrs[ATTR_LATITUDE] - input_longitude: float = input_attrs[ATTR_LONGITUDE] - input_radius: float = input_attrs[ATTR_RADIUS] + input_latitude: float = input_attrs[EntityStateAttribute.LATITUDE] + input_longitude: float = input_attrs[EntityStateAttribute.LONGITUDE] + input_radius: float = input_attrs[ZoneEntityStateAttribute.RADIUS] zones: list[tuple[str, float, float]] = [] @@ -221,12 +232,12 @@ def async_get_enclosing_zones(hass: HomeAssistant, zone_entity_id: str) -> list[ zone_dist := distance( input_latitude, input_longitude, - zone_attrs[ATTR_LATITUDE], - zone_attrs[ATTR_LONGITUDE], + zone_attrs[EntityStateAttribute.LATITUDE], + zone_attrs[EntityStateAttribute.LONGITUDE], ) ) is None: continue - zone_radius = zone_attrs[ATTR_RADIUS] + zone_radius = zone_attrs[ZoneEntityStateAttribute.RADIUS] if not zone_dist + input_radius <= zone_radius: continue zones.append((zone.entity_id, zone_dist, zone_radius)) @@ -281,13 +292,15 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) - zone_dist = distance( latitude, longitude, - zone.attributes[ATTR_LATITUDE], - zone.attributes[ATTR_LONGITUDE], + zone.attributes[EntityStateAttribute.LATITUDE], + zone.attributes[EntityStateAttribute.LONGITUDE], ) - if zone_dist is None or zone.attributes[ATTR_RADIUS] is None: + if zone_dist is None or zone.attributes[ZoneEntityStateAttribute.RADIUS] is None: return False - return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS]) + return zone_dist - radius < cast( + float, zone.attributes[ZoneEntityStateAttribute.RADIUS] + ) class ZoneStorageCollection(collection.DictStorageCollection): @@ -508,12 +521,12 @@ class Zone(collection.CollectionEntity): def _generate_attrs(self) -> None: """Generate new attrs based on config.""" self._attr_extra_state_attributes = { - ATTR_LATITUDE: self._config[CONF_LATITUDE], - ATTR_LONGITUDE: self._config[CONF_LONGITUDE], - ATTR_RADIUS: self._config[CONF_RADIUS], - ATTR_PASSIVE: self._config[CONF_PASSIVE], - ATTR_PERSONS: sorted(self._persons_in_zone), - ATTR_EDITABLE: self.editable, + EntityStateAttribute.LATITUDE: self._config[CONF_LATITUDE], + EntityStateAttribute.LONGITUDE: self._config[CONF_LONGITUDE], + ZoneEntityStateAttribute.RADIUS: self._config[CONF_RADIUS], + ZoneEntityStateAttribute.PASSIVE: self._config[CONF_PASSIVE], + ZoneEntityStateAttribute.PERSONS: sorted(self._persons_in_zone), + ZoneEntityStateAttribute.EDITABLE: self.editable, } @callback diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index f831195c4193..ae00b0bff599 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -5,14 +5,15 @@ from typing import Any, Unpack, cast, override import voluptuous as vol from homeassistant.components.device_tracker import ( - ATTR_IN_ZONES, DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityStateAttribute, +) +from homeassistant.components.person import ( + DOMAIN as PERSON_DOMAIN, + PersonEntityStateAttribute, ) -from homeassistant.components.person import DOMAIN as PERSON_DOMAIN from homeassistant.const import ( ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_ENTITY_ID, CONF_FOR, CONF_OPTIONS, @@ -20,6 +21,7 @@ from homeassistant.const import ( CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage @@ -41,6 +43,7 @@ from homeassistant.helpers.typing import ConfigType from . import in_zone from .const import DOMAIN +from .helpers import get_in_zones_attribute _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { vol.Required(CONF_ENTITY_ID): cv.entity_ids, @@ -48,8 +51,6 @@ _OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { } _CONDITION_SCHEMA = vol.Schema({CONF_OPTIONS: _OPTIONS_SCHEMA_DICT}) -_IN_ZONES_DOMAINS = {DEVICE_TRACKER_DOMAIN, PERSON_DOMAIN} - def zone( hass: HomeAssistant, @@ -88,14 +89,13 @@ def zone( # Prefer the in_zones attribute reported by the entity (e.g. person, # device_tracker) over recomputing membership from coordinates. - if ( - entity.domain in _IN_ZONES_DOMAINS - and (in_zones := entity.attributes.get(ATTR_IN_ZONES)) is not None - ): + if (in_zones_attr := get_in_zones_attribute(entity)) is not None and ( + in_zones := entity.attributes.get(in_zones_attr) + ) is not None: return zone_ent.entity_id in in_zones - latitude = entity.attributes.get(ATTR_LATITUDE) - longitude = entity.attributes.get(ATTR_LONGITUDE) + latitude = entity.attributes.get(EntityStateAttribute.LATITUDE) + longitude = entity.attributes.get(EntityStateAttribute.LONGITUDE) if latitude is None: raise ConditionErrorMessage( @@ -178,8 +178,10 @@ class ZoneCondition(Condition): _DOMAIN_SPECS: dict[str, DomainSpec] = { - "person": DomainSpec(value_source=ATTR_IN_ZONES), - "device_tracker": DomainSpec(value_source=ATTR_IN_ZONES), + PERSON_DOMAIN: DomainSpec(value_source=PersonEntityStateAttribute.IN_ZONES), + DEVICE_TRACKER_DOMAIN: DomainSpec( + value_source=DeviceTrackerEntityStateAttribute.IN_ZONES + ), } _ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend( @@ -205,8 +207,11 @@ class _ZoneTargetConditionBase(EntityConditionBase): def _in_target_zone(self, entity_state: State) -> bool: """Check if the entity is currently in the selected zone.""" - in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or () - return self._zone in in_zones + if (in_zones_attr := get_in_zones_attribute(entity_state)) and ( + in_zones := entity_state.attributes.get(in_zones_attr) + ): + return self._zone in in_zones + return False class InZoneCondition(_ZoneTargetConditionBase): diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py index af6ac00cbbb5..0725b2ce6fdf 100644 --- a/homeassistant/components/zone/const.py +++ b/homeassistant/components/zone/const.py @@ -1,7 +1,20 @@ """Constants for the zone component.""" +from enum import StrEnum + CONF_PASSIVE = "passive" DOMAIN = "zone" HOME_ZONE = "home" + + +class ZoneEntityStateAttribute(StrEnum): + """State attributes for zone entities.""" + + RADIUS = "radius" + PASSIVE = "passive" + PERSONS = "persons" + EDITABLE = "editable" + + ATTR_PASSIVE = "passive" ATTR_RADIUS = "radius" diff --git a/homeassistant/components/zone/helpers.py b/homeassistant/components/zone/helpers.py new file mode 100644 index 000000000000..5e83c6e9c7b3 --- /dev/null +++ b/homeassistant/components/zone/helpers.py @@ -0,0 +1,24 @@ +"""Helpers for the zone integration.""" + +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + DeviceTrackerEntityStateAttribute, +) +from homeassistant.components.person import ( + DOMAIN as PERSON_DOMAIN, + PersonEntityStateAttribute, +) +from homeassistant.core import State + + +def get_in_zones_attribute(state: State) -> str | None: + """Return the in_zones attribute for the tracked entity, or None. + + Only person and device_tracker entities report zone membership; each + exposes it under its own platform enum. Any other domain returns None. + """ + if state.domain == PERSON_DOMAIN: + return PersonEntityStateAttribute.IN_ZONES + if state.domain == DEVICE_TRACKER_DOMAIN: + return DeviceTrackerEntityStateAttribute.IN_ZONES + return None diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index f9fa1f9aea8b..c23a3bb6f3c9 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -5,15 +5,14 @@ from typing import TYPE_CHECKING, Any, cast, override import voluptuous as vol -from homeassistant.components.device_tracker import ATTR_IN_ZONES from homeassistant.const import ( - ATTR_FRIENDLY_NAME, CONF_ENTITY_ID, CONF_EVENT, CONF_FOR, CONF_OPTIONS, CONF_TARGET, CONF_ZONE, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -45,8 +44,8 @@ from homeassistant.helpers.trigger import ( from homeassistant.helpers.typing import ConfigType from . import condition -from .condition import _IN_ZONES_DOMAINS from .const import DOMAIN +from .helpers import get_in_zones_attribute EVENT_ENTER = "enter" EVENT_LEAVE = "leave" @@ -65,7 +64,8 @@ def _state_has_zone_info(state: State) -> bool: tracker); other entities are matched by their coordinates. """ return location.has_location(state) or ( - state.domain in _IN_ZONES_DOMAINS and ATTR_IN_ZONES in state.attributes + (in_zones_attr := get_in_zones_attribute(state)) is not None + and in_zones_attr in state.attributes ) @@ -168,7 +168,7 @@ class LegacyZoneTrigger(Trigger): if (event == EVENT_ENTER and not from_match and to_match) or ( event == EVENT_LEAVE and from_match and not to_match ): - description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}" + description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[EntityStateAttribute.FRIENDLY_NAME]}" run_action( { "entity_id": entity, @@ -199,8 +199,11 @@ class ZoneTriggerBase(EntityTriggerBase): def _in_target_zone(self, state: State) -> bool: """Check if the entity is in the selected zone.""" - in_zones = state.attributes.get(ATTR_IN_ZONES) or () - return self._zone in in_zones + if (in_zones_attr := get_in_zones_attribute(state)) and ( + in_zones := state.attributes.get(in_zones_attr) + ): + return self._zone in in_zones + return False class EnteredZoneTrigger(ZoneTriggerBase): diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index b9e533742690..131d8e1fcb84 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -557,7 +557,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # and cool to mirror previous behavior. If none of those are available, set it # to the first available mode that is not off. try: - hvac_mode = next( + hvac_mode: HVACMode = next( mode for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL) if mode in self._hvac_modes diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 32d60b6269ec..db6fde0f7871 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -456,13 +456,13 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() self.ws_address = f"ws://{discovery_info.host}:{discovery_info.port}" home_id_display = format_home_id_for_display(int(home_id)) - # Show home ID and network location in discovery notification self.context.update( { "title_placeholders": { - "host": discovery_info.host, - "port": str(discovery_info.port), - "home_id": home_id_display, + CONF_NAME: ( + f"Network {home_id_display} at " + f"{discovery_info.host}:{discovery_info.port}" + ) } } ) @@ -1574,8 +1574,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) self.socket_path = discovery_info.socket_path + home_id_display = format_home_id_for_display(discovery_info.zwave_home_id) self.context["title_placeholders"] = { - CONF_NAME: f"{discovery_info.name} via ESPHome" + CONF_NAME: f"Network {home_id_display} via {discovery_info.name} (ESPHome)" } self._adapter_discovered = True diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 1e07651bc2bc..a40baddc99db 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -244,9 +244,10 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): LOGGER.info("%s after setting lock configuration for %s", msg, self.entity_id) async def async_set_user(self, **kwargs: Any) -> SetUserReturn: - """Create or update an access-control user on the lock.""" + """Create or update an access-control user, optionally with a credential.""" user_type = kwargs.get(const.ATTR_USER_TYPE) credential_rule = kwargs.get(const.ATTR_CREDENTIAL_RULE) + credential_type = kwargs.get(const.ATTR_CREDENTIAL_TYPE) try: return await lock_helpers.async_set_user( self.info.node, @@ -261,6 +262,13 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): else None ), active=kwargs.get(const.ATTR_USER_ACTIVE), + credential_type=( + CREDENTIAL_TYPE_REVERSE_MAP[credential_type] + if credential_type is not None + else None + ), + credential_slot=kwargs.get(const.ATTR_CREDENTIAL_SLOT), + credential_data=kwargs.get(const.ATTR_CREDENTIAL_DATA), ) except BaseZwaveJSServerError as err: raise _credential_service_error("set_user_failed", err) from err diff --git a/homeassistant/components/zwave_js/lock_helpers.py b/homeassistant/components/zwave_js/lock_helpers.py index e4d3c89adeb7..c66500be18f8 100644 --- a/homeassistant/components/zwave_js/lock_helpers.py +++ b/homeassistant/components/zwave_js/lock_helpers.py @@ -16,7 +16,12 @@ from zwave_js_server.const.command_class.access_control import ( UserCredentialType, UserCredentialUserType, ) -from zwave_js_server.model.access_control import SetUserOptions +from zwave_js_server.exceptions import FailedZWaveCommand +from zwave_js_server.model.access_control import ( + AddUserCredential, + SetUserOptions, + UserCredentialCapability, +) from zwave_js_server.model.node import Node from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -191,6 +196,8 @@ class SetUserReturn(TypedDict): """Return type for set_user.""" user_id: int + # None unless a credential was written in the same call. + credential_slot: int | None class SetCredentialReturn(TypedDict): @@ -200,6 +207,121 @@ class SetCredentialReturn(TypedDict): user_id: int +async def _validate_credential_data( + node: Node, + credential_type: UserCredentialType, + credential_data: str, +) -> UserCredentialCapability: + """Validate a credential payload against the device capabilities. + + Returns the capability for the credential type so callers can reuse its + slot information. + """ + cred_type_str = CREDENTIAL_TYPE_MAP.get(credential_type, str(credential_type)) + cred_caps = await node.access_control.get_credential_capabilities_cached() + type_cap = cred_caps.supported_credential_types.get(credential_type) + if type_cap is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_type_not_supported", + translation_placeholders={"credential_type": cred_type_str}, + ) + + if not ( + type_cap.min_credential_length + <= len(credential_data) + <= type_cap.max_credential_length + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_data_invalid_length", + translation_placeholders={ + "credential_type": cred_type_str, + "min_length": str(type_cap.min_credential_length), + "max_length": str(type_cap.max_credential_length), + }, + ) + if credential_type is UserCredentialType.PIN_CODE and not ( + credential_data.isascii() and credential_data.isdigit() + ): + # str.isdigit() accepts non-ASCII digit code points (e.g. Arabic-Indic), + # which the lock firmware cannot store. Restrict to ASCII 0-9. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_data_pin_not_digits", + ) + + return type_cap + + +async def _async_find_available_user_slot(node: Node) -> int: + """Return the first unused user slot, raising if the lock is full.""" + user_caps = await node.access_control.get_user_capabilities_cached() + users = await node.access_control.get_users_cached() + used_ids = {u.user_id for u in users} + user_id = next( + (i for i in range(1, user_caps.max_users + 1) if i not in used_ids), + None, + ) + if user_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_available_user_slots", + ) + return user_id + + +async def _async_find_available_credential_slot( + node: Node, + credential_type: UserCredentialType, + type_cap: UserCredentialCapability, +) -> int: + """Return the first unused slot for a credential type, raising if full.""" + existing = await node.access_control.get_credentials_by_type_cached(credential_type) + used_slots = {c.slot for c in existing} + slot = next( + ( + s + for s in range(1, type_cap.number_of_credential_slots + 1) + if s not in used_slots + ), + None, + ) + if slot is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_available_credential_slots", + translation_placeholders={ + "credential_type": CREDENTIAL_TYPE_MAP.get( + credential_type, str(credential_type) + ) + }, + ) + return slot + + +async def _async_resolve_credential_slot( + node: Node, + user_id: int, + credential_type: UserCredentialType, + type_cap: UserCredentialCapability, + credential_slot: int | None, +) -> int: + """Resolve the slot a credential should be written to for a user.""" + # An explicit slot is honored as-is. + if credential_slot is not None: + return credential_slot + # When the lock supports independent user and credentials, find the first + # available slot for the credential. + user_caps = await node.access_control.get_user_capabilities_cached() + if user_caps.supports_users_without_credentials: + return await _async_find_available_credential_slot( + node, credential_type, type_cap + ) + # Otherwise the credential must live in the user's own slot. + return user_id + + # --- Business logic functions --- @@ -298,30 +420,17 @@ async def async_set_user( user_type: UserCredentialUserType | None = None, credential_rule: UserCredentialRule | None = None, active: bool | None = None, + credential_slot: int | None = None, + credential_type: UserCredentialType | None = None, + credential_data: str | None = None, ) -> SetUserReturn: - """Create or update an access-control user. Returns the allocated user_id.""" - supported = await node.access_control.is_supported() - if not supported: + """Create or update an access-control user, optionally with a credential.""" + if not await node.access_control.is_supported(): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="access_control_not_supported", ) - # Auto-find first available user slot - if user_id is None: - user_caps = await node.access_control.get_user_capabilities_cached() - users = await node.access_control.get_users_cached() - used_ids = {u.user_id for u in users} - user_id = next( - (i for i in range(1, user_caps.max_users + 1) if i not in used_ids), - None, - ) - if user_id is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_available_user_slots", - ) - options = SetUserOptions( active=active, user_type=user_type, @@ -329,9 +438,89 @@ async def async_set_user( credential_rule=credential_rule, ) + # No user_id => create a new user via addUser, which handles locks that require + # credentials to be created alongside their user. + if user_id is None: + return await _async_create_user( + node, options, credential_slot, credential_type, credential_data + ) + + # A user_id => update the existing user via setUser, then write any + # credential separately (addUser only creates new users). status = await node.access_control.set_user(user_id, options) _raise_on_set_user_error(status) - return SetUserReturn(user_id=user_id) + + resolved_slot: int | None = None + if credential_type is not None and credential_data is not None: + type_cap = await _validate_credential_data( + node, credential_type, credential_data + ) + resolved_slot = await _async_resolve_credential_slot( + node, user_id, credential_type, type_cap, credential_slot + ) + cred_status = await node.access_control.set_credential( + user_id, credential_type, resolved_slot, credential_data + ) + _raise_on_set_credential_error(cred_status) + + return SetUserReturn(user_id=user_id, credential_slot=resolved_slot) + + +async def _async_create_user( + node: Node, + options: SetUserOptions, + credential_slot: int | None, + credential_type: UserCredentialType | None, + credential_data: str | None, +) -> SetUserReturn: + """Create a new user via addUser, optionally bundling a credential.""" + user_id = await _async_find_available_user_slot(node) + + credential: AddUserCredential | None = None + resolved_slot: int | None = None + if credential_type is not None and credential_data is not None: + type_cap = await _validate_credential_data( + node, credential_type, credential_data + ) + resolved_slot = await _async_resolve_credential_slot( + node, user_id, credential_type, type_cap, credential_slot + ) + credential = AddUserCredential( + credential_type=credential_type, + credential_slot=resolved_slot, + data=credential_data, + ) + else: + user_caps = await node.access_control.get_user_capabilities_cached() + if not user_caps.supports_users_without_credentials: + # On User Code CC a user cannot exist without its code, so zwave-js + # rejects addUser without a credential. Fail early with a clear + # error instead of letting the command fail downstream. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="credential_required", + ) + + result = await node.access_control.add_user(user_id, options, credential) + _raise_on_set_user_error(result.user) + if ( + result.credential is not None + and result.credential is not SetCredentialResult.OK + ): + # addUser creates the user before writing the credential, so on User + # Credential CC a failed credential write leaves a credential-less user + # behind. Roll it back before surfacing the error. (On User Code CC the + # user and credential share a slot, so this cannot occur.) + try: + await node.access_control.delete_user(user_id) + except FailedZWaveCommand: + _LOGGER.warning( + "Could not roll back user %s after its credential failed to add", + user_id, + ) + _raise_on_set_credential_error(result.credential) + + return SetUserReturn(user_id=user_id, credential_slot=resolved_slot) async def async_delete_user(node: Node, user_id: int) -> None: @@ -378,60 +567,12 @@ async def async_set_credential( translation_key="access_control_not_supported", ) - cred_type_str = CREDENTIAL_TYPE_MAP.get(credential_type, str(credential_type)) - cred_caps = await node.access_control.get_credential_capabilities_cached() - type_cap = cred_caps.supported_credential_types.get(credential_type) - if type_cap is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="credential_type_not_supported", - translation_placeholders={"credential_type": cred_type_str}, - ) - - # Validate credential_data length and format against device capabilities - if not ( - type_cap.min_credential_length - <= len(credential_data) - <= type_cap.max_credential_length - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="credential_data_invalid_length", - translation_placeholders={ - "credential_type": cred_type_str, - "min_length": str(type_cap.min_credential_length), - "max_length": str(type_cap.max_credential_length), - }, - ) - if credential_type is UserCredentialType.PIN_CODE and not ( - credential_data.isascii() and credential_data.isdigit() - ): - # str.isdigit() accepts non-ASCII digit code points (e.g. Arabic-Indic), - # which the lock firmware cannot store. Restrict to ASCII 0-9. - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="credential_data_pin_not_digits", - ) + type_cap = await _validate_credential_data(node, credential_type, credential_data) if credential_slot is None: - existing = await node.access_control.get_credentials_by_type_cached( - credential_type + credential_slot = await _async_find_available_credential_slot( + node, credential_type, type_cap ) - used_slots = {c.slot for c in existing} - credential_slot = next( - ( - s - for s in range(1, type_cap.number_of_credential_slots + 1) - if s not in used_slots - ), - None, - ) - if credential_slot is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_available_credential_slots", - translation_placeholders={"credential_type": cred_type_str}, - ) status = await node.access_control.set_credential( user_id, credential_type, credential_slot, credential_data @@ -450,7 +591,12 @@ async def async_delete_credential( credential_type: UserCredentialType, credential_slot: int, ) -> None: - """Delete a single credential.""" + """Delete a single credential. + + On User Code CC the credential shares its user's slot, so zwave-js clears + the whole slot and deletes the user along with the credential; no special + handling is needed here. + """ if not await node.access_control.is_supported(): raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 92f45f90ab71..80aba468d7ad 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -92,6 +92,11 @@ def _async_register_credential_services(hass: HomeAssistant) -> None: CREDENTIAL_RULE_REVERSE_MAP.keys() ), vol.Optional(const.ATTR_USER_ACTIVE): cv.boolean, + vol.Inclusive(const.ATTR_CREDENTIAL_TYPE, "credential"): vol.In( + const.WRITABLE_CREDENTIAL_TYPES + ), + vol.Optional(const.ATTR_CREDENTIAL_SLOT): uint16_id, + vol.Inclusive(const.ATTR_CREDENTIAL_DATA, "credential"): cv.string, }, func="async_set_user", supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index dc1bbbdc1aef..4cb2b6a214b8 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -129,6 +129,22 @@ set_user: active: selector: boolean: + credential_type: + selector: + select: + options: + - pin_code + - password + credential_slot: + selector: + number: + min: 1 + max: 65535 + step: 1 + mode: box + credential_data: + selector: + text: delete_user: target: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 95536733c869..e7f13d332e74 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -30,7 +30,7 @@ "invalid_ws_url": "Invalid websocket URL", "unknown": "[%key:common::config_flow::error::unknown%]" }, - "flow_title": "Network {home_id} at {host}:{port}", + "flow_title": "{name}", "progress": { "backup_nvm": "Please wait while the network backup completes", "install_addon": "Installation can take several minutes", @@ -322,6 +322,9 @@ "credential_rejected_wrong_uuid": { "message": "The device rejected the credential because the user unique identifier does not match." }, + "credential_required": { + "message": "This device requires a credential when adding a user." + }, "credential_type_not_supported": { "message": "Credential type {credential_type} is not supported on this device" }, @@ -800,16 +803,28 @@ "name": "Set lock user code" }, "set_user": { - "description": "Creates or updates an access-control user on a lock entity. Returns the allocated user_id for each targeted lock entity.", + "description": "Creates or updates an access-control user on a lock entity. When no user_id is given, a new user is created. A credential (PIN code or password) can be written in the same call. Returns the allocated user_id, plus the credential_slot when a credential was written, for each targeted lock entity.", "fields": { "active": { "description": "Whether the user is active.", "name": "Active" }, + "credential_data": { + "description": "The credential data (e.g. PIN digits or password string).", + "name": "Credential data" + }, "credential_rule": { "description": "Credential rule for the user (single, dual, or triple).", "name": "Credential rule" }, + "credential_slot": { + "description": "Credential slot index. If not specified, the first available slot is used.", + "name": "Credential slot" + }, + "credential_type": { + "description": "The type of credential (pin_code or password).", + "name": "Credential type" + }, "user_id": { "description": "User slot index. If not specified, the first available slot is used.", "name": "User index" diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 91b3559f4466..7dd68ff7e3c1 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -19,11 +19,11 @@ from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node.firmware import NodeFirmwareUpdateInfo from homeassistant.components.update import ( - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, UpdateEntityFeature, + UpdateEntityStateAttribute, ) from homeassistant.const import EntityCategory from homeassistant.core import CoreState, HomeAssistant, callback @@ -355,7 +355,11 @@ class ZWaveFirmwareUpdateEntity(ZWaveNodeBaseEntity, UpdateEntity): # If we have a complete previous state, use that to set the latest version if ( (state := await self.async_get_last_state()) - and (latest_version := state.attributes.get(ATTR_LATEST_VERSION)) + and ( + latest_version := state.attributes.get( + UpdateEntityStateAttribute.LATEST_VERSION + ) + ) is not None and (extra_data := await self.async_get_last_extra_data()) and ( diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2edbb3c035f8..9f1e2839be95 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2133,6 +2133,7 @@ class ConfigEntries: self._hass_config = hass_config self._entries = ConfigEntryItems(hass) self._store = ConfigEntryStore(hass) + self._initialized = asyncio.Event() EntityRegistryDisabledHandler(hass).async_setup() @callback @@ -2277,7 +2278,7 @@ class ConfigEntries: dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) - dev_reg.async_clear_config_entry(entry_id) + dev_reg.async_clear_config_entry(entry_id, entry.domain) ent_reg.async_clear_config_entry(entry_id) # If the configuration entry is removed during reauth, it should @@ -2302,6 +2303,7 @@ class ConfigEntries: if config is None: self._entries = ConfigEntryItems(self.hass) + self._initialized.set() return entries: ConfigEntryItems = ConfigEntryItems(self.hass) @@ -2341,6 +2343,12 @@ class ConfigEntries: EVENT_HOMEASSISTANT_STARTED, self._async_scan_orphan_ignored_entries ) + self._initialized.set() + + async def async_wait_initialized(self) -> None: + """Wait until the config entries are loaded from storage.""" + await self._initialized.wait() + async def _async_scan_orphan_ignored_entries( self, event: Event[NoEventData] ) -> None: @@ -2686,7 +2694,7 @@ class ConfigEntries: dev_reg = dr.async_get(self.hass) ent_reg = er.async_get(self.hass) - dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id) + dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id, entry.domain) ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id) return result diff --git a/homeassistant/const.py b/homeassistant/const.py index 326742e062e1..346f9a37f58b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -48,6 +48,9 @@ ENTITY_MATCH_ANY: Final = "any" # If no name is specified DEVICE_DEFAULT_NAME: Final = "Unnamed Device" +# Default radius of the Home Zone (in meters) +DEFAULT_RADIUS: Final = 100 + # Max characters for data stored in the recorder (changes to these limits would require # a database migration) MAX_LENGTH_EVENT_EVENT_TYPE: Final = 64 @@ -471,6 +474,8 @@ class EntityStateAttribute(StrEnum): ENTITY_PICTURE = "entity_picture" FRIENDLY_NAME = "friendly_name" ICON = "icon" + LATITUDE = "latitude" + LONGITUDE = "longitude" RESTORED = "restored" SUPPORTED_FEATURES = "supported_features" UNIT_OF_MEASUREMENT = "unit_of_measurement" diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index de958b97d417..82413efcc721 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -49,6 +49,7 @@ from .const import ( CONF_UNIT_SYSTEM, CONF_URL, CONF_USERNAME, + DEFAULT_RADIUS, EVENT_CORE_CONFIG_UPDATE, KEY_DATA_LOGGING_DISABLED_REASON, LEGACY_CONF_WHITELIST_EXTERNAL_DIRS, @@ -538,8 +539,6 @@ class Config: def __init__(self, hass: HomeAssistant, config_dir: str) -> None: """Initialize a new config object.""" - from .components.zone import DEFAULT_RADIUS # noqa: PLC0415 - self.hass = hass self.latitude: float = 0 @@ -858,9 +857,6 @@ class Config: old_data: dict[str, Any], ) -> dict[str, Any]: """Migrate to the new version.""" - - from .components.zone import DEFAULT_RADIUS # noqa: PLC0415 - data = old_data if old_major_version == 1 and old_minor_version < 2: # In 1.2, we remove support for "imperial", replaced by "us_customary" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 06ebd9b263f9..06fe7da634be 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -15,6 +15,7 @@ APPLICATION_CREDENTIALS = [ "google", "google_assistant_sdk", "google_drive", + "google_health", "google_mail", "google_photos", "google_sheets", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 789334a9d9fd..5ef4c22897d6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -74,6 +74,7 @@ FLOWS = { "aprilaire", "apsystems", "aquacell", + "aqualogic", "aqvify", "aranet", "arcam_fmj", @@ -256,12 +257,14 @@ FLOWS = { "fritzbox_callmonitor", "fronius", "frontier_silicon", + "fuelprices_dk", "fujitsu_fglair", "fully_kiosk", "fumis", "fyta", "garages_amsterdam", "gardena_bluetooth", + "gatus", "gdacs", "generic", "geniushub", @@ -284,6 +287,7 @@ FLOWS = { "google_cloud", "google_drive", "google_generative_ai_conversation", + "google_health", "google_mail", "google_photos", "google_sheets", @@ -303,6 +307,8 @@ FLOWS = { "guntamatic", "habitica", "hanna", + "harbor", + "harman_luxury", "harmony", "hdfury", "hegel", @@ -385,6 +391,7 @@ FLOWS = { "justnimbus", "jvc_projector", "kaleidescape", + "karakeep", "keenetic_ndms2", "kegtron", "keymitt_ble", @@ -408,6 +415,7 @@ FLOWS = { "ld2410_ble", "leaone", "led_ble", + "led_infrared", "lektrico", "letpot", "lg_infrared", @@ -422,8 +430,10 @@ FLOWS = { "lifx", "linkplay", "litejet", + "litellm", "litterrobot", "livisi", + "llama_cpp", "local_calendar", "local_file", "local_ip", @@ -433,11 +443,13 @@ FLOWS = { "london_underground", "lookin", "loqed", + "luci", "luftdaten", "lunatone", "lupusec", "lutron", "lutron_caseta", + "lyngdorf", "lyric", "madvr", "mailgun", @@ -496,6 +508,7 @@ FLOWS = { "nasweb", "neato", "nederlandse_spoorwegen", + "neopool", "ness_alarm", "nest", "netatmo", @@ -573,7 +586,6 @@ FLOWS = { "peblar", "peco", "pegel_online", - "permobil", "pglab", "philips_js", "pi_hole", @@ -663,6 +675,7 @@ FLOWS = { "satel_integra", "saunum", "schlage", + "scorpiontrack", "scrape", "screenlogic", "season", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 0cb446fd2c92..2c7dba6202db 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1058,6 +1058,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "my[45]50*", "macaddress": "001E0C*", }, + { + "domain": "stiebel_eltron", + "hostname": "servicewelt*", + }, { "domain": "sunricher_dali", "registered_devices": True, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 58949fe0594e..5b860e42de22 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -499,7 +499,7 @@ "aqualogic": { "name": "AquaLogic", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "aquostv": { @@ -2319,6 +2319,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "fuelprices_dk": { + "name": "Fuelprices.dk", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "fujitsu": { "name": "Fujitsu", "integrations": { @@ -2382,6 +2388,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "gatus": { + "name": "Gatus", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "gaviota": { "name": "Gaviota", "integration_type": "virtual", @@ -2554,6 +2566,12 @@ "iot_class": "cloud_polling", "name": "Google Gemini" }, + "google_health": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Health" + }, "google_mail": { "integration_type": "service", "config_flow": true, @@ -2740,6 +2758,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "harbor": { + "name": "Harbor Sleep", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "hardkernel": { "name": "Hardkernel", "integration_type": "hardware", @@ -2752,6 +2776,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "harman_luxury": { + "name": "Harman Luxury Audio", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "harvey": { "name": "Harvey", "integration_type": "virtual", @@ -3482,6 +3512,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "karakeep": { + "name": "Karakeep", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "keba": { "name": "Keba Charging Station", "integration_type": "hub", @@ -3698,6 +3734,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "led_infrared": { + "name": "LED Infrared", + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state" + }, "legrand": { "name": "Legrand", "integration_type": "virtual", @@ -3875,6 +3917,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "litellm": { + "name": "LiteLLM", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "litterrobot": { "name": "Whisker", "integration_type": "hub", @@ -3887,6 +3935,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "llama_cpp": { + "name": "llama.cpp", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "llamalab_automate": { "name": "LlamaLab Automate", "integration_type": "hub", @@ -4021,6 +4075,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "lyngdorf": { + "name": "Lyngdorf", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "madeco": { "name": "Madeco", "integration_type": "virtual", @@ -4133,12 +4193,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "melcloud_home": { - "name": "MELCloud Home", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "melissa": { "name": "Melissa", "integration_type": "hub", @@ -4332,6 +4386,12 @@ "iot_class": "cloud_polling", "name": "MELCloud" }, + "melcloud_home": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "MELCloud Home" + }, "mitsubishi_comfort": { "integration_type": "hub", "config_flow": true, @@ -4364,6 +4424,7 @@ "iot_class": "local_polling" }, "modbus": { + "name": "Modbus", "integration_type": "hub", "config_flow": false, "iot_class": "local_polling" @@ -4618,6 +4679,12 @@ "integration_type": "virtual", "supported_by": "shelly" }, + "neopool": { + "name": "NeoPool", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", @@ -4628,7 +4695,8 @@ "name": "Netatmo", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "single_config_entry": true }, "netdata": { "name": "Netdata", @@ -5092,7 +5160,7 @@ "integrations": { "luci": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "OpenWrt (luci)" }, @@ -5289,7 +5357,7 @@ "permobil": { "name": "MyPermobil", "integration_type": "device", - "config_flow": true, + "config_flow": false, "iot_class": "cloud_polling" }, "pge": { @@ -6160,6 +6228,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "scorpiontrack": { + "name": "ScorpionTrack", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "scrape": { "name": "Scrape", "integration_type": "hub", @@ -8487,7 +8561,6 @@ "local_todo", "min_max", "mobile_app", - "modbus", "moehlenhoff_alpha2", "mold_indicator", "moon", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index ecb94f5d1e1f..8409d1d8f436 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -135,6 +135,16 @@ SSDP = { "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1", }, ], + "harman_luxury": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Harman Luxury Audio", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Harman Luxury Audio", + }, + ], "harmony": [ { "deviceType": "urn:myharmony-com:device:harmony:1", @@ -206,6 +216,12 @@ SSDP = { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1", }, ], + "lyngdorf": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Lyngdorf", + }, + ], "nanoleaf": [ { "st": "Nanoleaf_aurora:light", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index c775a4aed728..8ab868a35b5e 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING, Any, Literal, TypedDict, override -from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType @@ -581,7 +581,8 @@ def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: if ( state.domain != "sensor" - or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.TEMPERATURE + or state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + != SensorDeviceClass.TEMPERATURE ): raise ValueError(f"Entity {entity_id} is not a temperature sensor") @@ -595,6 +596,7 @@ def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: if ( state.domain != "sensor" - or state.attributes.get(ATTR_DEVICE_CLASS) != SensorDeviceClass.HUMIDITY + or state.attributes.get(EntityStateAttribute.DEVICE_CLASS) + != SensorDeviceClass.HUMIDITY ): raise ValueError(f"Entity {entity_id} is not a humidity sensor") diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 212ecf02f380..4896cf59b498 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -3,8 +3,7 @@ import abc import asyncio from collections import deque -from collections.abc import Callable, Container, Coroutine, Generator, Iterable, Mapping -from contextlib import contextmanager +from collections.abc import Callable, Container, Coroutine, Iterable, Mapping from dataclasses import dataclass from datetime import datetime, time as dt_time, timedelta import functools as ft @@ -31,8 +30,6 @@ from typing import ( import voluptuous as vol from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, CONF_ABOVE, CONF_AFTER, CONF_ATTRIBUTE, @@ -51,13 +48,21 @@ from homeassistant.const import ( CONF_TARGET, CONF_VALUE_TEMPLATE, CONF_WEEKDAY, + CONF_ZONE, ENTITY_MATCH_ALL, ENTITY_MATCH_ANY, STATE_UNAVAILABLE, STATE_UNKNOWN, WEEKDAYS, + EntityStateAttribute, +) +from homeassistant.core import ( + HomeAssistant, + State, + callback, + split_entity_id, + valid_entity_id, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.exceptions import ( ConditionError, ConditionErrorContainer, @@ -989,7 +994,7 @@ class EntityNumericalConditionBase(EntityConditionBase): # Entity not found return None if not self._is_valid_unit( - entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) ): # Entity unit does not match the expected unit return None @@ -1007,7 +1012,7 @@ class EntityNumericalConditionBase(EntityConditionBase): domain_spec = self._domain_specs[entity_state.domain] if domain_spec.value_source is None: if not self._is_valid_unit( - entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) ): return None return entity_state.state @@ -1095,7 +1100,7 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase): def _get_entity_unit(self, entity_state: State) -> str | None: """Get the unit of an entity from its state.""" - return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) @override def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None: @@ -1121,7 +1126,7 @@ class EntityNumericalConditionWithUnitBase(EntityNumericalConditionBase): try: return self._unit_converter.convert( value, - entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + entity_state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT), self._base_unit, ) except HomeAssistantError: @@ -1228,25 +1233,42 @@ def condition_trace_update_result(**kwargs: Any) -> None: node.update_result(**kwargs) -@contextmanager -def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement]: +class trace_condition: """Trace condition evaluation.""" - should_pop = True - trace_element = trace_stack_top(trace_stack_cv) - if trace_element and trace_element.reuse_by_child: - should_pop = False - trace_element.reuse_by_child = False - else: - trace_element = condition_trace_append(variables, trace_path_get()) - trace_stack_push(trace_stack_cv, trace_element) - try: - yield trace_element - except Exception as ex: - trace_element.set_error(ex) - raise - finally: - if should_pop: - trace_stack_pop(trace_stack_cv) + + __slots__ = ("_should_pop", "_trace_element", "_variables") + + _should_pop: bool + _trace_element: TraceElement + + def __init__(self, variables: TemplateVarsType) -> None: + """Store the variables for the trace element.""" + self._variables = variables + + def __enter__(self) -> TraceElement: + """Start tracing the condition evaluation.""" + should_pop = True + trace_element = trace_stack_top(trace_stack_cv) + if trace_element and trace_element.reuse_by_child: + should_pop = False + trace_element.reuse_by_child = False + else: + trace_element = condition_trace_append(self._variables, trace_path_get()) + trace_stack_push(trace_stack_cv, trace_element) + self._should_pop = should_pop + self._trace_element = trace_element + return trace_element + + def __exit__( + self, exc_type: object, exc_val: BaseException | None, exc_tb: object + ) -> None: + """Finish tracing the condition evaluation.""" + try: + if exc_val is not None and isinstance(exc_val, Exception): + self._trace_element.set_error(exc_val) + finally: + if self._should_pop: + trace_stack_pop(trace_stack_cv) @overload @@ -1851,7 +1873,7 @@ def time( ): after = datetime.strptime(after_entity.state, "%H:%M:%S").time() elif ( - after_entity.attributes.get(ATTR_DEVICE_CLASS) + after_entity.attributes.get(EntityStateAttribute.DEVICE_CLASS) in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and after_entity.state not in ( STATE_UNAVAILABLE, @@ -1881,7 +1903,7 @@ def time( except ValueError: return False elif ( - before_entity.attributes.get(ATTR_DEVICE_CLASS) + before_entity.attributes.get(EntityStateAttribute.DEVICE_CLASS) in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and before_entity.state not in ( STATE_UNAVAILABLE, @@ -2120,6 +2142,27 @@ def async_extract_entities(config: ConfigType | Template) -> set[str]: to_process.extend(config["conditions"]) continue + if condition == "time": + # The before and after options can be a time or an entity id. + for key in (CONF_AFTER, CONF_BEFORE): + if isinstance(value := config.get(key), str) and valid_entity_id(value): + referenced.add(value) + continue + + if condition == "zone": + options = config.get(CONF_OPTIONS, {}) + referenced.update(options.get(CONF_ENTITY_ID, [])) + referenced.update(options.get(CONF_ZONE, [])) + + elif condition in ( + "zone.in_zone", + "zone.not_in_zone", + "zone.occupancy_is_detected", + "zone.occupancy_is_not_detected", + ): + if zone_entity_id := config.get(CONF_OPTIONS, {}).get(CONF_ZONE): + referenced.add(zone_entity_id) + entity_ids = config.get(CONF_ENTITY_ID) if isinstance(entity_ids, str): diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index 2d90a9c7914b..af8e5908661f 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -3,6 +3,7 @@ from homeassistant.core import HomeAssistant, callback from . import device_registry as dr, entity_registry as er +from .frame import ReportBehavior, report_usage @callback @@ -41,13 +42,18 @@ def async_device_info_to_link_from_entity( ) -> dr.DeviceInfo | None: """DeviceInfo with information to link a device from an entity. - DeviceInfo will only return information to categorize as a link. + Deprecated, always returns None; set entity.device_entry instead. """ - - return async_device_info_to_link_from_device_id( - hass, - async_entity_id_to_device_id(hass, entity_id_or_uuid), + report_usage( + "calls async_device_info_to_link_from_entity, which is deprecated and always " + "returns None: a device_info carrying another device's identifiers implicitly " + "added the caller's config entry to that device, which a single-config-entry " + "device can't represent. Set entity.device_entry = " + "async_entity_id_to_device(hass, source_entity_id) instead", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", ) + return None @callback @@ -57,18 +63,17 @@ def async_device_info_to_link_from_device_id( ) -> dr.DeviceInfo | None: """DeviceInfo with information to link a device from a device id. - DeviceInfo will only return information to categorize as a link. + Deprecated, always returns None; set entity.device_entry instead. """ - - dev_reg = dr.async_get(hass) - - if device_id is None or (device := dev_reg.async_get(device_id=device_id)) is None: - return None - - return dr.DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, + report_usage( + "calls async_device_info_to_link_from_device_id, which is deprecated and always " + "returns None: a device_info carrying another device's identifiers implicitly " + "added the caller's config entry to that device, which a single-config-entry " + "device can't represent. Set entity.device_entry to the target device instead", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", ) + return None @callback diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index daf373a18624..10c015f5520d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,11 +2,15 @@ import asyncio from collections import defaultdict -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Set as AbstractSet +import copy +from dataclasses import dataclass from datetime import datetime from enum import StrEnum from functools import lru_cache import logging +import os +import shutil import time from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack, override @@ -32,7 +36,12 @@ from homeassistant.util.json import format_unserializable_data from . import storage, translation from .debounce import Debouncer from .deprecation import deprecated_function -from .frame import ReportBehavior, report_usage +from .frame import ( + MissingIntegrationFrame, + ReportBehavior, + get_integration_frame, + report_usage, +) from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType from .typing import UNDEFINED, UndefinedType @@ -54,8 +63,8 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event "device_registry_updated" ) STORAGE_KEY = "core.device_registry" -STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 12 +STORAGE_VERSION_MAJOR = 3 +STORAGE_VERSION_MINOR = 1 CLEANUP_DELAY = 10 @@ -66,8 +75,32 @@ CONNECTION_ZIGBEE = "zigbee" ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 -# Can be removed when suggested_area is removed from DeviceEntry -RUNTIME_ONLY_ATTRS = {"suggested_area"} +# suggested_area can be removed when suggested_area is removed from DeviceEntry. +# pending_move can be removed once add_config_entry_id and remove_config_entry_id +# are removed from the device registry API. +RUNTIME_ONLY_ATTRS = {"suggested_area", "pending_move"} + + +@dataclass(frozen=True, slots=True) +class _PendingMove: + """A deferred config-entry move recorded by add_config_entry_id. + + A later remove_config_entry_id from the same integration (origin_domain) completes + the move; one from a different integration cancels it. Runtime-only, never stored. + """ + + config_entry_id: str + config_subentry_id: str | None + origin_domain: str | None + + +def _current_integration_domain() -> str | None: + """Return the domain of the integration in the current call stack, if any.""" + try: + return get_integration_frame().integration + except MissingIntegrationFrame: + return None + CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"} @@ -102,7 +135,8 @@ class DeviceInfo(TypedDict, total=False): hw_version: str | None translation_key: str | None translation_placeholders: Mapping[str, str] | None - via_device: tuple[str, str] + via_device: tuple[str, str] # Deprecated, use via_device_id instead + via_device_id: str DEVICE_INFO_TYPES = { @@ -127,6 +161,7 @@ DEVICE_INFO_TYPES = { "suggested_area", "sw_version", "via_device", + "via_device_id", }, "secondary": { "connections", @@ -135,14 +170,10 @@ DEVICE_INFO_TYPES = { "default_name", # Used by Fritz "via_device", + "via_device_id", }, } -DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values())) - -# Integrations which may share a device with a native integration -LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"} - class _EventDeviceRegistryUpdatedData_Create(TypedDict): """EventDeviceRegistryUpdated data for action type 'create'.""" @@ -365,9 +396,10 @@ def _normalize_connections_validator( class DeviceEntry: """Device Registry Entry.""" + config_entry_id: str = attr.ib() + area_id: str | None = attr.ib(default=None) - config_entries: set[str] = attr.ib(converter=set, factory=set) - config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) + config_subentry_id: str | None = attr.ib(default=None) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib( converter=set, factory=set, validator=_normalize_connections_validator @@ -379,20 +411,85 @@ class DeviceEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set) labels: set[str] = attr.ib(converter=set, factory=set) + # composite_device_id is the id of the pre-migration composite device this device was + # split from; composite_primary_config_entry is that composite's former + # primary_config_entry, so a restored composite device can report it. + # split_at records when the split happened. + composite_device_id: str | None = attr.ib(default=None) + composite_primary_config_entry: str | None = attr.ib(default=None) + split_at: datetime | None = attr.ib(default=None) manufacturer: str | None = attr.ib(default=None) model: str | None = attr.ib(default=None) model_id: str | None = attr.ib(default=None) modified_at: datetime = attr.ib(factory=utcnow) name_by_user: str | None = attr.ib(default=None) name: str | None = attr.ib(default=None) - primary_config_entry: str | None = attr.ib(default=None) + # Set on devices created by splitting a pre-migration composite device: the + # identifiers and connections copied from the composite have not yet been reconciled. + # On the owning integration's first re-registration they are replaced with the ones + # it provides and this flag is cleared - a one-shot marker, unlike composite_device_id + # which is kept for the device's lifetime so old ids keep resolving; neither can be + # derived from the other. This flag and the replacement logic can be removed in HA + # Core 2027.8. + has_composite_identifiers: bool = attr.ib(default=False) serial_number: str | None = attr.ib(default=None) - # Suggested area is deprecated and will be removed from DeviceEntry in 2026.9. + # Suggested area is deprecated and will be removed from DeviceEntry in HA Core 2026.9. _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) + # Transient pending move target (config_entry_id, config_subentry_id) initiated by + # add_config_entry_id and completed by a subsequent remove_config_entry_id. It is + # never stored and is not part of equality. Can be removed in HA Core 2027.8. + _pending_move: _PendingMove | None = attr.ib(default=None, eq=False) + # Set only on the read-only composite device that async_get synthesizes on demand + # for a pre-migration composite device id. It holds the union of the split + # devices' config entries and subentries so callers see the pre-split device. It is + # never stored and the composite is never added to the registry. Can be removed in + # HA Core 2027.8. + _composite_subentries: dict[str, set[str | None]] | None = attr.ib( + default=None, eq=False + ) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) + @property + def config_entries(self) -> set[str]: + """Return the config entries this device belongs to. + + Deprecated compatibility shim: a device now belongs to a single config + entry, available as config_entry_id. + """ + if self._composite_subentries is not None: + return set(self._composite_subentries) + return {self.config_entry_id} + + @property + def config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belongs to. + + Deprecated compatibility shim: a device now belongs to a single config + entry and subentry, available as config_entry_id and config_subentry_id. + """ + if self._composite_subentries is not None: + return { + entry_id: set(subentries) + for entry_id, subentries in self._composite_subentries.items() + } + return {self.config_entry_id: {self.config_subentry_id}} + + @property + def primary_config_entry(self) -> str: + """Return the primary config entry of this device. + + Deprecated compatibility shim: a device now belongs to a single config + entry, available as config_entry_id, which is its primary config entry. + + For a restored composite device (synthesized on the fly by async_get for a + pre-migration composite device id), this returns the composite's former + primary_config_entry, which is recorded on the split devices during migration as + composite_primary_config_entry. + """ + return self.config_entry_id + @property def disabled(self) -> bool: """Return if entry is disabled.""" @@ -407,11 +504,16 @@ class DeviceEntry: return { "area_id": self.area_id, "configuration_url": self.configuration_url, + # config_entries and config_entries_subentries are deprecated and kept for + # backwards compatibility, they can be removed in HA Core 2027.8. They use the + # compatibility properties so a restored composite reports its merged entries. "config_entries": list(self.config_entries), "config_entries_subentries": { entry_id: list(subentries) for entry_id, subentries in self.config_entries_subentries.items() }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "connections": list(self.connections), "created_at": self.created_at.timestamp(), "disabled_by": self.disabled_by, @@ -455,15 +557,8 @@ class DeviceEntry: json_bytes( { "area_id": self.area_id, - # The config_entries list can be removed from the storage - # representation in HA Core 2026.2 - "config_entries": list(self.config_entries), - "config_entries_subentries": { - entry_id: list(subentries) - for entry_id, subentries in ( - self.config_entries_subentries.items() - ) - }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "configuration_url": self.configuration_url, "connections": list(self.connections), "created_at": self.created_at, @@ -473,12 +568,18 @@ class DeviceEntry: "id": self.id, "identifiers": list(self.identifiers), "labels": list(self.labels), + "composite_device_id": self.composite_device_id, + "composite_primary_config_entry": ( + self.composite_primary_config_entry + ), + "split_at": self.split_at, "manufacturer": self.manufacturer, "model": self.model, "model_id": self.model_id, "modified_at": self.modified_at, "name_by_user": self.name_by_user, "name": self.name, + "has_composite_identifiers": (self.has_composite_identifiers), "primary_config_entry": self.primary_config_entry, "serial_number": self.serial_number, "sw_version": self.sw_version, @@ -496,13 +597,32 @@ class DeviceEntry: return self._suggested_area +# async_update_device arguments that redefine which identifiers/connections a device is +# keyed by, or move it to another config entry. They are ambiguous on a synthesized +# composite (there is no single underlying device to retarget), so the composite shim +# drops them with a warning instead of fanning them out. serial_number is intentionally +# NOT here: it describes the physical device and is consistent across a composite's +# splits, so it fans out like sw_version. Can be removed in HA Core 2027.8. +_COMPOSITE_IGNORED_UPDATE_ARGS = ( + "merge_connections", + "merge_identifiers", + "new_config_entry_id", + "new_config_subentry_id", + "new_connections", + "new_identifiers", +) + + @attr.s(frozen=True, slots=True) class DeletedDeviceEntry: """Deleted Device Registry Entry.""" + # config_entry_id is None for orphaned deleted devices, i.e. devices whose owning + # config entry has been removed + config_entry_id: str | None = attr.ib() + config_subentry_id: str | None = attr.ib() + area_id: str | None = attr.ib() - config_entries: set[str] = attr.ib() - config_entries_subentries: dict[str, set[str | None]] = attr.ib() connections: set[tuple[str, str]] = attr.ib( validator=_normalize_connections_validator ) @@ -514,8 +634,30 @@ class DeletedDeviceEntry: modified_at: datetime = attr.ib() name_by_user: str | None = attr.ib() orphaned_timestamp: float | None = attr.ib() + # Domain of the config entry that owns (or owned) this device, recorded when the + # device is deleted so a re-added config entry only restores an orphan from the same + # integration. None for legacy stores. + domain: str | None = attr.ib(default=None) _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) + @property + def config_entries(self) -> set[str]: + """Return the config entries this device belonged to. + + Deprecated compatibility shim; empty for orphaned deleted devices. + """ + return {self.config_entry_id} if self.config_entry_id is not None else set() + + @property + def config_entries_subentries(self) -> dict[str, set[str | None]]: + """Return the config subentries this device belonged to. + + Deprecated compatibility shim; empty for orphaned deleted devices. + """ + if self.config_entry_id is None: + return {} + return {self.config_entry_id: {self.config_subentry_id}} + def to_device_entry( self, config_entry: ConfigEntry, @@ -537,9 +679,9 @@ class DeletedDeviceEntry: disabled_by = disabled_by if disabled_by is not UNDEFINED else None return DeviceEntry( area_id=self.area_id, + config_entry_id=config_entry.entry_id, + config_subentry_id=config_subentry_id, # type ignores: likely https://github.com/python/mypy/issues/8625 - 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=disabled_by, @@ -556,15 +698,8 @@ class DeletedDeviceEntry: json_bytes( { "area_id": self.area_id, - # The config_entries list can be removed from the storage - # representation in HA Core 2026.2 - "config_entries": list(self.config_entries), - "config_entries_subentries": { - entry_id: list(subentries) - for entry_id, subentries in ( - self.config_entries_subentries.items() - ) - }, + "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "connections": list(self.connections), "created_at": self.created_at, "disabled_by": self.disabled_by @@ -577,11 +712,23 @@ class DeletedDeviceEntry: "modified_at": self.modified_at, "name_by_user": self.name_by_user, "orphaned_timestamp": self.orphaned_timestamp, + "domain": self.domain, } ) ) +def _copy_if_exists(source: str, destination: str) -> bool: + """Copy source to destination when source exists (runs in the executor). + + Returns whether the file was copied. + """ + if not os.path.isfile(source): + return False + shutil.copyfile(source, destination) + return True + + class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" @@ -593,10 +740,12 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): old_data: dict[str, list[dict[str, Any]]], ) -> dict[str, Any]: """Migrate to the new version.""" - # Support for a future major version bump to 2 added in HA Core 2025.2. - # Major versions 1 and 2 will be the same, except that version 2 will no - # longer store a list of config_entries. + # Note: There's no version 2, it was planned and supported by previous versions + # of the migrator which treated version 2 like version 1. if old_major_version < 3: + # Copy the store before the version 3 migrator rewrites every device, so a + # user can recover the pre-migration registry if the migration misbehaves. + await self._async_backup_store() if old_minor_version < 2: # Version 1.2 implements migration and freezes the available keys, # populate keys which were introduced before version 1.2 @@ -677,80 +826,277 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): # of version 1.10 for device in old_data["deleted_devices"]: device["disabled_by_undefined"] = old_minor_version < 10 + # Version 3 restricts a device to a single config entry and subentry, + # introduced in 2026.8. Composite devices which belonged to several + # config entries (or several subentries of one entry) are split into one + # device per (config entry, subentry). Each split device keeps a copy of + # the identifiers and connections and a reference (composite_device_id) to the original + # composite device id, so that actions targeting the old id still reach + # all split devices. Entities are moved to the matching split device when + # the registries are loaded. + migrated_at = utcnow().isoformat() + devices: list[dict[str, Any]] = [] + # Ids of active devices dropped for lacking a config entry; a retained + # child's via_device_id pointing at one is detached below. + dropped_device_ids: set[str] = set() + # old composite id -> {config entry id -> new split id}, to rewrite + # via_device_id links pointing at a split parent + composite_splits: dict[str, dict[str, str]] = {} + # Active splits whose copied disabled_by must be reconciled against their + # single config entry once the config entries are loaded + migrated_active_splits: list[dict[str, Any]] = [] + for device in old_data["devices"]: + # One target per config entry. config_entries_subentries was a set, so + # the old model allowed a device in several subentries of one config + # entry, but the single-owner model keeps one. Multi-subentry devices + # created by core integrations all come from broken subentry migrators + # (which left a device in both None and its real subentry), so prefer + # a real subentry over the main entry (None). Collapsing rather than + # splitting avoids duplicate devices which, sharing identifiers and + # connections within one config entry, would collide in the + # per-config-entry identifier/connection index. + pairs = [ + ( + config_entry_id, + next((s for s in subentry_ids if s is not None), None), + ) + for config_entry_id, subentry_ids in device[ + "config_entries_subentries" + ].items() + ] + if not pairs: + # Drop devices that have no config entry / subentry pairs + dropped_device_ids.add(device["id"]) + continue + if len(pairs) == 1: + config_entry_id, subentry_id = pairs[0] + device["config_entry_id"] = config_entry_id + device["config_subentry_id"] = subentry_id + device["composite_device_id"] = None + device["composite_primary_config_entry"] = None + device["split_at"] = None + device["has_composite_identifiers"] = False + devices.append(device) + continue + old_id = device["id"] + composite_primary = device.get("primary_config_entry") + for config_entry_id, subentry_id in pairs: + split = copy.deepcopy(device) + split["id"] = uuid_util.random_uuid_hex() + split["config_entry_id"] = config_entry_id + split["config_subentry_id"] = subentry_id + split["primary_config_entry"] = config_entry_id + split["composite_device_id"] = old_id + split["composite_primary_config_entry"] = composite_primary + split["split_at"] = migrated_at + split["has_composite_identifiers"] = True + devices.append(split) + migrated_active_splits.append(split) + composite_splits.setdefault(old_id, {})[config_entry_id] = split[ + "id" + ] + # Rewrite via_device_id links that pointed at a now-split composite parent + # to a live split: the parent's split in the child's own config entry when + # there is one, otherwise any of the parent's splits, so the link never + # dangles on the removed composite id. A link to a retained unsplit parent is + # left unchanged; a link to a dropped parent is detached below. + for device in devices: + if ( + splits := composite_splits.get(device["via_device_id"]) + ) is not None: + device["via_device_id"] = splits.get( + device["config_entry_id"], next(iter(splits.values())) + ) + elif device["via_device_id"] in dropped_device_ids: + # The parent was dropped (no config entries); detach the link as + # async_remove_device would, so it does not dangle on a removed id. + device["via_device_id"] = None + old_data["devices"] = devices + # A split inherited the composite's disabled_by, which may not match its + # single config entry (e.g. a split owned by an enabled entry must not stay + # CONFIG_ENTRY disabled). Config entries load concurrently, so wait for them + # and reconcile each split against its own entry. + if migrated_active_splits: + await self.hass.config_entries.async_wait_initialized() + for split in migrated_active_splits: + config_entry = self.hass.config_entries.async_get_entry( + split["config_entry_id"] + ) + if config_entry is not None: + _migrate_device_disabled_by( + split, config_entry.disabled_by is not None + ) + deleted_devices: list[dict[str, Any]] = [] + for device in old_data["deleted_devices"]: + # One target per config entry. config_entries_subentries was a set, so + # the old model allowed a device in several subentries of one config + # entry, but the single-owner model keeps one. Multi-subentry devices + # created by core integrations all come from broken subentry migrators + # (which left a device in both None and its real subentry), so prefer + # a real subentry over the main entry (None). Collapsing rather than + # splitting avoids duplicate devices which, sharing identifiers and + # connections within one config entry, would collide in the + # per-config-entry identifier/connection index. + pairs = [ + ( + config_entry_id, + next((s for s in subentry_ids if s is not None), None), + ) + for config_entry_id, subentry_ids in device[ + "config_entries_subentries" + ].items() + ] + if len(pairs) <= 1: + # Unlike active devices, config_entry_id=None is a valid + # (orphaned) state for a deleted device, so a deleted device with + # no config entries is kept rather than dropped. + config_entry_id, subentry_id = pairs[0] if pairs else (None, None) + device["config_entry_id"] = config_entry_id + device["config_subentry_id"] = subentry_id + device["domain"] = None + deleted_devices.append(device) + continue + # A deleted device that belonged to several config entries or subentries + # is split like an active one - each split keeps a copy of the + # identifiers/connections so every config entry can still restore its + # share when a matching device is re-registered. + for config_entry_id, subentry_id in pairs: + split = copy.deepcopy(device) + split["id"] = uuid_util.random_uuid_hex() + split["config_entry_id"] = config_entry_id + split["config_subentry_id"] = subentry_id + split["domain"] = None + deleted_devices.append(split) + old_data["deleted_devices"] = deleted_devices + # config_entries and config_entries_subentries are deprecated; v3 stores only + # the singular config_entry_id / config_subentry_id (single-entry devices kept + # the old keys, splits copied them via deepcopy). + for migrated in (*devices, *deleted_devices): + migrated.pop("config_entries", None) + migrated.pop("config_entries_subentries", None) - if old_major_version > 2: + if old_major_version > 3: raise NotImplementedError return old_data + async def _async_backup_store(self) -> None: + """Copy the store file to a timestamped backup before migrating.""" + source = self.path + backup = f"{source}.{utcnow().strftime('%Y%m%d_%H%M%S')}.migration_backup" + try: + copied = await self.hass.async_add_executor_job( + _copy_if_exists, source, backup + ) + except OSError as err: + _LOGGER.warning("Could not back up %s before migration: %s", source, err) + else: + if copied: + _LOGGER.info("Backed up %s to %s before migration", source, backup) + class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( BaseRegistryItems[_EntryTypeT] ): """Container for device registry items, maps device id -> entry. - Maintains two additional indexes: - - (connection_type, connection identifier) -> entry - - (DOMAIN, identifier) -> entry + Maintains two additional indexes. An identifier or connection can be shared by + several devices, each belonging to a different config entry, so each index maps a + connection or identifier to the devices that have it, keyed by config entry id: + - (connection_type, connection identifier) -> {config_entry_id: entry} + - (DOMAIN, identifier) -> {config_entry_id: entry} """ def __init__(self) -> None: """Initialize the container.""" super().__init__() - self._connections: dict[tuple[str, str], _EntryTypeT] = {} - self._identifiers: dict[tuple[str, str], _EntryTypeT] = {} + self._connections: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} + self._identifiers: dict[tuple[str, str], dict[str | None, _EntryTypeT]] = {} @override def _index_entry(self, key: str, entry: _EntryTypeT) -> None: """Index an entry.""" + config_entry_id = entry.config_entry_id for connection in entry.connections: - self._connections[connection] = entry + self._connections.setdefault(connection, {})[config_entry_id] = entry for identifier in entry.identifiers: - self._identifiers[identifier] = entry + self._identifiers.setdefault(identifier, {})[config_entry_id] = entry @override def _unindex_entry( self, key: str, replacement_entry: _EntryTypeT | None = None ) -> None: - """Unindex an entry.""" + """Unindex an entry. + + Guards against collisions, the code below can be simplified once + collisions are not longer allowed, refer to commit history in PR + 175785. + """ old_entry = self.data[key] + config_entry_id = old_entry.config_entry_id for connection in old_entry.connections: - if connection in self._connections: - del self._connections[connection] + by_config_entry = self._connections.get(connection) + if by_config_entry is not None and ( + by_config_entry.get(config_entry_id) is old_entry + ): + del by_config_entry[config_entry_id] + if not by_config_entry: + del self._connections[connection] for identifier in old_entry.identifiers: - if identifier in self._identifiers: - del self._identifiers[identifier] + by_config_entry = self._identifiers.get(identifier) + if by_config_entry is not None and ( + by_config_entry.get(config_entry_id) is old_entry + ): + del by_config_entry[config_entry_id] + if not by_config_entry: + del self._identifiers[identifier] def get_entry( self, identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, + *, + config_entry_id: str | None | UndefinedType = UNDEFINED, ) -> _EntryTypeT | None: - """Get entry from identifiers or connections.""" + """Get the first entry matching identifiers or connections. + + If config_entry_id is given, only an entry belonging to that config entry is + returned. Otherwise the first matching entry from any config entry is returned. + """ if identifiers: for identifier in identifiers: - if identifier in self._identifiers: - return self._identifiers[identifier] + if (by_config_entry := self._identifiers.get(identifier)) is not None: + if config_entry_id is UNDEFINED: + return next(iter(by_config_entry.values())) + if config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] if not connections: return None for connection in _normalize_connections(connections): - if connection in self._connections: - return self._connections[connection] + if (by_config_entry := self._connections.get(connection)) is not None: + if config_entry_id is UNDEFINED: + return next(iter(by_config_entry.values())) + if config_entry_id in by_config_entry: + return by_config_entry[config_entry_id] return None def get_entries( self, - identifiers: set[tuple[str, str]] | None, - connections: set[tuple[str, str]] | None, - ) -> Iterable[_EntryTypeT]: - """Get entries from identifiers or connections.""" + identifiers: AbstractSet[tuple[str, str]] | None = None, + connections: AbstractSet[tuple[str, str]] | None = None, + ) -> list[_EntryTypeT]: + """Get all entries matching identifiers or connections, across config entries.""" + entries: dict[str, _EntryTypeT] = {} if identifiers: for identifier in identifiers: - if identifier in self._identifiers: - yield self._identifiers[identifier] + if (by_config_entry := self._identifiers.get(identifier)) is not None: + for entry in by_config_entry.values(): + entries[entry.id] = entry if connections: for connection in _normalize_connections(connections): - if connection in self._connections: - yield self._connections[connection] + if (by_config_entry := self._connections.get(connection)) is not None: + for entry in by_config_entry.values(): + entries[entry.id] = entry + return list(entries.values()) class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): @@ -759,16 +1105,18 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): def __init__(self) -> None: """Initialize the container. - Maintains three additional indexes: + Maintains four additional indexes: - area_id -> dict[key, True] - config_entry_id -> dict[key, True] - label -> dict[key, True] + - composite_device_id -> dict[key, True] """ super().__init__() self._area_id_index: RegistryIndexType = defaultdict(dict) self._config_entry_id_index: RegistryIndexType = defaultdict(dict) self._labels_index: RegistryIndexType = defaultdict(dict) + self._composite_device_id_index: RegistryIndexType = defaultdict(dict) @override def _index_entry(self, key: str, entry: DeviceEntry) -> None: @@ -778,8 +1126,9 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): self._area_id_index[area_id][key] = True for label in entry.labels: self._labels_index[label][key] = True - for config_entry_id in entry.config_entries: - self._config_entry_id_index[config_entry_id][key] = True + self._config_entry_id_index[entry.config_entry_id][key] = True + if entry.composite_device_id is not None: + self._composite_device_id_index[entry.composite_device_id][key] = True @override def _unindex_entry( @@ -792,8 +1141,13 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) - for config_entry_id in entry.config_entries: - self._unindex_entry_value(key, config_entry_id, self._config_entry_id_index) + self._unindex_entry_value( + key, entry.config_entry_id, self._config_entry_id_index + ) + if entry.composite_device_id is not None: + self._unindex_entry_value( + key, entry.composite_device_id, self._composite_device_id_index + ) super()._unindex_entry(key, replacement_entry) def get_devices_for_area_id(self, area_id: str) -> list[DeviceEntry]: @@ -815,12 +1169,98 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): data[key] for key in self._config_entry_id_index.get(config_entry_id, ()) ] + def get_devices_for_composite_device_id( + self, composite_device_id: str + ) -> list[DeviceEntry]: + """Get the devices a pre-migration composite device was split into.""" + data = self.data + return [ + data[key] + for key in self._composite_device_id_index.get(composite_device_id, ()) + ] + + +class DeletedDeviceRegistryItems(DeviceRegistryItems[DeletedDeviceEntry]): + """Container for deleted device registry entries. + + A deleted device that still belongs to a config entry is indexed by config entry id in + the base class, like an active device. An orphaned deleted device (its config entry + removed) has no config entry id and would collide with every other orphan in the base + config_entry_id=None slot, so orphans are kept out of the base index and tracked in a + separate index keyed by device id, which is unique so orphans never shadow each other. + Orphans are matched on restore by get_orphaned_entry. + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._orphaned_connections: dict[ + tuple[str, str], dict[str, DeletedDeviceEntry] + ] = {} + self._orphaned_identifiers: dict[ + tuple[str, str], dict[str, DeletedDeviceEntry] + ] = {} + + @override + def _index_entry(self, key: str, entry: DeletedDeviceEntry) -> None: + """Index an entry, keeping orphans in the separate id-keyed index.""" + if entry.config_entry_id is not None: + super()._index_entry(key, entry) + return + for connection in entry.connections: + self._orphaned_connections.setdefault(connection, {})[entry.id] = entry + for identifier in entry.identifiers: + self._orphaned_identifiers.setdefault(identifier, {})[entry.id] = entry + + @override + def _unindex_entry( + self, key: str, replacement_entry: DeletedDeviceEntry | None = None + ) -> None: + """Unindex an entry from the base or the orphan index.""" + entry = self.data[key] + if entry.config_entry_id is not None: + super()._unindex_entry(key, replacement_entry) + return + for connection in entry.connections: + if connection in self._orphaned_connections: + del self._orphaned_connections[connection][entry.id] + if not self._orphaned_connections[connection]: + del self._orphaned_connections[connection] + for identifier in entry.identifiers: + if identifier in self._orphaned_identifiers: + del self._orphaned_identifiers[identifier][entry.id] + if not self._orphaned_identifiers[identifier]: + del self._orphaned_identifiers[identifier] + + def get_orphaned_entry( + self, + identifiers: set[tuple[str, str]] | None, + connections: set[tuple[str, str]] | None, + domain: str, + ) -> DeletedDeviceEntry | None: + """Return an orphan of the given domain to restore. + + Orphans are matched on their recorded domain so a chance identifier or connection + collision doesn't restore another integration's device. A domain-less orphan + (carried over by the migration with no recoverable domain) is left for the + periodic purge rather than restored. + """ + orphans: dict[str, DeletedDeviceEntry] = {} + for identifier in identifiers or (): + orphans.update(self._orphaned_identifiers.get(identifier, {})) + for connection in _normalize_connections(connections or set()): + orphans.update(self._orphaned_connections.get(connection, {})) + for entry in orphans.values(): + if entry.domain == domain: + return entry + return None + class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): """Class to hold a registry of devices.""" devices: ActiveDeviceRegistryItems - deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] + deleted_devices: DeletedDeviceRegistryItems _device_data: dict[str, DeviceEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -842,8 +1282,53 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): We retrieve the DeviceEntry from the underlying dict to avoid the overhead of the UserDict __getitem__. + + For a pre-migration composite device id, a read-only composite device + merged from the split devices is returned, so integration code that resolves a + device by id (e.g. in a service handler) keeps working. The composite is + synthesized on demand and never stored, so it stays invisible to enumeration, + identifier search and the frontend device list. """ - return self._device_data.get(device_id) + if (device := self._device_data.get(device_id)) is not None: + return device + if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + return self._restore_composite_device(device_id, split_devices) + return None + + @callback + def _restore_composite_device( + self, device_id: str, split_devices: list[DeviceEntry] + ) -> DeviceEntry: + """Synthesize a read-only composite device from its split devices.""" + composite_subentries: dict[str, set[str | None]] = {} + identifiers: set[tuple[str, str]] = set() + connections: set[tuple[str, str]] = set() + for split_device in split_devices: + composite_subentries.setdefault(split_device.config_entry_id, set()).add( + split_device.config_subentry_id + ) + identifiers |= split_device.identifiers + connections |= split_device.connections + # Functional identity (identifiers, connections, serial_number) is consistent + # across splits of the same physical device. Use the split owning the composite's + # former primary config entry as the base, so config_entry_id - and thus + # primary_config_entry - reports the composite's former primary. + primary_config_entry = split_devices[0].composite_primary_config_entry + base = next( + ( + split_device + for split_device in split_devices + if split_device.config_entry_id == primary_config_entry + ), + split_devices[0], + ) + return attr.evolve( + base, + composite_subentries=composite_subentries, + connections=connections, # type: ignore[arg-type] + id=device_id, + identifiers=identifiers, # type: ignore[arg-type] + ) @callback def async_get_device( @@ -851,8 +1336,100 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, ) -> DeviceEntry | None: - """Check if device is registered.""" - return self.devices.get_entry(identifiers, connections) + """Check if a device is registered. + + Identifiers and connections are unique per config entry. If several config + entries share the looked-up identifier or connection, the match is resolved to a + single device when possible - preferring the device whose config entry domain + matches the looked-up identifier. If the remaining matches are the splits of one + pre-migration composite device, a read-only composite spanning them is returned + (async_update_device and async_remove_device fan it out to the underlying + devices). Otherwise, for independent devices sharing an identifier or connection, + one owned by the calling integration is preferred, falling back to the first + match. + """ + matches = self._async_matching_devices(identifiers, connections) + if len(matches) <= 1: + return matches[0] if matches else None + # If the matches are the splits of one pre-migration composite device, return a + # read-only composite over them, reusing the composite's id so stored references + # (an automation, a fired event, or an entity holding the old device id) keep + # resolving to it as before the split. + composite_device_ids = {match.composite_device_id for match in matches} + if ( + len(composite_device_ids) == 1 + and (pre_migration_id := next(iter(composite_device_ids))) is not None + ): + return self._restore_composite_device(pre_migration_id, matches) + # Otherwise they are independent devices sharing an identifier or connection. + # Prefer one owned by the calling integration so the caller resolves to its own + # device rather than an insertion-order-dependent one; fall back to the first. + if (domain := _current_integration_domain()) is not None and ( + device := self._first_device_in_domain(matches, domain) + ) is not None: + return device + return matches[0] + + def _first_device_in_domain( + self, devices: Iterable[DeviceEntry], domain: str + ) -> DeviceEntry | None: + """Return the first device whose config entry belongs to domain.""" + for device in devices: + entry = self.hass.config_entries.async_get_entry(device.config_entry_id) + if entry is not None and entry.domain == domain: + return device + return None + + @callback + def _async_matching_devices( + self, + identifiers: AbstractSet[tuple[str, str]] | None, + connections: AbstractSet[tuple[str, str]] | None, + ) -> list[DeviceEntry]: + """Return devices matching the lookup, narrowed by identifier-domain priority.""" + matches = self.devices.get_entries(identifiers, connections) + if len(matches) > 1 and identifiers: + domains = {identifier[0] for identifier in identifiers} + preferred = [ + device + for device in matches + if ( + entry := self.hass.config_entries.async_get_entry( + device.config_entry_id + ) + ) + and entry.domain in domains + ] + if preferred: + return preferred + return matches + + @callback + def _async_device_ids_for_composite_device_id( + self, device_id: str + ) -> list[str] | None: + """Return the underlying real device ids if device_id is a composite.""" + if device_id in self.devices: + return None + if split_devices := self.devices.get_devices_for_composite_device_id(device_id): + return [split_device.id for split_device in split_devices] + return None + + @callback + def async_get_devices_for_composite_device_id( + self, composite_device_id: str + ) -> list[DeviceEntry]: + """Return the devices a composite device id represents. + + A composite device id is a pre-migration composite id - a device that belonged to + several config entries, split into one device per config entry, each keeping the + original id as composite_device_id. The underlying live devices are returned so + that actions and entity lookups targeting the composite id still reach all of + them; unmodified integrations keep the pre-rewrite behaviour, where a shared + identifier/connection resolved to a single multi-config-entry device. Returns an + empty list for a device id which is not a composite device id. + """ + return self.devices.get_devices_for_composite_device_id(composite_device_id) def _substitute_name_placeholders( self, @@ -908,7 +1485,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): sw_version: str | None | UndefinedType = UNDEFINED, translation_key: str | None = None, translation_placeholders: Mapping[str, str] | None = None, + # via_device is deprecated and will be removed in HA Core 2027.8, use + # via_device_id instead via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, + via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" default_manufacturer = _validate_str( @@ -931,6 +1511,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): f"Can't link device to unknown config entry {config_entry_id}" ) + # Validate before mutating the registry below. `via_device=None` (an explicit + # "no via device") alongside a via_device_id is contradictory, so reject it too. + if via_device is not UNDEFINED and via_device_id is not UNDEFINED: + raise HomeAssistantError( + "Passing both `via_device` and `via_device_id` is not allowed; " + "`via_device` is deprecated, pass `via_device_id` only" + ) + if ( + config_subentry_id is not UNDEFINED + and config_subentry_id is not None + and config_subentry_id not in config_entry.subentries + ): + raise HomeAssistantError( + f"Config entry {config_entry_id} has no subentry {config_subentry_id}" + ) + if translation_key: full_translation_key = ( f"component.{config_entry.domain}.device.{translation_key}.name" @@ -958,6 +1554,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("name", name), ("suggested_area", suggested_area), ("via_device", via_device), + ("via_device_id", via_device_id), *validated_fields.items(), ) if val is not UNDEFINED @@ -974,7 +1571,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): connections = _normalize_connections(connections) device = self.devices.get_entry( - identifiers=identifiers, connections=connections + connections=connections, + identifiers=identifiers, + config_entry_id=config_entry_id, ) is_new = False @@ -982,7 +1581,20 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if device is None: is_new = True - deleted_device = self.deleted_devices.get_entry(identifiers, connections) + deleted_device = self.deleted_devices.get_entry( + connections=connections, + identifiers=identifiers, + config_entry_id=config_entry_id, + ) + if deleted_device is None: + # Fall back to an orphan (its owning config entry was removed) + # so re-adding an integration restores the device id, area, labels and name + # rather than create a fresh device. Matching on the recorded domain keeps + # a chance identifier/connection collision from restoring another + # integration's device. + deleted_device = self.deleted_devices.get_orphaned_entry( + identifiers, connections, config_entry.domain + ) if deleted_device is None: area_id: str | None = None if ( @@ -995,7 +1607,16 @@ 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(area_id=area_id) + device = DeviceEntry( + area_id=area_id, + config_entry_id=config_entry_id, + # Interpret not specifying a subentry as None + config_subentry_id=( + config_subentry_id + if config_subentry_id is not UNDEFINED + else None + ), + ) else: self.deleted_devices.pop(deleted_device.id) @@ -1024,7 +1645,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): name = default_name if via_device is not None and via_device is not UNDEFINED: - if (via := self.devices.get_entry(identifiers={via_device})) is None: + # Resolve the deprecated via_device to a device id. The identifier is not + # unique across config entries, so prefer a via device in the same config + # entry, then one from the same integration (domain), falling back to any + # config entry (a via device may legitimately belong to a different config + # entry). This ambiguity is why via_device is deprecated. + via = ( + self.devices.get_entry( + identifiers={via_device}, config_entry_id=config_entry_id + ) + or self._first_device_in_domain( + self.devices.get_entries(identifiers={via_device}), + config_entry.domain, + ) + or self.devices.get_entry(identifiers={via_device}) + ) + if via is None: report_usage( "calls `device_registry.async_get_or_create` referencing a " f"non existing `via_device` {via_device}, " @@ -1032,25 +1668,46 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): core_behavior=ReportBehavior.LOG, breaks_in_ha_version="2025.12.0", ) + via_device_id = via.id if via else UNDEFINED + elif via_device is None: + # An explicit `via_device=None` means "no via device" (a via_device_id + # alongside it is rejected above). + via_device_id = None - via_device_id: str | UndefinedType = via.id if via else UNDEFINED + # On the owning integration's first re-registration of a device created by + # splitting a pre-migration composite device, replace the identifiers and + # connections copied from the composite with the ones the integration provides, + # instead of merging. This block and the has_composite_identifiers flag + # can be removed in HA Core 2027.8. + identifiers_connections: dict[str, Any] + has_composite_identifiers: bool | UndefinedType = UNDEFINED + if not is_new and device.has_composite_identifiers: + identifiers_connections = { + "new_connections": connections, + "new_identifiers": identifiers, + } + has_composite_identifiers = False else: - via_device_id = UNDEFINED + identifiers_connections = { + "merge_connections": connections or UNDEFINED, + "merge_identifiers": identifiers or UNDEFINED, + } device = self._async_update_device( device.id, allow_collisions=True, - add_config_entry_id=config_entry_id, - add_config_subentry_id=config_subentry_id, - device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, is_new=is_new, - merge_connections=connections or UNDEFINED, - merge_identifiers=identifiers or UNDEFINED, name=name, + has_composite_identifiers=has_composite_identifiers, + # Move the device if the integration re-registers it under a different + # subentry; UNDEFINED leaves the subentry unchanged. Also validates an + # explicitly provided subentry for new devices. + new_config_subentry_id=config_subentry_id, suggested_area=suggested_area, via_device_id=via_device_id, + **identifiers_connections, **validated_fields, ) @@ -1071,7 +1728,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): allow_collisions: bool = False, 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, @@ -1084,6 +1740,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model_id: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + # has_composite_identifiers can be removed in HA Core 2027.8 + has_composite_identifiers: bool | UndefinedType = UNDEFINED, + new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: 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, @@ -1106,9 +1766,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs - config_entries = old.config_entries - config_entries_subentries = old.config_entries_subentries - if add_config_entry_id is not UNDEFINED: if ( add_config_entry := self.hass.config_entries.async_get_entry( @@ -1143,6 +1800,26 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Can't remove config subentry without specifying config entry" ) + if ( + new_config_entry_id is not UNDEFINED + and self.hass.config_entries.async_get_entry(new_config_entry_id) is None + ): + raise HomeAssistantError( + f"Can't move device to unknown config entry {new_config_entry_id}" + ) + + if ( + new_config_entry_id is not UNDEFINED + or new_config_subentry_id is not UNDEFINED + ) and ( + add_config_entry_id is not UNDEFINED + or remove_config_entry_id is not UNDEFINED + ): + raise HomeAssistantError( + "Can't combine new_config_entry_id or new_config_subentry_id with " + "add_config_entry_id or remove_config_entry_id" + ) + if not new_connections and not new_identifiers: raise HomeAssistantError( "A device must have at least one of identifiers or connections" @@ -1158,109 +1835,133 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): "Cannot define both merge_identifiers and new_identifiers" ) - if add_config_entry_id is not UNDEFINED: - if add_config_subentry_id is UNDEFINED: - # Interpret not specifying a subentry as None (the main entry) - add_config_subentry_id = None - - primary_entry_id = old.primary_config_entry - if ( - device_info_type == "primary" - and add_config_entry_id != primary_entry_id - ): - if ( - primary_entry_id is None - or not ( - primary_entry := self.hass.config_entries.async_get_entry( - primary_entry_id - ) + # A device belongs to exactly one config entry and subentry: + # - add_config_entry_id (with an optional add_config_subentry_id) records a + # transient pending move to that config entry and subentry; on its own it does + # not move the device. Integrations move a device by adding the new config + # entry and then removing the current one, often in separate calls; the removal + # of the current config entry performs the pending move. + # - remove_config_entry_id on the owning entry performs a pending move if there + # is one, otherwise it removes the device, since it has no other config entry. + # - new_config_entry_id / new_config_subentry_id move the device immediately. + target_config_entry_id: str | UndefinedType = UNDEFINED + target_config_subentry_id: str | None | UndefinedType = UNDEFINED + pending_move: _PendingMove | None | UndefinedType = UNDEFINED + if new_config_entry_id is not UNDEFINED: + target_config_entry_id = new_config_entry_id + target_config_subentry_id = ( + new_config_subentry_id + if new_config_subentry_id is not UNDEFINED + else None + ) + # An immediate move to a new config entry supersedes a deferred move from an + # earlier add_config_entry_id; clear it so a later removal of the new owner + # deletes the device instead of performing the stale move. + pending_move = None + elif new_config_subentry_id is not UNDEFINED: + target_config_subentry_id = new_config_subentry_id + else: + if add_config_entry_id is not UNDEFINED: + # Adding the config entry (and subentry) the device already belongs to is a + # no-op; recording it as a pending move would make a later removal of that + # sole owner move the device to itself instead of deleting it. + already_owner = add_config_entry_id == old.config_entry_id and ( + add_config_subentry_id is UNDEFINED + or add_config_subentry_id == old.config_subentry_id + ) + if not already_owner: + pending_move = _PendingMove( + add_config_entry_id, + add_config_subentry_id + if add_config_subentry_id is not UNDEFINED + else None, + _current_integration_domain(), ) - or primary_entry.domain in LOW_PRIO_CONFIG_ENTRY_DOMAINS - ): - new_values["primary_config_entry"] = add_config_entry_id - old_values["primary_config_entry"] = primary_entry_id - - if add_config_entry_id not in old.config_entries: - config_entries = old.config_entries | {add_config_entry_id} - 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 remove_config_entry_id == old.config_entry_id and ( + remove_config_subentry_id is UNDEFINED + or remove_config_subentry_id == old.config_subentry_id + ): + move_from_prior_call = pending_move is UNDEFINED + move_target = ( + pending_move if pending_move is not UNDEFINED else old._pending_move # noqa: SLF001 + ) + # A deferred move armed by an earlier add_config_entry_id only completes + # if the integration now removing the owning entry is the one that armed + # it. A removal from a different integration (e.g. device_tracker + # attaching a shared MAC) is unrelated, so cancel the move and delete the + # device instead of silently transferring it. Origins from core/tests are + # undetermined (None) and never cancel. 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 + move_target is not None + and move_from_prior_call + and move_target.origin_domain is not None + and (current_domain := _current_integration_domain()) is not None + and current_domain != move_target.origin_domain ): - 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] - ): - config_entries_subentries = old.config_entries_subentries | { - add_config_entry_id: old.config_entries_subentries[ - add_config_entry_id - ] - | {add_config_subentry_id} - } - - if ( - remove_config_entry_id is not UNDEFINED - and remove_config_entry_id in config_entries - ): - if remove_config_subentry_id is UNDEFINED: - config_entries_subentries = dict(old.config_entries_subentries) - del config_entries_subentries[remove_config_entry_id] - elif ( - remove_config_subentry_id - in old.config_entries_subentries[remove_config_entry_id] - ): - config_entries_subentries = old.config_entries_subentries | { - remove_config_entry_id: old.config_entries_subentries[ - remove_config_entry_id - ] - - {remove_config_subentry_id} - } - if not config_entries_subentries[remove_config_entry_id]: - del config_entries_subentries[remove_config_entry_id] - - if remove_config_entry_id not in config_entries_subentries: - if config_entries == {remove_config_entry_id}: + move_target = None + if move_target is None: self.async_remove_device(device_id) return None + target_config_entry_id = move_target.config_entry_id + target_config_subentry_id = move_target.config_subentry_id + pending_move = None + # A pre-migration composite's splits share identity, so once one split + # completes the move to the target entry the others must not also move + # there and collide; clear their pending moves. + if old.composite_device_id is not None: + for sibling in self.devices.get_devices_for_composite_device_id( + old.composite_device_id + ): + if ( + sibling.id != device_id + and sibling._pending_move is not None # noqa: SLF001 + ): + self.devices[sibling.id] = attr.evolve( + sibling, pending_move=None + ) - if remove_config_entry_id == old.primary_config_entry: - new_values["primary_config_entry"] = None - old_values["primary_config_entry"] = old.primary_config_entry - - 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 target_config_subentry_id not in (UNDEFINED, None): + resolved_config_entry_id = ( + target_config_entry_id + if target_config_entry_id is not UNDEFINED + else old.config_entry_id + ) + resolved_config_entry = self.hass.config_entries.async_get_entry( + resolved_config_entry_id + ) + if ( + resolved_config_entry is None + or target_config_subentry_id not in resolved_config_entry.subentries + ): + raise HomeAssistantError( + f"Config entry {resolved_config_entry_id} has no" + f" subentry {target_config_subentry_id}" ) - 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 + if ( + target_config_entry_id is not UNDEFINED + and target_config_entry_id != old.config_entry_id + ): + new_values["config_entry_id"] = target_config_entry_id + old_values["config_entry_id"] = old.config_entry_id + if ( + target_config_subentry_id is not UNDEFINED + and target_config_subentry_id != old.config_subentry_id + ): + new_values["config_subentry_id"] = target_config_subentry_id + old_values["config_subentry_id"] = old.config_subentry_id + # pending_move is a transient runtime-only attribute; it is not reported in the + # update event (not added to old_values) and never stored + if pending_move is not UNDEFINED and pending_move != old._pending_move: # noqa: SLF001 + new_values["pending_move"] = pending_move - if config_entries_subentries != old.config_entries_subentries: - new_values["config_entries_subentries"] = config_entries_subentries - old_values["config_entries_subentries"] = old.config_entries_subentries + # Identifiers and connections are unique per config entry, so when the device is + # moved to another config entry they are validated against the new one + effective_config_entry_id = ( + target_config_entry_id + if target_config_entry_id is not UNDEFINED + else old.config_entry_id + ) added_connections: set[tuple[str, str]] | None = None added_identifiers: set[tuple[str, str]] | None = None @@ -1268,6 +1969,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if merge_connections is not UNDEFINED: normalized_connections = self._validate_connections( device_id, + effective_config_entry_id, merge_connections, allow_collisions, ) @@ -1279,7 +1981,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if merge_identifiers is not UNDEFINED: merge_identifiers = self._validate_identifiers( - device_id, merge_identifiers, allow_collisions + device_id, + effective_config_entry_id, + merge_identifiers, + allow_collisions, ) old_identifiers = old.identifiers if not merge_identifiers.issubset(old_identifiers): @@ -1289,16 +1994,52 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if new_connections is not UNDEFINED: added_connections = new_values["connections"] = self._validate_connections( - device_id, new_connections, False + device_id, effective_config_entry_id, new_connections, False ) old_values["connections"] = old.connections if new_identifiers is not UNDEFINED: added_identifiers = new_values["identifiers"] = self._validate_identifiers( - device_id, new_identifiers, False + device_id, effective_config_entry_id, new_identifiers, False ) old_values["identifiers"] = old.identifiers + # On a move to another config entry, validate the identifiers and connections + # retained from the old entry against the new one, so the move can't silently + # overwrite the index slot of a device that already has the same identity there. + # A full new_identifiers / new_connections replacement is validated above; + # merge_* only adds, so the retained old values still need checking here. + if effective_config_entry_id != old.config_entry_id: + if new_identifiers is UNDEFINED: + self._validate_identifiers( + device_id, effective_config_entry_id, old.identifiers, False + ) + if new_connections is UNDEFINED: + self._validate_connections( + device_id, effective_config_entry_id, old.connections, False + ) + + # On a move, reflect the new owning config entry's disabled state (as restoring a + # deleted device does) unless disabled_by was passed explicitly: disable an + # enabled device moved onto a disabled entry, and clear a CONFIG_ENTRY disable + # when moved onto an enabled entry. A USER disable is preserved. + if ( + disabled_by is UNDEFINED + and target_config_entry_id is not UNDEFINED + and target_config_entry_id != old.config_entry_id + and ( + target_entry := self.hass.config_entries.async_get_entry( + target_config_entry_id + ) + ) + is not None + ): + if target_entry.disabled_by: + if old.disabled_by is None: + disabled_by = DeviceEntryDisabler.CONFIG_ENTRY + elif old.disabled_by is DeviceEntryDisabler.CONFIG_ENTRY: + disabled_by = None + for attr_name, value in ( ("area_id", area_id), ("configuration_url", configuration_url), @@ -1311,6 +2052,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ("model_id", model_id), ("name", name), ("name_by_user", name_by_user), + ("has_composite_identifiers", has_composite_identifiers), ("serial_number", serial_number), ("sw_version", sw_version), ("via_device_id", via_device_id), @@ -1336,13 +2078,28 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new = attr.evolve(old, **new_values) self.devices[device_id] = new - # NOTE: Once we solve the broader issue of duplicated devices, we might - # want to revisit it. Instead of simply removing the duplicated deleted device, - # we might want to merge the information from it into the non-deleted device. + # On a move, the device's whole retained identity newly appears in the target + # config entry; added_identifiers/added_connections are empty on a retained- + # identity move, so match the target entry's deleted device by the full identity. + match_identifiers: set[tuple[str, str]] | None + match_connections: set[tuple[str, str]] | None + if effective_config_entry_id != old.config_entry_id: + match_identifiers = new.identifiers + match_connections = new.connections + else: + match_identifiers = added_identifiers + match_connections = added_connections for deleted_device in self.deleted_devices.get_entries( - added_identifiers, added_connections + match_identifiers, match_connections ): - del self.deleted_devices[deleted_device.id] + # get_entries matches across config entries, but identifiers/connections are + # unique per config entry - only remove the deleted device owned by this + # device's config entry, so another entry can still restore its own. + if ( + deleted_device.config_entry_id == effective_config_entry_id + and deleted_device.id in self.deleted_devices + ): + del self.deleted_devices[deleted_device.id] # If its only run time attributes (suggested_area) # that do not get saved we do not want to write @@ -1374,7 +2131,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): 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, @@ -1386,6 +2142,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model_id: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: 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, @@ -1398,11 +2156,57 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): ) -> 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_entry_id + A device belongs to a single config entry and subentry. To move a device to + another config entry or subentry, pass new_config_entry_id and/or + new_config_subentry_id. To remove a device, pass remove_config_entry_id with the + device's config entry. + + :param add_config_entry_id: Deprecated. Combined with remove_config_entry_id it + moves the device; on its own it does nothing. + :param add_config_subentry_id: Deprecated. Combined with remove_config_subentry_id + it moves the device to another subentry; on its own it does nothing. + :param new_config_entry_id: Move the device to this config entry. + :param new_config_subentry_id: Move the device to this subentry. + :param remove_config_entry_id: Remove the device if it is the device's config + entry, unless combined with add_config_entry_id to move the device. + :param remove_config_subentry_id: Remove the device from a specific subentry of + remove_config_entry_id. """ + if ( + underlying_ids := self._async_device_ids_for_composite_device_id(device_id) + ) is not None: + # Fan the update out to each underlying device; keep in sync with the + # update parameters above. + update_args = { + "add_config_entry_id": add_config_entry_id, + "add_config_subentry_id": add_config_subentry_id, + "area_id": area_id, + "configuration_url": configuration_url, + "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_config_entry_id": new_config_entry_id, + "new_config_subentry_id": new_config_subentry_id, + "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, + } + return self._async_update_composite_device( + device_id, underlying_ids, update_args + ) if suggested_area is not UNDEFINED: report_usage( "passes a suggested_area to device_registry.async_update device", @@ -1425,7 +2229,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): add_config_entry_id=add_config_entry_id, add_config_subentry_id=add_config_subentry_id, area_id=area_id, - device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, labels=labels, @@ -1433,6 +2236,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): merge_identifiers=merge_identifiers, name_by_user=name_by_user, name=name, + new_config_entry_id=new_config_entry_id, + new_config_subentry_id=new_config_subentry_id, new_connections=new_connections, new_identifiers=new_identifiers, remove_config_entry_id=remove_config_entry_id, @@ -1446,10 +2251,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): def _validate_connections( self, device_id: str, + config_entry_id: str, connections: set[tuple[str, str]], allow_collisions: bool, ) -> set[tuple[str, str]]: - """Normalize and validate connections, raise on collision with other devices.""" + """Normalize and validate connections, raise on collision with other devices. + + Connections are unique per config entry, so only collisions with other devices + of the same config entry are considered. + """ normalized_connections = _normalize_connections(connections) if allow_collisions: return normalized_connections @@ -1459,7 +2269,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry(connections={connection}) + existing_device := self.devices.get_entry( + connections={connection}, config_entry_id=config_entry_id + ) ) and existing_device.id != device_id: raise DeviceConnectionCollisionError( normalized_connections, existing_device @@ -1471,10 +2283,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): def _validate_identifiers( self, device_id: str, + config_entry_id: str, identifiers: set[tuple[str, str]], allow_collisions: bool, ) -> set[tuple[str, str]]: - """Validate identifiers, raise on collision with other devices.""" + """Validate identifiers, raise on collision with other devices. + + Identifiers are unique per config entry, so only collisions with other devices + of the same config entry are considered. + """ if allow_collisions: return identifiers @@ -1483,21 +2300,70 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # conflict, the index will only see the last one and we will not # be able to tell which one caused the conflict if ( - existing_device := self.devices.get_entry(identifiers={identifier}) + existing_device := self.devices.get_entry( + identifiers={identifier}, config_entry_id=config_entry_id + ) ) and existing_device.id != device_id: raise DeviceIdentifierCollisionError(identifiers, existing_device) return identifiers + @callback + def _async_update_composite_device( + self, + composite_id: str, + underlying_ids: list[str], + update_args: dict[str, Any], + ) -> DeviceEntry | None: + """Fan an async_update_device call on a composite out to its real devices.""" + forward = { + name: value for name, value in update_args.items() if value is not UNDEFINED + } + if ignored := [ + name for name in _COMPOSITE_IGNORED_UPDATE_ARGS if name in forward + ]: + # These rewrite a device's functional identity or move it, which is ambiguous + # across the composite's underlying devices; drop them rather than corrupt or + # collide, and report the offending integration. + report_usage( + f"passed {', '.join(ignored)} to device_registry.async_update_device " + "for a composite device that spans several config entries (returned for " + "an ambiguous async_get_device lookup, or " + "resolved from a stored device id of a pre-migration composite); the " + "argument cannot be applied to the merged device and was ignored - " + "target a single device, e.g. one returned by " + "async_entries_for_config_entry", + core_behavior=ReportBehavior.LOG, + ) + for name in ignored: + del forward[name] + for underlying_id in underlying_ids: + self.async_update_device(underlying_id, **forward) + remaining = [ + self.devices[underlying_id] + for underlying_id in underlying_ids + if underlying_id in self.devices + ] + if not remaining: + return None + return self._restore_composite_device(composite_id, remaining) + @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" + if ( + underlying_ids := self._async_device_ids_for_composite_device_id(device_id) + ) is not None: + for underlying_id in underlying_ids: + self.async_remove_device(underlying_id) + return self.hass.verify_event_loop_thread("device_registry.async_remove_device") device = self.devices.pop(device_id) + config_entry = self.hass.config_entries.async_get_entry(device.config_entry_id) self.deleted_devices[device_id] = DeletedDeviceEntry( area_id=device.area_id, - config_entries=device.config_entries, - config_entries_subentries=device.config_entries_subentries, + config_entry_id=device.config_entry_id, + config_subentry_id=device.config_subentry_id, connections=device.connections, created_at=device.created_at, disabled_by=device.disabled_by, @@ -1507,6 +2373,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): modified_at=utcnow(), name_by_user=device.name_by_user, orphaned_timestamp=None, + domain=config_entry.domain if config_entry is not None else None, ) for other_device in list(self.devices.values()): if other_device.via_device_id == device_id: @@ -1530,19 +2397,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): data = await self._store.async_load() devices = ActiveDeviceRegistryItems() - deleted_devices: DeviceRegistryItems[DeletedDeviceEntry] = DeviceRegistryItems() + deleted_devices = DeletedDeviceRegistryItems() if data is not None: for device in data["devices"]: devices[device["id"]] = DeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries_subentries"]), - config_entries_subentries={ - config_entry_id: set(subentries) - for config_entry_id, subentries in device[ - "config_entries_subentries" - ].items() - }, + config_entry_id=device["config_entry_id"], + config_subentry_id=device["config_subentry_id"], configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={ @@ -1567,13 +2429,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for iden in device["identifiers"] }, labels=set(device["labels"]), + composite_device_id=device["composite_device_id"], + composite_primary_config_entry=device[ + "composite_primary_config_entry" + ], + split_at=( + datetime.fromisoformat(device["split_at"]) + if device["split_at"] + else None + ), manufacturer=device["manufacturer"], model=device["model"], model_id=device["model_id"], modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], name=device["name"], - primary_config_entry=device["primary_config_entry"], + has_composite_identifiers=device["has_composite_identifiers"], serial_number=device["serial_number"], sw_version=device["sw_version"], via_device_id=device["via_device_id"], @@ -1596,13 +2467,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries"]), - config_entries_subentries={ - config_entry_id: set(subentries) - for config_entry_id, subentries in device[ - "config_entries_subentries" - ].items() - }, + config_entry_id=device["config_entry_id"], + config_subentry_id=device["config_subentry_id"], connections={tuple(conn) for conn in device["connections"]}, created_at=datetime.fromisoformat(device["created_at"]), disabled_by=get_optional_enum( @@ -1616,6 +2482,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): modified_at=datetime.fromisoformat(device["modified_at"]), name_by_user=device["name_by_user"], orphaned_timestamp=device["orphaned_timestamp"], + domain=device["domain"], ) self.devices = devices @@ -1645,83 +2512,110 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): } @callback - def async_clear_config_entry(self, config_entry_id: str) -> None: + def _resolve_orphan_domain( + self, config_entry_id: str, domain: str | None + ) -> str | None: + """Return the domain to record on devices orphaned from a config entry.""" + if domain is not None: + return domain + if ( + entry := self.hass.config_entries.async_get_entry(config_entry_id) + ) is not None: + return entry.domain + return None + + @callback + def _async_orphan_deleted_device( + self, deleted_device: DeletedDeviceEntry, domain: str | None, now_time: float + ) -> None: + """Mark a deleted device as orphaned, remembering its former domain.""" + if domain is not None: + # Orphans are indexed by their recorded domain, so two orphans of the + # same domain sharing an identifier or connection would collide. When a + # device from the same integration is orphaned, drop any existing orphan + # it overlaps so the newest one wins deterministically instead of shadowing + # it. + for existing in list(self.deleted_devices.values()): + if ( + existing.config_entry_id is None + and existing.domain == domain + and ( + existing.connections & deleted_device.connections + or existing.identifiers & deleted_device.identifiers + ) + ): + del self.deleted_devices[existing.id] + self.deleted_devices[deleted_device.id] = attr.evolve( + deleted_device, + config_entry_id=None, + config_subentry_id=None, + orphaned_timestamp=now_time, + domain=domain, + ) + self.async_schedule_save() + + @callback + def async_clear_config_entry( + self, config_entry_id: str, domain: str | None = None + ) -> None: """Clear config entry from registry entries.""" + domain = self._resolve_orphan_domain(config_entry_id, domain) 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_remove_device(device.id) + # A split device records the composite's former primary config entry; when that + # config entry is removed, clear the now-dangling reference so a restored + # composite no longer points at a config entry that no longer exists. + for device in list(self.devices.values()): + if device.composite_primary_config_entry == config_entry_id: + self.devices[device.id] = attr.evolve( + device, composite_primary_config_entry=None + ) + self.async_schedule_save() + # A device owned by another config entry may hold a transient pending move + # targeting the entry being removed; clear it so a later completion deletes the + # device instead of moving it onto the removed entry. + for device in list(self.devices.values()): + pending_move = device._pending_move # noqa: SLF001 + if ( + pending_move is not None + and pending_move.config_entry_id == config_entry_id + ): + self.devices[device.id] = attr.evolve(device, pending_move=None) for deleted_device in list(self.deleted_devices.values()): - config_entries = deleted_device.config_entries - if config_entry_id not in config_entries: + if deleted_device.config_entry_id != config_entry_id: continue - if config_entries == {config_entry_id}: - # 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={}, - ) - else: - config_entries = config_entries - {config_entry_id} - config_entries_subentries = dict( - deleted_device.config_entries_subentries - ) - del config_entries_subentries[config_entry_id] - # No need to reindex here since we currently - # do not have a lookup by config entry - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - config_entries=config_entries, - config_entries_subentries=config_entries_subentries, - ) - self.async_schedule_save() + self._async_orphan_deleted_device(deleted_device, domain, now_time) @callback def async_clear_config_subentry( - self, config_entry_id: str, config_subentry_id: str + self, config_entry_id: str, config_subentry_id: str, domain: str | None = None ) -> None: - """Clear config entry from registry entries.""" + """Clear config subentry from registry entries.""" + domain = self._resolve_orphan_domain(config_entry_id, domain) 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, - remove_config_subentry_id=config_subentry_id, - ) - for deleted_device in list(self.deleted_devices.values()): - config_entries = deleted_device.config_entries - config_entries_subentries = deleted_device.config_entries_subentries + if device.config_subentry_id != config_subentry_id: + continue + self.async_remove_device(device.id) + # A device may hold a transient pending move targeting the subentry being removed; + # clear it so a later completion deletes the device instead of validating against + # the removed subentry. + for device in list(self.devices.values()): + pending_move = device._pending_move # noqa: SLF001 if ( - config_entry_id not in config_entries_subentries - or config_subentry_id not in config_entries_subentries[config_entry_id] + pending_move is not None + and pending_move.config_entry_id == config_entry_id + and pending_move.config_subentry_id == config_subentry_id + ): + self.devices[device.id] = attr.evolve(device, pending_move=None) + for deleted_device in list(self.deleted_devices.values()): + if ( + deleted_device.config_entry_id != config_entry_id + or deleted_device.config_subentry_id != config_subentry_id ): continue - if config_entries_subentries == {config_entry_id: {config_subentry_id}}: - # We're removing the last config subentry from the last config - # entry, 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={}, - ) - else: - config_entries_subentries = config_entries_subentries | { - config_entry_id: config_entries_subentries[config_entry_id] - - {config_subentry_id} - } - if not config_entries_subentries[config_entry_id]: - del config_entries_subentries[config_entry_id] - config_entries = config_entries - {config_entry_id} - # No need to reindex here since we currently - # do not have a lookup by config entry - self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, - config_entries=config_entries, - config_entries_subentries=config_entries_subentries, - ) - self.async_schedule_save() + self._async_orphan_deleted_device(deleted_device, domain, now_time) @callback def async_purge_expired_orphaned_devices(self) -> None: @@ -1821,7 +2715,6 @@ def async_config_entry_disabled_by_changed( the config entry is disabled, enable devices in the registry that are associated with a config entry when the config entry is enabled and the devices are marked DeviceEntryDisabler.CONFIG_ENTRY. - Only disable a device if all associated config entries are disabled. """ devices = async_entries_for_config_entry(registry, config_entry.entry_id) @@ -1833,25 +2726,37 @@ def async_config_entry_disabled_by_changed( registry._async_update_device(device.id, disabled_by=None) # noqa: SLF001 return - enabled_config_entries = { - entry.entry_id - for entry in registry.hass.config_entries.async_entries() - if not entry.disabled_by - } - for device in devices: if device.disabled: # Device already disabled, do not overwrite continue - if len(device.config_entries) > 1 and device.config_entries.intersection( - enabled_config_entries - ): - continue registry._async_update_device( # noqa: SLF001 device.id, disabled_by=DeviceEntryDisabler.CONFIG_ENTRY ) +@callback +def _migrate_device_disabled_by( + device: dict[str, Any], config_entry_disabled: bool +) -> None: + """Reconcile a stored device's disabled_by with its config entry's disabled state. + + Reimplements async_config_entry_disabled_by_changed on stored data so the 1.13 + migration can fix a split device that inherited the composite's disabled_by. Kept in + lockstep with that function by test_migrate_device_disabled_by_matches_runtime; can be + removed in HA Core 2027.8. + """ + disabled_by = device["disabled_by"] + if not config_entry_disabled: + # Config entry enabled: drop a config-entry disable, keep a user/integration one + if disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: + device["disabled_by"] = None + return + # Config entry disabled: disable the device unless it is already disabled + if disabled_by is None: + device["disabled_by"] = DeviceEntryDisabler.CONFIG_ENTRY + + @callback def async_cleanup( hass: HomeAssistant, @@ -1864,8 +2769,7 @@ def async_cleanup( references_config_entries = { device.id for device in dev_reg.devices.values() - for config_entry_id in device.config_entries - if config_entry_id in config_entry_ids + if device.config_entry_id in config_entry_ids } # Find all devices that are referenced in the entity registry. @@ -1883,11 +2787,10 @@ def async_cleanup( # Find all referenced config entries that no longer exist # This shouldn't happen but have not been able to track down the bug :( 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( # noqa: SLF001 - device.id, remove_config_entry_id=config_entry_id - ) + if device.config_entry_id not in config_entry_ids: + dev_reg._async_update_device( # noqa: SLF001 + device.id, remove_config_entry_id=device.config_entry_id + ) # Periodic purge of orphaned devices to avoid the registry # growing without bounds when there are lots of deleted devices diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 733482d58e2f..f4c4366d1b5a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -38,7 +38,6 @@ from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey from . import device_registry as dr, entity_registry as er, service, translation -from .deprecation import deprecated_function from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later from .frame import report_usage @@ -1233,77 +1232,6 @@ class EntityPlatform: """Return the platform name (e.g hue).""" return self.platform_data.platform_name - @property - @deprecated_function( - "platform_data.component_translations", - breaks_in_ha_version="2026.8", - ) - def component_translations(self) -> dict[str, str]: - """Return the component translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.component_translations - - @property - @deprecated_function( - "platform_data.platform_translations", - breaks_in_ha_version="2026.8", - ) - def platform_translations(self) -> dict[str, str]: - """Return the platform translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.platform_translations - - @property - @deprecated_function( - "platform_data.object_id_component_translations", - breaks_in_ha_version="2026.8", - ) - def object_id_component_translations(self) -> dict[str, str]: - """Return the object ID component translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.object_id_component_translations - - @property - @deprecated_function( - "platform_data.object_id_platform_translations", - breaks_in_ha_version="2026.8", - ) - def object_id_platform_translations(self) -> dict[str, str]: - """Return the object ID platform translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.object_id_platform_translations - - @property - @deprecated_function( - "platform_data.default_language_platform_translations", - breaks_in_ha_version="2026.8", - ) - def default_language_platform_translations(self) -> dict[str, str]: - """Return the default language platform translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return self.platform_data.default_language_platform_translations - - @deprecated_function( - "platform_data.async_load_translations", - breaks_in_ha_version="2026.8", - ) - async def async_load_translations(self) -> None: - """Load translations. - - Will be removed in Home Assistant Core 2026.8. - """ - return await self.platform_data.async_load_translations() - @overload def _async_derive_object_ids( diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3683385f7b0a..092976d6e382 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -934,9 +934,14 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): Also maintains a count of enabled entries per config entry id. """ - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the container.""" super().__init__() + # hass is stored only so get_entries_for_device_id can expand a pre-migration + # composite device id to its split devices. Remove it, and restore the no-argument + # constructor, once the device registry deprecation period is over and composite + # device ids are no longer resolved. + self._hass = hass self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} self._config_entry_id_index: RegistryIndexType = defaultdict(dict) @@ -1002,13 +1007,36 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): return self._entry_ids.get(key) def get_entries_for_device_id( - self, device_id: str, include_disabled_entities: bool = False + self, + device_id: str, + include_disabled_entities: bool = False, ) -> list[RegistryEntry]: - """Get entries for device.""" + """Get entries for device. + + A device_id may be a pre-migration composite device id, which was split into one + device per config entry. The entries of the split devices are included, so a + lookup by the old composite id still finds the entities that were moved to the + split devices. + """ data = self.data + if keys := self._device_id_index.get(device_id): + # Entities are indexed only under real (live or just-removed) device ids, + # never under a composite device id, so a non-empty bucket means the direct + # result is complete and the device registry can be skipped. + return [ + entry + for key in keys + if not (entry := data[key]).disabled_by or include_disabled_entities + ] + # No directly indexed entities: device_id may be a pre-migration composite device + # id, which resolves to the entities of the split devices it was migrated into. + device_registry = dr.async_get(self._hass) return [ entry - for key in self._device_id_index.get(device_id, ()) + for device in device_registry.async_get_devices_for_composite_device_id( + device_id + ) + for key in self._device_id_index.get(device.id, ()) if not (entry := data[key]).disabled_by or include_disabled_entities ] @@ -1095,7 +1123,7 @@ def _validate_item( ) if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) - if not device_registry.async_get(device_id): + if device_id not in device_registry.devices: raise ValueError(f"Device {device_id} does not exist") if ( disabled_by @@ -1629,36 +1657,32 @@ class EntityRegistry(BaseRegistry): changes = event.data["changes"] - # Remove entities which belong to config entries no longer associated with the - # device - if old_config_entries := changes.get("config_entries"): + # Remove entities which belong to the config entry the device no longer belongs + # to. changes carries the old config_entry_id only when it changed (a move). + if "config_entry_id" in changes: + old_config_entry_id = changes["config_entry_id"] entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - config_entry_id = entity.config_entry_id if ( - entity.config_entry_id in old_config_entries - and entity.config_entry_id not in device.config_entries + entity.config_entry_id == old_config_entry_id + and entity.config_entry_id != device.config_entry_id ): self.async_remove(entity.entity_id) - # Remove entities which belong to config subentries no longer - # associated with the device - if old_config_entries_subentries := changes.get("config_entries_subentries"): + # Remove entities which belong to the config subentry the device no longer + # belongs to. changes carries the old config_subentry_id only when it changed. + if "config_subentry_id" in changes: + old_config_subentry_id = changes["config_subentry_id"] entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) for entity in entities: - config_entry_id = entity.config_entry_id - config_subentry_id = entity.config_subentry_id if ( - config_entry_id in device.config_entries - and config_entry_id in old_config_entries_subentries - and config_subentry_id - in old_config_entries_subentries[config_entry_id] - and config_subentry_id - not in device.config_entries_subentries[config_entry_id] + entity.config_entry_id == device.config_entry_id + and entity.config_subentry_id == old_config_subentry_id + and entity.config_subentry_id != device.config_subentry_id ): self.async_remove(entity.entity_id) @@ -2011,16 +2035,53 @@ class EntityRegistry(BaseRegistry): async def _async_load(self) -> None: """Load the entity registry.""" # Device registry must be loaded before entity registry because - # migration and entity processing reference device names. - await dr.async_get(self.hass).async_wait_loaded() + # migration and entity processing reference device names, and because entities + # are moved to the correct device when a pre-migration composite device was + # split into one device per config entry. + device_registry = dr.async_get(self.hass) + await device_registry.async_wait_loaded() _async_setup_cleanup(self.hass, self) _async_setup_entity_restore(self.hass, self) data = await self._store.async_load() - entities = EntityRegistryItems() + entities = EntityRegistryItems(self.hass) deleted_entities: dict[tuple[str, str, str], DeletedRegistryEntry] = {} + # Move entities to the correct device when a pre-migration composite device was + # split into one device per config entry. This can be removed 12 months after + # the config entries split migration ships. + migrated_composite_device = False + + def _split_device_id( + device_id: str | None, + config_entry_id: str | None, + config_subentry_id: str | None, + ) -> str | None: + """Map a device id to the split device matching the entity's config entry.""" + # Note: check container membership, not async_get, which returns a restored + # composite for a composite device id + if device_id is None or device_id in device_registry.devices: + return device_id + successors = device_registry.async_get_devices_for_composite_device_id( + device_id + ) + if not successors: + # The device is gone (e.g. the migration dropped a device with no config + # entry) and was not split; detach the entity rather than leave it pointing + # at a device id that no longer exists. + return None + for successor in successors: + if ( + successor.config_entry_id == config_entry_id + and successor.config_subentry_id == config_subentry_id + ): + return successor.id + for successor in successors: + if successor.config_entry_id == config_entry_id: + return successor.id + return successors[0].id + if data is not None: for entity in data["entities"]: try: @@ -2048,11 +2109,19 @@ class EntityRegistry(BaseRegistry): ) continue + device_id = _split_device_id( + entity["device_id"], + entity["config_entry_id"], + entity["config_subentry_id"], + ) + if device_id != entity["device_id"]: + migrated_composite_device = True + original_name_unprefixed = _unprefix_original_name( self.hass, entity["original_name"], entity["has_entity_name"], - entity["device_id"], + device_id, ) entities[entity["entity_id"]] = RegistryEntry( @@ -2065,7 +2134,7 @@ class EntityRegistry(BaseRegistry): config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), device_class=entity["device_class"], - device_id=entity["device_id"], + device_id=device_id, disabled_by=RegistryEntryDisabler(entity["disabled_by"]) if entity["disabled_by"] else None, @@ -2164,6 +2233,10 @@ class EntityRegistry(BaseRegistry): self.entities = entities self._entities_data = entities.data + # Persist entities moved off a split pre-migration composite device + if migrated_composite_device: + self.async_schedule_save() + @override def _data_to_save(self) -> dict[str, Any]: """Return data of entity registry to store in a file.""" @@ -2300,7 +2373,11 @@ async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None: def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False ) -> list[RegistryEntry]: - """Return entries that match a device.""" + """Return entries that match a device. + + A pre-migration composite device id resolves to the entries of the devices it was + split into. + """ return registry.entities.get_entries_for_device_id( device_id, include_disabled_entities ) diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index c433040a6c56..ba9f5191b6c2 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -7,17 +7,18 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, valid_entity from . import device_registry as dr, entity_registry as er from .event import async_track_entity_registry_updated_event +from .frame import ReportBehavior, report_usage def async_handle_source_entity_changes( hass: HomeAssistant, *, - add_helper_config_entry_to_device: bool = True, helper_config_entry_id: str, set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None, + **kwargs: Any, ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. @@ -31,11 +32,9 @@ def async_handle_source_entity_changes( called. If the source entity is identified by a UUID, the helper config entry is reloaded. - Source entity moved to another device: The helper entity is updated to link - to the new device, and the helper config entry removed from the old device - and added to the new device. Then the helper config entry is reloaded. + to the new device. Then the helper config entry is reloaded. - Source entity removed from the device: The helper entity is updated to link - to no device, and the helper config entry removed from the old device. Then - the helper config entry is reloaded. + to no device. Then the helper config entry is reloaded. :param set_source_entity_id_or_uuid: A function which updates the source entity ID or UUID, e.g., in the helper config entry options. @@ -43,6 +42,22 @@ def async_handle_source_entity_changes( is removed. This can be used to clean up any resources related to the source entity or ask the user to select a new source entity. """ + if "add_helper_config_entry_to_device" in kwargs: + del kwargs["add_helper_config_entry_to_device"] + # Adding the helper's config entry to the source device is no longer supported + # now that a device belongs to a single config entry; the helper entities link to + # the source device via their device_id instead. + report_usage( + "calls async_handle_source_entity_changes with " + "add_helper_config_entry_to_device, which no longer has any effect", + core_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2027.8.0", + ) + if kwargs: + raise TypeError( + "async_handle_source_entity_changes() got unexpected keyword arguments " + f"{', '.join(map(repr, kwargs))}" + ) async def async_registry_updated( event: Event[er.EventEntityRegistryUpdatedData], @@ -89,9 +104,8 @@ def async_handle_source_entity_changes( # No need to do any cleanup return - # The source entity has been moved to a different device, update the helper - # entities to link to the new device and the helper device to include the - # helper config entry + # The source entity has been moved to a different device; relink the helper + # entities to the new device. for helper_entity in entity_registry.entities.get_entries_for_config_entry_id( helper_config_entry_id ): @@ -100,17 +114,6 @@ def async_handle_source_entity_changes( helper_entity.entity_id, device_id=source_entity_entry.device_id ) - if add_helper_config_entry_to_device: - if source_entity_entry.device_id is not None: - device_registry.async_update_device( - source_entity_entry.device_id, - add_config_entry_id=helper_config_entry_id, - ) - - device_registry.async_update_device( - source_device_id, remove_config_entry_id=helper_config_entry_id - ) - source_device_id = source_entity_entry.device_id # Reload the config entry so the helper entity is recreated with diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index f93009cbeb39..a380cec1ae31 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -27,6 +27,10 @@ from .json import find_paths_unserializable_data, json_bytes, json_dumps _LOGGER = logging.getLogger(__name__) +# Responses smaller than this fit within a single network packet, so +# compressing them wastes event-loop CPU without reducing round-trips. +MIN_COMPRESSED_RESPONSE_SIZE: Final = 1024 + type AllowCorsType = Callable[[AbstractRoute | AbstractResource], None] KEY_AUTHENTICATED: Final = "ha_authenticated" @@ -160,7 +164,8 @@ class HomeAssistantView: headers=headers, zlib_executor_size=32768, ) - response.enable_compression() + if len(msg) > MIN_COMPRESSED_RESPONSE_SIZE: + response.enable_compression() return response def json_message( diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 7fc212c9dba3..236eb7556138 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -266,7 +266,7 @@ async def _async_process_integration_platforms( # Any = platform. -type ProcessPlatform[_R] = Callable[[HomeAssistant, str, Any], _R] +type ProcessPlatform[_R] = Callable[[HomeAssistant, str, Any], _R | Awaitable[_R]] class LazyIntegrationPlatforms[_R]: @@ -277,6 +277,8 @@ class LazyIntegrationPlatforms[_R]: this only imports and processes the platform for an integration the first time it is requested, and only for integrations that are loaded. + The process callback may be a coroutine function; its result is awaited. + The platform is intentionally not registered for preloading, since for a rarely used platform that would import it for every integration during loading, defeating the point of loading it lazily. @@ -358,7 +360,10 @@ class LazyIntegrationPlatforms[_R]: result: _R | None = None if platform is not None: try: - result = self._process_platform(self._hass, domain, platform) + processed = self._process_platform(self._hass, domain, platform) + if isinstance(processed, Awaitable): + processed = await processed + result = processed except Exception: _LOGGER.exception( "Error processing %s platform for %s", diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index f65c731d12a0..1a8528c8f039 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -14,11 +14,7 @@ from propcache.api import cached_property import voluptuous as vol from homeassistant.components.homeassistant.exposed_entities import async_should_expose -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, -) +from homeassistant.const import ATTR_ENTITY_ID, EntityStateAttribute from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.hass_dict import HassKey @@ -455,7 +451,9 @@ def _filter_by_features( yield candidate continue - supported_features = candidate.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported_features = candidate.state.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if (supported_features & features) == features: yield candidate @@ -474,7 +472,7 @@ def _filter_by_device_classes( yield candidate continue - device_class = candidate.state.attributes.get(ATTR_DEVICE_CLASS) + device_class = candidate.state.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class and (device_class in device_classes): yield candidate @@ -811,7 +809,7 @@ def async_match_states( @callback def async_test_feature(state: State, feature: int, feature_name: str) -> None: """Test if state supports a feature.""" - if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0: + if state.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) & feature == 0: raise IntentHandleError(f"Entity {state.name} does not support {feature_name}") diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index f89608f793f7..efc050a2c257 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -3,37 +3,20 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field as dc_field -from datetime import timedelta -from decimal import Decimal -from enum import Enum -from functools import cache, partial -from operator import attrgetter -from typing import Any, cast, override +from typing import Any, override import slugify as unicode_slug import voluptuous as vol from voluptuous_openapi import UNSUPPORTED, convert -from homeassistant.components.calendar import ( - DOMAIN as CALENDAR_DOMAIN, - SERVICE_GET_EVENTS, -) -from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER -from homeassistant.components.homeassistant import async_should_expose -from homeassistant.components.intent import async_device_supports_timers -from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN -from homeassistant.components.sensor import async_rounded_state -from homeassistant.components.todo import DOMAIN as TODO_DOMAIN, TodoServices -from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, ) -from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id +from homeassistant.core import Context, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt as dt_util, yaml as yaml_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType from homeassistant.util.ulid import ulid_now @@ -42,18 +25,20 @@ from . import ( area_registry as ar, config_validation as cv, device_registry as dr, - entity_registry as er, floor_registry as fr, intent, selector, service, ) +from .deprecation import deprecated_function from .singleton import singleton ACTION_PARAMETERS_CACHE: HassKey[ dict[str, dict[str, tuple[str | None, vol.Schema]]] ] = HassKey("llm_action_parameters_cache") +APIS_CACHE: HassKey[dict[str, API]] = HassKey("llm_apis") + LLM_API_ASSIST = "assist" @@ -67,49 +52,8 @@ Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. """ -NO_ENTITIES_PROMPT = ( - "Only if the user wants to control a device, tell them to expose entities " - "to their voice assistant in Home Assistant." -) - -DEVICE_CONTROL_TOOL_USAGE_PROMPT = ( - "When controlling Home Assistant always call the intent tools. " - "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " - "When controlling a device, prefer passing just name and domain. " - "When controlling an area, prefer passing just area name and domain." -) - -DYNAMIC_CONTEXT_PROMPT = ( - "You ARE equipped to answer questions about the" - " current state of\n" - "the home using the `GetLiveContext` tool." - " This is a primary function." - " Do not state you lack the\n" - "functionality if the question requires live data.\n" - "If the user asks about device existence/type" - ' (e.g., "Do I have lights in the bedroom?"):' - " Answer\n" - "from the static context below.\n" - "If the user asks about the CURRENT state, value," - ' or mode (e.g., "Is the lock locked?",\n' - '"Is the fan on?",' - ' "What mode is the thermostat in?",' - ' "What is the temperature outside?"):\n' - " 1. Recognize this requires live data.\n" - " 2. You MUST call `GetLiveContext`." - " This tool will provide the needed real-time" - " information (like temperature from the local" - " weather, lock status, etc.).\n" - " 3. Use the tool's response** to answer the" - " user accurately" - ' (e.g., "The temperature outside is' - ' [value from tool].").\n' - "For general knowledge questions not about the" - " home: Answer truthfully from internal" - " knowledge.\n" -) - +@deprecated_function("an empty string", breaks_in_ha_version="2027.2") @callback def async_render_no_api_prompt(hass: HomeAssistant) -> str: """Return the prompt to be used when no API is configured. @@ -119,13 +63,15 @@ def async_render_no_api_prompt(hass: HomeAssistant) -> str: return "" -@singleton("llm") +@singleton(APIS_CACHE) @callback def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: - """Get all the LLM APIs.""" - return { - LLM_API_ASSIST: AssistAPI(hass=hass), - } + """Return the registry of LLM APIs. + + APIs are registered by their owning integration; the Assist API is + registered by the ``llm`` integration during setup. + """ + return {} @callback @@ -191,7 +137,7 @@ class LLMContext: language: str | None """Language of the LLM request.""" - assistant: str | None + assistant: str """Assistant domain that is handling the LLM request.""" device_id: str | None @@ -458,308 +404,6 @@ class MergedAPI(API): return merged -class AssistAPI(API): - """API exposing Assist API to LLMs.""" - - IGNORE_INTENTS = { - intent.INTENT_GET_TEMPERATURE, - INTENT_GET_WEATHER, - INTENT_OPEN_COVER, # deprecated - INTENT_CLOSE_COVER, # deprecated - intent.INTENT_GET_STATE, - intent.INTENT_NEVERMIND, - intent.INTENT_TOGGLE, - intent.INTENT_GET_CURRENT_DATE, - intent.INTENT_GET_CURRENT_TIME, - intent.INTENT_RESPOND, - } - - def __init__(self, hass: HomeAssistant) -> None: - """Init the class.""" - super().__init__( - hass=hass, - id=LLM_API_ASSIST, - name="Assist", - ) - self.cached_slugify = cache( - partial(unicode_slug.slugify, separator="_", lowercase=False) - ) - - @override - async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: - """Return the instance of the API.""" - if llm_context.assistant: - exposed_entities: dict | None = _get_exposed_entities( - self.hass, llm_context.assistant, include_state=False - ) - else: - exposed_entities = None - - return APIInstance( - api=self, - api_prompt=self._async_get_api_prompt(llm_context, exposed_entities), - llm_context=llm_context, - tools=self._async_get_tools(llm_context, exposed_entities), - custom_serializer=selector_serializer, - ) - - @callback - def _async_get_api_prompt( - self, llm_context: LLMContext, exposed_entities: dict | None - ) -> str: - if not exposed_entities or not exposed_entities["entities"]: - return NO_ENTITIES_PROMPT - - # Collect all parts, filtering out any None values - prompt_parts = [ - DEVICE_CONTROL_TOOL_USAGE_PROMPT, - DYNAMIC_CONTEXT_PROMPT, - *self._async_get_exposed_entities_prompt(exposed_entities), - self._async_get_voice_satellite_area_prompt(llm_context), - self._async_get_no_timer_prompt(llm_context), - ] - - # Filter out None and empty strings before joining - return "\n".join([part for part in prompt_parts if part]) - - @callback - def _async_get_no_timer_prompt(self, llm_context: LLMContext) -> str | None: - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - return "This device is not able to start timers." - return None - - @callback - def _async_get_voice_satellite_area_prompt(self, llm_context: LLMContext) -> str: - """Return the area prompt for the voice satellite.""" - floor: fr.FloorEntry | None = None - area: ar.AreaEntry | None = None - extra = "" - if llm_context.device_id: - device_reg = dr.async_get(self.hass) - device = device_reg.async_get(llm_context.device_id) - - if device: - area_reg = ar.async_get(self.hass) - if device.area_id and (area := area_reg.async_get_area(device.area_id)): - floor_reg = fr.async_get(self.hass) - if area.floor_id: - floor = floor_reg.async_get_floor(area.floor_id) - - extra = ( - "and all generic commands like" - " 'turn on the lights' should target" - " this area." - ) - - if floor and area: - return f"You are in area {area.name} (floor {floor.name}) {extra}".strip() - if area: - return f"You are in area {area.name} {extra}".strip() - return ( - "When a user asks to turn on all devices of a specific type, " - "ask the user to specify an area, unless there" - " is only one device of that type." - ) - - @callback - def _async_get_exposed_entities_prompt( - self, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the API for exposed entities.""" - prompt = [] - - if exposed_entities and exposed_entities["entities"]: - prompt.append( - "Static Context: An overview of the areas" - " and the devices in this smart home:" - ) - prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) - - return prompt - - @callback - def _async_get_tools( - self, llm_context: LLMContext, exposed_entities: dict | None - ) -> list[Tool]: - """Return a list of LLM tools.""" - ignore_intents = self.IGNORE_INTENTS - if not llm_context.device_id or not async_device_supports_timers( - self.hass, llm_context.device_id - ): - ignore_intents = ignore_intents | { - intent.INTENT_START_TIMER, - intent.INTENT_CANCEL_TIMER, - intent.INTENT_INCREASE_TIMER, - intent.INTENT_DECREASE_TIMER, - intent.INTENT_PAUSE_TIMER, - intent.INTENT_UNPAUSE_TIMER, - intent.INTENT_TIMER_STATUS, - } - - intent_handlers = [ - intent_handler - for intent_handler in intent.async_get(self.hass) - if intent_handler.intent_type not in ignore_intents - ] - - exposed_domains: set[str] | None = None - if exposed_entities is not None: - exposed_domains = { - info["domain"] for info in exposed_entities["entities"].values() - } - - intent_handlers = [ - intent_handler - for intent_handler in intent_handlers - if intent_handler.platforms is None - or intent_handler.platforms & exposed_domains - ] - - tools: list[Tool] = [ - IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) - for intent_handler in intent_handlers - ] - - tools.append(GetDateTimeTool()) - - if exposed_entities: - if exposed_entities[CALENDAR_DOMAIN]: - names = [] - for info in exposed_entities[CALENDAR_DOMAIN].values(): - names.extend(info["names"].split(", ")) - tools.append(CalendarGetEventsTool(names)) - - if exposed_domains is not None and TODO_DOMAIN in exposed_domains: - names = [] - for info in exposed_entities["entities"].values(): - if info["domain"] != TODO_DOMAIN: - continue - names.extend(info["names"].split(", ")) - tools.append(TodoGetItemsTool(names)) - - tools.extend( - ScriptTool(self.hass, script_entity_id) - for script_entity_id in exposed_entities[SCRIPT_DOMAIN] - ) - - if exposed_domains: - tools.append(GetLiveContextTool()) - - return tools - - -def _get_exposed_entities( - hass: HomeAssistant, - assistant: str, - include_state: bool = True, -) -> dict[str, dict[str, dict[str, Any]]]: - """Get exposed entities. - - Splits out calendars and scripts. - """ - area_registry = ar.async_get(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - interesting_attributes = { - "temperature", - "current_temperature", - "temperature_unit", - "brightness", - "humidity", - "unit_of_measurement", - "device_class", - "current_position", - "percentage", - "volume_level", - "media_title", - "media_artist", - "media_album_name", - } - - entities = {} - data: dict[str, dict[str, Any]] = { - SCRIPT_DOMAIN: {}, - CALENDAR_DOMAIN: {}, - } - - for state in sorted(hass.states.async_all(), key=attrgetter("name")): - if not async_should_expose(hass, assistant, state.entity_id): - continue - - entity_entry = entity_registry.async_get(state.entity_id) - device_entry = ( - device_registry.async_get(entity_entry.device_id) - if entity_entry is not None and entity_entry.device_id is not None - else None - ) - names = intent.async_get_entity_aliases(hass, entity_entry, state=state) - area_names = [] - - if entity_entry is not None: - if ( - entity_entry.area_id is not None - and (area_entry := area_registry.async_get_area(entity_entry.area_id)) - is not None - ): - # Entity is in area - area_names.append(area_entry.name) - area_names.extend(sorted(area_entry.aliases)) - elif device_entry is not None: - # Check device area - if ( - device_entry.area_id is not None - and ( - area_entry := area_registry.async_get_area(device_entry.area_id) - ) - is not None - ): - area_names.append(area_entry.name) - area_names.extend(sorted(area_entry.aliases)) - - info: dict[str, Any] = { - "names": ", ".join(names), - "domain": state.domain, - } - - if include_state: - info["state"] = state.state - - # Format numeric states with configured display precision - if state.domain == "sensor": - info["state"] = async_rounded_state(hass, state.entity_id, state) - - # 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) - - if include_state and ( - attributes := { - str(attr_name): ( - str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value - ) - for attr_name, attr_value in state.attributes.items() - if attr_name in interesting_attributes - } - ): - info["attributes"] = attributes - - if state.domain in data: - data[state.domain][state.entity_id] = info - else: - entities[state.entity_id] = info - - data["entities"] = entities - return data - - def selector_serializer(schema: Any) -> Any: # noqa: C901 """Convert selectors into OpenAPI schema.""" if not isinstance(schema, selector.Selector): @@ -957,20 +601,6 @@ def _get_cached_action_parameters( parameters = vol.Schema(schema) - if domain == SCRIPT_DOMAIN: - entity_registry = er.async_get(hass) - if ( - entity_id := entity_registry.async_get_entity_id(domain, domain, action) - ) is not None and ( - entity_entry := entity_registry.async_get(entity_id) - ) is not None: - aliases = er.async_get_entity_aliases(hass, entity_entry) - if aliases: - if description: - description = description + ". Aliases: " + str(sorted(aliases)) - else: - description = "Aliases: " + str(sorted(aliases)) - parameters_cache.setdefault(domain, {})[action] = (description, parameters) return description, parameters @@ -1044,335 +674,3 @@ class ActionTool(Tool): ) return {"success": True, "result": result} - - -class ScriptTool(ActionTool): - """LLM Tool representing a Script.""" - - def __init__( - self, - hass: HomeAssistant, - script_entity_id: str, - ) -> None: - """Init the class.""" - script_name = split_entity_id(script_entity_id)[1] - - action = script_name - entity_registry = er.async_get(hass) - entity_entry = entity_registry.async_get(script_entity_id) - if entity_entry and entity_entry.unique_id: - action = entity_entry.unique_id - - super().__init__(hass, SCRIPT_DOMAIN, action) - - self.name = script_name - if self.name[0].isdigit(): - self.name = "_" + self.name - - -class CalendarGetEventsTool(Tool): - """LLM Tool allowing querying a calendar.""" - - name = "calendar_get_events" - description = ( - "Get events from a calendar. " - "When asked if something happens, search the whole week. " - "Results are RFC 5545 which means 'end' is exclusive." - ) - - def __init__(self, calendars: list[str]) -> None: - """Init the get events tool.""" - self.parameters = vol.Schema( - { - vol.Required("calendar"): vol.In(calendars), - vol.Required("range"): vol.In(["today", "week"]), - } - ) - - @override - async def async_call( - self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: - """Query a calendar.""" - data = self.parameters(tool_input.tool_args) - result = intent.async_match_targets( - hass, - intent.MatchTargetsConstraints( - name=data["calendar"], - domains=[CALENDAR_DOMAIN], - assistant=llm_context.assistant, - ), - ) - if not result.is_match: - return {"success": False, "error": "Calendar not found"} - - entity_id = result.states[0].entity_id - if data["range"] == "today": - start = dt_util.now() - end = dt_util.start_of_local_day() + timedelta(days=1) - elif data["range"] == "week": - start = dt_util.now() - end = dt_util.start_of_local_day() + timedelta(days=7) - - service_data = { - "entity_id": entity_id, - "start_date_time": start.isoformat(), - "end_date_time": end.isoformat(), - } - - service_result = await hass.services.async_call( - CALENDAR_DOMAIN, - SERVICE_GET_EVENTS, - service_data, - context=llm_context.context, - blocking=True, - return_response=True, - ) - - events = [ - event if "T" in event["start"] else {**event, "all_day": True} - for event in cast(dict, service_result)[entity_id]["events"] - ] - - return {"success": True, "result": events} - - -class TodoGetItemsTool(Tool): - """LLM Tool allowing querying a to-do list.""" - - name = "todo_get_items" - description = ( - "Query a to-do list to find out what items are on it. " - "Use this to answer questions like " - "'What's on my task list?' or " - "'Read my grocery list'. " - "Filters items by status (needs_action, completed, all)." - ) - - def __init__(self, todo_lists: list[str]) -> None: - """Init the get items tool.""" - self.parameters = vol.Schema( - { - vol.Required("todo_list"): vol.In(todo_lists), - vol.Optional( - "status", - description=( - "Filter returned items by status," - " by default returns incomplete" - " items" - ), - default="needs_action", - ): vol.In(["needs_action", "completed", "all"]), - } - ) - - @override - async def async_call( - self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: - """Query a to-do list.""" - data = self.parameters(tool_input.tool_args) - result = intent.async_match_targets( - hass, - intent.MatchTargetsConstraints( - name=data["todo_list"], - domains=[TODO_DOMAIN], - assistant=llm_context.assistant, - ), - ) - if not result.is_match: - return {"success": False, "error": "To-do list not found"} - entity_id = result.states[0].entity_id - service_data: dict[str, Any] = {"entity_id": entity_id} - if status := data.get("status"): - if status == "all": - service_data["status"] = ["needs_action", "completed"] - else: - service_data["status"] = [status] - service_result = await hass.services.async_call( - TODO_DOMAIN, - TodoServices.GET_ITEMS, - service_data, - context=llm_context.context, - blocking=True, - return_response=True, - ) - if not service_result: - return {"success": False, "error": "To-do list not found"} - items = cast(dict, service_result)[entity_id]["items"] - return {"success": True, "result": items} - - -def _live_context_match_error( - match_result: intent.MatchTargetsResult, - name_filter: str | None, - area_filter: str | None, - domain_filter: list[str] | None, -) -> str: - """Build an actionable error message for a failed GetLiveContext match.""" - reason = match_result.no_match_reason - if reason is intent.MatchFailedReason.INVALID_AREA: - return f"Area '{match_result.no_match_name}' does not exist" - if reason is intent.MatchFailedReason.NAME: - return f"No exposed entities matched name '{name_filter}'" - if reason is intent.MatchFailedReason.AREA: - return f"No exposed entities found in area '{area_filter}'" - if reason is intent.MatchFailedReason.DOMAIN: - domains = ", ".join(domain_filter) if domain_filter else "" - return f"No exposed entities found in domain(s): {domains}" - return "No entities matched the provided filter" - - -class GetLiveContextTool(Tool): - """Tool for getting the current state of exposed entities. - - This returns state for all entities that have been exposed to - the assistant. This is different than the GetState intent, which - returns state for entities based on intent parameters. - """ - - name = "GetLiveContext" - description = ( - "Provides real-time information about the" - " CURRENT state, value, or mode of devices," - " sensors, entities, or areas. " - "Use this tool for: " - "1. Answering questions about current" - " conditions (e.g., 'Is the light on?'). " - "2. As the first step in conditional actions" - " (e.g., 'If the weather is rainy, turn off" - " sprinklers' requires checking the weather" - " first). " - "You may filter for devices by name, domain," - " and area, including combining those" - " filters. " - "Prefer filtering by domain when searching" - " for multiple devices of the same type." - ) - parameters = vol.Schema( - { - vol.Optional( - "name", - description="Filter entities by name or alias (case-insensitive).", - ): cv.string, - vol.Optional( - "domain", - description=( - "Filter entities by domain" - " (e.g. 'light', 'sensor')." - " Accepts a single domain or a list." - ), - ): vol.Any(cv.string, [cv.string]), - vol.Optional( - "area", - description="Filter entities by area name or alias (case-insensitive).", - ): cv.string, - } - ) - - @override - async def async_call( - self, - hass: HomeAssistant, - tool_input: ToolInput, - llm_context: LLMContext, - ) -> JsonObjectType: - """Get the current state of exposed entities.""" - if llm_context.assistant is None: - # Note this doesn't happen in practice since this tool won't be - # exposed if no assistant is configured. - return {"success": False, "error": "No assistant configured"} - - args = self.parameters(tool_input.tool_args) - exposed_entities = _get_exposed_entities(hass, llm_context.assistant) - - if not exposed_entities["entities"]: - return {"success": False, "error": NO_ENTITIES_PROMPT} - - name_filter = args.get("name") - area_filter = args.get("area") - domain_filter = args.get("domain") - - if isinstance(domain_filter, str): - domain_filter = [domain_filter] - - if domain_filter is not None: - domain_filter = [ - normalized_domain - for domain in domain_filter - if (normalized_domain := domain.strip().lower()) - ] - - if name_filter or area_filter or domain_filter: - exposed_states = [ - state - for entity_id in exposed_entities["entities"] - if (state := hass.states.get(entity_id)) is not None - ] - match_result = intent.async_match_targets( - hass, - intent.MatchTargetsConstraints( - name=name_filter, - area_name=area_filter, - domains=domain_filter, - # This tool only returns context, so multiple entities - # sharing a name (e.g. "AC" in two areas) should all be - # returned rather than failing as an ambiguous match. - allow_duplicate_names=True, - ), - states=exposed_states, - ) - - if not match_result.is_match: - return { - "success": False, - "error": _live_context_match_error( - match_result, name_filter, area_filter, domain_filter - ), - } - - matched_ids = {state.entity_id for state in match_result.states} - entities = [ - info - for entity_id, info in exposed_entities["entities"].items() - if entity_id in matched_ids - ] - else: - entities = list(exposed_entities["entities"].values()) - - prompt = [ - "Live Context: An overview of the areas" - " and the devices in this smart home:", - yaml_util.dump(entities), - ] - return { - "success": True, - "result": "\n".join(prompt), - } - - -class GetDateTimeTool(Tool): - """Tool for getting the current date and time.""" - - name = "GetDateTime" - description = "Provides the current date and time." - - @override - async def async_call( - self, - hass: HomeAssistant, - tool_input: ToolInput, - llm_context: LLMContext, - ) -> JsonObjectType: - """Get the current date and time.""" - now = dt_util.now() - - return { - "success": True, - "result": { - "date": now.strftime("%Y-%m-%d"), - "time": now.strftime("%H:%M:%S"), - "timezone": now.strftime("%Z"), - "weekday": now.strftime("%A"), - }, - } diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index 42c251e72d4f..9d268b74f6d5 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -3,7 +3,7 @@ from collections.abc import Iterable import logging -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, State from homeassistant.util import location as location_util @@ -17,8 +17,12 @@ def has_location(state: State) -> bool: """ return ( isinstance(state, State) - and isinstance(state.attributes.get(ATTR_LATITUDE), (float, int)) - and isinstance(state.attributes.get(ATTR_LONGITUDE), (float, int)) + and isinstance( + state.attributes.get(EntityStateAttribute.LATITUDE), (float, int) + ) + and isinstance( + state.attributes.get(EntityStateAttribute.LONGITUDE), (float, int) + ) ) @@ -36,8 +40,8 @@ def closest(latitude: float, longitude: float, states: Iterable[State]) -> State with_location, key=lambda state: ( location_util.distance( - state.attributes.get(ATTR_LATITUDE), - state.attributes.get(ATTR_LONGITUDE), + state.attributes.get(EntityStateAttribute.LATITUDE), + state.attributes.get(EntityStateAttribute.LONGITUDE), latitude, longitude, ) @@ -72,13 +76,13 @@ def find_coordinates( # Check if entity_state is a zone zone_entity = hass.states.get(f"zone.{entity_state.state}") - if has_location(zone_entity): # type: ignore[arg-type] + if zone_entity and has_location(zone_entity): _LOGGER.debug( "%s is in %s, getting zone location", name, - zone_entity.entity_id, # type: ignore[union-attr] + zone_entity.entity_id, ) - return _get_location_from_attributes(zone_entity) # type: ignore[arg-type] + return _get_location_from_attributes(zone_entity) # Check if entity_state is a friendly name of a zone if (zone_coords := resolve_zone(hass, entity_state.state)) is not None: @@ -124,4 +128,7 @@ def resolve_zone(hass: HomeAssistant, zone_name: str) -> str | None: def _get_location_from_attributes(entity_state: State) -> str: """Get the lat/long string from an entities attributes.""" attr = entity_state.attributes - return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}" + return ( + f"{attr.get(EntityStateAttribute.LATITUDE)}," + f"{attr.get(EntityStateAttribute.LONGITUDE)}" + ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 2653d8132821..224d6688f033 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,8 +1,7 @@ """Helpers to execute scripts.""" import asyncio -from collections.abc import AsyncGenerator, Callable, Mapping, Sequence -from contextlib import asynccontextmanager +from collections.abc import Callable, Mapping, Sequence from contextvars import ContextVar from copy import copy from dataclasses import dataclass @@ -76,6 +75,7 @@ from homeassistant.core import ( State, SupportsResponse, callback, + valid_entity_id, ) from homeassistant.util import slugify from homeassistant.util.async_ import create_eager_task @@ -186,79 +186,102 @@ def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement: return trace_element -@asynccontextmanager -async def trace_action( - hass: HomeAssistant, - script_run: _ScriptRun, - stop: asyncio.Future[None], - variables: TemplateVarsType, -) -> AsyncGenerator[TraceElement]: +class trace_action: """Trace action execution.""" - path = trace_path_get() - trace_element = action_trace_append(variables, path) - trace_stack_push(trace_stack_cv, trace_element) - trace_id = trace_id_get() - if trace_id: - key = trace_id[0] - run_id = trace_id[1] - breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS] - if key in breakpoints and ( - ( - run_id in breakpoints[key] - and ( - path in breakpoints[key][run_id] - or NODE_ANY in breakpoints[key][run_id] + __slots__ = ("_hass", "_stop", "_trace_element", "_variables") + + _trace_element: TraceElement + + def __init__( + self, + hass: HomeAssistant, + script_run: _ScriptRun, + stop: asyncio.Future[None], + variables: TemplateVarsType, + ) -> None: + """Store the data needed to trace the action.""" + self._hass = hass + self._stop = stop + self._variables = variables + + async def __aenter__(self) -> TraceElement: + """Start tracing the action, handling any configured breakpoint.""" + hass = self._hass + stop = self._stop + path = trace_path_get() + trace_element = action_trace_append(self._variables, path) + self._trace_element = trace_element + trace_stack_push(trace_stack_cv, trace_element) + + trace_id = trace_id_get() + if trace_id: + key = trace_id[0] + run_id = trace_id[1] + breakpoints = hass.data[DATA_SCRIPT_BREAKPOINTS] + if key in breakpoints and ( + ( + run_id in breakpoints[key] + and ( + path in breakpoints[key][run_id] + or NODE_ANY in breakpoints[key][run_id] + ) ) - ) - or ( - RUN_ID_ANY in breakpoints[key] - and ( - path in breakpoints[key][RUN_ID_ANY] - or NODE_ANY in breakpoints[key][RUN_ID_ANY] + or ( + RUN_ID_ANY in breakpoints[key] + and ( + path in breakpoints[key][RUN_ID_ANY] + or NODE_ANY in breakpoints[key][RUN_ID_ANY] + ) + ) + ): + async_dispatcher_send_internal( + hass, SCRIPT_BREAKPOINT_HIT, key, run_id, path ) - ) - ): - async_dispatcher_send_internal( - hass, SCRIPT_BREAKPOINT_HIT, key, run_id, path - ) - done = hass.loop.create_future() + done = hass.loop.create_future() - @callback - def async_continue_stop( - command: Literal["continue", "stop"] | None = None, - ) -> None: - if command == "stop": - _set_result_unless_done(stop) - _set_result_unless_done(done) + @callback + def async_continue_stop( + command: Literal["continue", "stop"] | None = None, + ) -> None: + if command == "stop": + _set_result_unless_done(stop) + _set_result_unless_done(done) - signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) - remove_signal1 = async_dispatcher_connect(hass, signal, async_continue_stop) - remove_signal2 = async_dispatcher_connect( - hass, SCRIPT_DEBUG_CONTINUE_ALL, async_continue_stop - ) + signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) + remove_signal1 = async_dispatcher_connect( + hass, signal, async_continue_stop + ) + remove_signal2 = async_dispatcher_connect( + hass, SCRIPT_DEBUG_CONTINUE_ALL, async_continue_stop + ) - await asyncio.wait([stop, done], return_when=asyncio.FIRST_COMPLETED) - remove_signal1() - remove_signal2() + await asyncio.wait([stop, done], return_when=asyncio.FIRST_COMPLETED) + remove_signal1() + remove_signal2() - try: - yield trace_element - except _AbortScript as ex: - trace_element.set_error(ex.__cause__ or ex) - raise - except _ConditionFail: - # Clear errors which may have been set when evaluating the condition - trace_element.set_error(None) - raise - except _StopScript: - raise - except Exception as ex: - trace_element.set_error(ex) - raise - finally: - trace_stack_pop(trace_stack_cv) + return trace_element + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: object, + ) -> None: + """Finish tracing the action, recording any error.""" + try: + if isinstance(exc_val, _AbortScript): + self._trace_element.set_error(exc_val.__cause__ or exc_val) + elif isinstance(exc_val, _ConditionFail): + # Clear errors which may have been set when evaluating the condition + self._trace_element.set_error(None) + elif isinstance(exc_val, _StopScript): + pass + elif isinstance(exc_val, Exception): + self._trace_element.set_error(exc_val) + finally: + trace_stack_pop(trace_stack_cv) def make_script_schema( @@ -450,7 +473,12 @@ class _ScriptRun: _timeout = ( "" if timeout is None else f" (timeout: {timedelta(seconds=timeout)})" ) - self._log("Executing step %s%s", self._script.last_action, _timeout) + self._log( + "Executing step %s%s", + self._script.last_action, + _timeout, + level=logging.DEBUG, + ) async def async_run(self) -> ScriptRunResult | None: """Run script.""" @@ -463,7 +491,11 @@ class _ScriptRun: response = None try: - self._log("Running %s", self._script.running_description) + self._log( + "Running %s", + self._script.running_description, + level=logging.INFO if self._script.top_level else logging.DEBUG, + ) for self._step, self._action in enumerate(self._script.sequence): # noqa: B020 if self._stop.done(): script_execution_set("cancelled") @@ -531,6 +563,7 @@ class _ScriptRun: self._log( "Skipped disabled step %s", self._action.get(CONF_ALIAS, action), + level=logging.DEBUG, ) trace_set_result(enabled=False) return @@ -777,7 +810,12 @@ class _ScriptRun: self._log("Error in 'condition' evaluation:\n%s", ex, level=logging.WARNING) check = False - self._log("Test condition %s: %s", self._script.last_action, check) + self._log( + "Test condition %s: %s", + self._script.last_action, + check, + level=logging.DEBUG, + ) trace_update_result(result=check) if not check: raise _ConditionFail @@ -822,7 +860,13 @@ class _ScriptRun: warned_too_many_loops = False async def async_run_sequence(iteration: int, extra_msg: str = "") -> None: - self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg) + self._log( + "Repeating %s: Iteration %i%s", + description, + iteration, + extra_msg, + level=logging.DEBUG, + ) with trace_path("sequence"): await self._async_run_script(script) @@ -1668,6 +1712,15 @@ class Script: if CONF_ELSE in step: Script._find_referenced_target(target, referenced, step[CONF_ELSE]) + elif action == cv.SCRIPT_ACTION_REPEAT: + for cond in step[CONF_REPEAT].get(CONF_WHILE, []): + referenced |= condition.async_extract_targets(cond, target) + for cond in step[CONF_REPEAT].get(CONF_UNTIL, []): + referenced |= condition.async_extract_targets(cond, target) + Script._find_referenced_target( + target, referenced, step[CONF_REPEAT][CONF_SEQUENCE] + ) + elif action == cv.SCRIPT_ACTION_PARALLEL: for script in step[CONF_PARALLEL]: Script._find_referenced_target( @@ -1709,6 +1762,12 @@ class Script: elif action == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: referenced.add(step[CONF_DEVICE_ID]) + elif action == cv.SCRIPT_ACTION_FIRE_EVENT: + if (event_data := step.get(CONF_EVENT_DATA)) and isinstance( + device_id := event_data.get(ATTR_DEVICE_ID), str + ): + referenced.add(device_id) + elif action == cv.SCRIPT_ACTION_CHOOSE: for choice in step[CONF_CHOOSE]: for cond in choice[CONF_CONDITIONS]: @@ -1724,6 +1783,15 @@ class Script: if CONF_ELSE in step: Script._find_referenced_devices(referenced, step[CONF_ELSE]) + elif action == cv.SCRIPT_ACTION_REPEAT: + for cond in step[CONF_REPEAT].get(CONF_WHILE, []): + referenced |= condition.async_extract_devices(cond) + for cond in step[CONF_REPEAT].get(CONF_UNTIL, []): + referenced |= condition.async_extract_devices(cond) + Script._find_referenced_devices( + referenced, step[CONF_REPEAT][CONF_SEQUENCE] + ) + elif action == cv.SCRIPT_ACTION_PARALLEL: for script in step[CONF_PARALLEL]: Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) @@ -1761,9 +1829,26 @@ class Script: for trigger in step[CONF_WAIT_FOR_TRIGGER]: referenced |= set(trigger_helper.async_extract_entities(trigger)) + elif action == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: + # Only extract the entity if it has been resolved to an entity + # id during validation; unvalidated configs hold an entity + # registry id. + if isinstance( + entity_id := step.get(ATTR_ENTITY_ID), str + ) and valid_entity_id(entity_id): + referenced.add(entity_id) + elif action == cv.SCRIPT_ACTION_ACTIVATE_SCENE: referenced.add(step[CONF_SCENE]) + elif action == cv.SCRIPT_ACTION_FIRE_EVENT: + if ( + (event_data := step.get(CONF_EVENT_DATA)) + and isinstance(entity_id := event_data.get(ATTR_ENTITY_ID), str) + and valid_entity_id(entity_id) + ): + referenced.add(entity_id) + elif action == cv.SCRIPT_ACTION_CHOOSE: for choice in step[CONF_CHOOSE]: for cond in choice[CONF_CONDITIONS]: @@ -1779,6 +1864,15 @@ class Script: if CONF_ELSE in step: Script._find_referenced_entities(referenced, step[CONF_ELSE]) + elif action == cv.SCRIPT_ACTION_REPEAT: + for cond in step[CONF_REPEAT].get(CONF_WHILE, []): + referenced |= condition.async_extract_entities(cond) + for cond in step[CONF_REPEAT].get(CONF_UNTIL, []): + referenced |= condition.async_extract_entities(cond) + Script._find_referenced_entities( + referenced, step[CONF_REPEAT][CONF_SEQUENCE] + ) + elif action == cv.SCRIPT_ACTION_PARALLEL: for script in step[CONF_PARALLEL]: Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 5936aadf2cd0..268cc67e8490 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -245,6 +245,26 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): model_id: str +ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = ( + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + { + # Filter on properties of the device the entity belongs to + vol.Optional("device"): DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA, + } + ) +) + + +class EntityWithDeviceFilterSelectorConfig(EntityFilterSelectorConfig, total=False): + """Class to represent an entity selector filter config. + + Adds device filtering on top of the shared entity filter, only used by + the entity selector. + """ + + device: DeviceFilterSelectorConfig + + class ActionSelectorConfig(BaseSelectorConfig): """Class to represent an action selector config.""" @@ -985,7 +1005,10 @@ class EntitySelectorConfig( include_entities: list[str] multiple: bool reorder: bool - filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + filter: ( + EntityWithDeviceFilterSelectorConfig + | list[EntityWithDeviceFilterSelectorConfig] + ) @SELECTORS.register("entity") @@ -1004,7 +1027,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): vol.Optional("reorder", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, - [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + [ENTITY_WITH_DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], ), } ), diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 5a9523099948..c9c56e4f09ab 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -2,13 +2,12 @@ import asyncio from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence -import dataclasses from enum import Enum from functools import cache, partial import inspect import logging from types import ModuleType -from typing import TYPE_CHECKING, Any, TypedDict, cast, override +from typing import TYPE_CHECKING, Any, TypedDict, cast import voluptuous as vol @@ -60,7 +59,7 @@ from . import ( target as target_helpers, template, ) -from .deprecation import deprecated_class, deprecated_function, deprecated_hass_argument +from .deprecation import deprecated_hass_argument from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType @@ -223,33 +222,6 @@ class ServiceParams(TypedDict): target: dict | None -@deprecated_class( - "homeassistant.helpers.target.TargetSelection", - breaks_in_ha_version="2026.8", -) -class ServiceTargetSelector(target_helpers.TargetSelection): - """Class to hold a target selector for a service.""" - - def __init__(self, service_call: ServiceCall) -> None: - """Extract ids from service call data.""" - super().__init__(service_call.data) - - -@deprecated_class( - "homeassistant.helpers.target.SelectedEntities", - breaks_in_ha_version="2026.8", -) -class SelectedEntities(target_helpers.SelectedEntities): - """Class to hold the selected entities.""" - - @override - def log_missing( - self, missing_entities: set[str], logger: logging.Logger | None = None - ) -> None: - """Log about missing items.""" - super().log_missing(missing_entities, logger or _LOGGER) - - def call_from_config( hass: HomeAssistant, config: ConfigType, @@ -443,21 +415,6 @@ async def async_extract_entity_ids( return referenced.referenced | referenced.indirectly_referenced -@deprecated_function( - "homeassistant.helpers.target.async_extract_referenced_entity_ids", - breaks_in_ha_version="2026.8", -) -def async_extract_referenced_entity_ids( - hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True -) -> SelectedEntities: - """Extract referenced entity IDs from a service call.""" - target_selection = target_helpers.TargetSelection(service_call.data) - selected = target_helpers.async_extract_referenced_entity_ids( - hass, target_selection, expand_group - ) - return SelectedEntities(**dataclasses.asdict(selected)) - - @deprecated_hass_argument(breaks_in_ha_version="2026.10") async def async_extract_config_entry_ids( service_call: ServiceCall, expand_group: bool = True diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 87eb7041699b..d34151002f11 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -206,8 +206,19 @@ def async_extract_referenced_entity_ids( selected.missing_areas.add(area_id) for device_id in target_selection.device_ids: - if device_id not in dev_reg.devices: + if device_id in dev_reg.devices: + selected.referenced_devices.add(device_id) + elif split_devices := dev_reg.async_get_devices_for_composite_device_id( + device_id + ): + # A multi config entry composite device id is no longer a device itself; + # it resolves to the devices it was split into so actions targeting it + # still trickle down. Only the splits are referenced, not the composite id, + # so a device-id consumer does not act on the same underlying device twice. + selected.referenced_devices.update(device.id for device in split_devices) + else: selected.missing_devices.add(device_id) + selected.referenced_devices.add(device_id) if target_selection.label_ids: label_reg = lr.async_get(hass) @@ -234,7 +245,6 @@ def async_extract_referenced_entity_ids( ) selected.referenced_areas.update(target_selection.area_ids) - selected.referenced_devices.update(target_selection.device_ids) if not selected.referenced_areas and not selected.referenced_devices: return selected diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index fcdba8878ddd..23ff8af3f366 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -74,6 +74,7 @@ from .states import ( StateAttrTranslated, StateTranslated, TemplateState as TemplateState, + TemplateStateBase, TemplateStateFromEntityId as TemplateStateFromEntityId, ) @@ -238,8 +239,31 @@ RESULT_WRAPPERS: dict[type, type] = {kls: gen_result_wrapper(kls) for kls in _ty RESULT_WRAPPERS[tuple] = TupleWrapper -@lru_cache(maxsize=EVAL_CACHE_SIZE) def _parse_result(render_result: str) -> Any: + """Parse a rendered result. + + Continuously changing numeric results, like sensor values, produce + a new string on every render and would always miss the eval cache, + paying for a full literal_eval. Convert them directly instead. + Anything the fast path cannot convert falls through to the cached + path, which handles the edge cases ("", ".", "+") identically. + """ + if _IS_NUMERIC.match(render_result): + if "." in render_result: + try: + return float(render_result) + except ValueError: + pass + else: + try: + return int(render_result) + except ValueError: + pass + return _cached_parse_result(render_result) + + +@lru_cache(maxsize=EVAL_CACHE_SIZE) +def _cached_parse_result(render_result: str) -> Any: """Parse a result and cache the result.""" # lru_cache does not memoize raised exceptions. The most common template # results, plain string states such as "on", "off" or "unavailable", are @@ -817,7 +841,14 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): def is_safe_attribute(self, obj, attr, value): """Test if attribute is safe.""" if isinstance( - obj, (AllStates, DomainStates, TemplateState, LoopContext, AsyncLoopContext) + obj, + ( + AllStates, + DomainStates, + TemplateStateBase, + LoopContext, + AsyncLoopContext, + ), ): return attr[0] != "_" diff --git a/homeassistant/helpers/template/extensions/state.py b/homeassistant/helpers/template/extensions/state.py index 4133789782c3..bb851959a9ca 100644 --- a/homeassistant/helpers/template/extensions/state.py +++ b/homeassistant/helpers/template/extensions/state.py @@ -6,11 +6,10 @@ from typing import TYPE_CHECKING, Any from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_PERSONS, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfLength, ) from homeassistant.core import State, valid_entity_id @@ -228,8 +227,8 @@ class StateExtension(BaseTemplateExtension): ) return None - latitude = point_state.attributes[ATTR_LATITUDE] - longitude = point_state.attributes[ATTR_LONGITUDE] + latitude = point_state.attributes[EntityStateAttribute.LATITUDE] + longitude = point_state.attributes[EntityStateAttribute.LONGITUDE] entities = args[1] @@ -308,8 +307,8 @@ class StateExtension(BaseTemplateExtension): ) return None - latitude = point_state.attributes[ATTR_LATITUDE] - longitude = point_state.attributes[ATTR_LONGITUDE] + latitude = point_state.attributes[EntityStateAttribute.LATITUDE] + longitude = point_state.attributes[EntityStateAttribute.LONGITUDE] locations.append((latitude, longitude)) diff --git a/homeassistant/helpers/template/states.py b/homeassistant/helpers/template/states.py index af8abdf608a7..4563f8d2a4d0 100644 --- a/homeassistant/helpers/template/states.py +++ b/homeassistant/helpers/template/states.py @@ -9,7 +9,7 @@ from typing import Any, override from lru import LRU from propcache.api import under_cached_property -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, EntityStateAttribute from homeassistant.core import ( Context, HomeAssistant, @@ -182,7 +182,7 @@ class StateTranslated: state_value = state.state domain = state.domain - device_class = state.attributes.get("device_class") + device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) 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 @@ -219,7 +219,7 @@ class StateAttrTranslated: return attr_value domain = state.domain - device_class = state.attributes.get("device_class") + device_class = state.attributes.get(EntityStateAttribute.DEVICE_CLASS) 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 @@ -413,7 +413,9 @@ class TemplateStateBase(State): state = async_rounded_state(self._hass, self._entity_id, self._state) else: state = self._state.state - if with_unit and (unit := self._state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): + if with_unit and ( + unit := self._state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ): return f"{state} {unit}" return state diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 2c2110af8dfc..84836af8c9d3 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -305,17 +305,27 @@ def script_execution_get() -> str | None: return data.script_execution -@contextmanager -def trace_path(suffix: str | list[str]) -> Generator[None]: +class trace_path: """Go deeper in the config tree. - Can not be used as a decorator on couroutine functions. + Can not be used as a decorator. """ - count = trace_path_push(suffix) - try: - yield - finally: - trace_path_pop(count) + + __slots__ = ("_count", "_suffix") + + _count: int + + def __init__(self, suffix: str | list[str]) -> None: + """Store the path suffix to push on enter.""" + self._suffix = suffix + + def __enter__(self) -> None: + """Go deeper in the config tree.""" + self._count = trace_path_push(self._suffix) + + def __exit__(self, *exc: object) -> None: + """Go back up in the config tree.""" + trace_path_pop(self._count) def async_trace_path[*_Ts]( diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index a541242de992..f07a0a8bf5d7 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -26,8 +26,8 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_UNIT_OF_MEASUREMENT, CONF_ALIAS, + CONF_AT, CONF_DEVICE_ID, CONF_ENABLED, CONF_ENTITY_ID, @@ -42,6 +42,7 @@ from homeassistant.const import ( CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -856,7 +857,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): entity_id=threshold.entity, ) return None - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if not self._is_valid_unit(unit): # Entity unit does not match the expected unit report_not_triggered( @@ -882,7 +883,9 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): domain_spec = self._domain_specs[state.domain] raw_value: Any if domain_spec.value_source is None: - if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)): + if not self._is_valid_unit( + state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) + ): return None raw_value = state.state else: @@ -907,7 +910,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase): domain_spec = self._domain_specs[state.domain] raw_value: Any if domain_spec.value_source is None: - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if not self._is_valid_unit(unit): report_not_triggered( "entity_unit_not_supported", @@ -984,7 +987,7 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase): def _get_entity_unit(self, state: State) -> str | None: """Get the unit of an entity from its state.""" - return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + return state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) @override def _report_tracked_value_problem( @@ -1050,7 +1053,7 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase): ) return None - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) try: return self._unit_converter.convert(value, unit, self._base_unit) except HomeAssistantError: @@ -2053,6 +2056,26 @@ def async_extract_entities(trigger_conf: dict) -> list[str]: if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"): return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return] + if trigger_conf[CONF_PLATFORM] == "time": + # Each at time can be a time, an entity id, an entity id with + # an offset, or a template. + entity_ids: list[str] = [] + for at_time in trigger_conf[CONF_AT]: + if isinstance(at_time, str) and valid_entity_id(at_time): + entity_ids.append(at_time) + elif isinstance(at_time, dict) and CONF_ENTITY_ID in at_time: + entity_ids.append(at_time[CONF_ENTITY_ID]) + return entity_ids + + if trigger_conf[CONF_PLATFORM] == "device": + # Only extract the entity if it has been resolved to an entity id + # during validation; unvalidated configs hold an entity registry id. + if isinstance( + entity_id := trigger_conf.get(CONF_ENTITY_ID), str + ) and valid_entity_id(entity_id): + return [entity_id] + return [] + if trigger_conf[CONF_PLATFORM] == "calendar": return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]] diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index e5ad9964ea27..9515fd43f85d 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -18,14 +18,12 @@ from homeassistant.components.sensor.helpers import ( # pylint: disable=home-as async_parse_date_datetime, ) from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - ATTR_FRIENDLY_NAME, - ATTR_ICON, CONF_DEVICE_CLASS, CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError @@ -48,9 +46,9 @@ CONF_ATTRIBUTES = "attributes" CONF_PICTURE = "picture" CONF_TO_ATTRIBUTE = { - CONF_ICON: ATTR_ICON, - CONF_NAME: ATTR_FRIENDLY_NAME, - CONF_PICTURE: ATTR_ENTITY_PICTURE, + CONF_ICON: EntityStateAttribute.ICON, + CONF_NAME: EntityStateAttribute.FRIENDLY_NAME, + CONF_PICTURE: EntityStateAttribute.ENTITY_PICTURE, } TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 78ab8b7accf6..4efe8fd385d3 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -101,8 +101,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): frame.report_usage( "relies on ContextVar, but should pass the config entry explicitly.", core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, custom_integration_behavior=frame.ReportBehavior.IGNORE, - breaks_in_ha_version="2026.8", ) self.config_entry = config_entries.current_entry.get() diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37e9c989b35b..176976e770e5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.46.2 +async-upnp-client==0.47.0 atomicwrites-homeassistant==1.4.1 attrs==26.1.0 audioop-lts==0.2.2 @@ -35,22 +35,22 @@ file-read-backwards==2.0.0 fnv-hash-fast==2.0.3 go2rtc-client==0.4.0 ha-ffmpeg==3.2.2 -habluetooth==6.26.2 +habluetooth==6.26.5 hass-nabucasa==2.2.0 hassil==3.8.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260624.4 +home-assistant-frontend==20260624.6 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 -openai==2.21.0 +openai==2.45.0 orjson==3.11.9 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==12.2.0 +Pillow==12.3.0 propcache==0.5.2 psutil-home-assistant==0.0.1 PyJWT==2.12.1 @@ -70,7 +70,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.25 +uv==0.11.28 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 @@ -256,3 +256,7 @@ azure-kusto-data==4.5.1 azure-kusto-ingest==4.5.1 coloredlogs==15.0.1 setuptools==81.0.0 + +# Pin cffi to 2.0.0 to avoid version mismatch with the pre-baked _cffi_backend in the base image. +# https://github.com/home-assistant/core/issues/175832 +cffi==2.0.0 diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 61936a49c0d3..0150f93fe15b 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -18,7 +18,7 @@ import threading import time from time import monotonic import traceback -from typing import Any +from typing import Any, override import packaging.tags @@ -173,7 +173,7 @@ class RuntimeConfig: safe_mode: bool = False -class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[name-defined,misc] +class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): """Event loop policy for Home Assistant.""" def __init__(self, debug: bool) -> None: @@ -184,8 +184,9 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[name- @property def loop_name(self) -> str: """Return name of the loop.""" - return self._loop_factory.__name__ # type: ignore[no-any-return] + return self._loop_factory.__name__ # type: ignore[attr-defined,no-any-return] + @override def new_event_loop(self) -> asyncio.AbstractEventLoop: """Get the event loop.""" loop: asyncio.AbstractEventLoop = super().new_event_loop() diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index 8ca2ef7fef11..173d792ba6e3 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -11,6 +11,7 @@ from homeassistant import runner from homeassistant.auth import auth_manager_from_config from homeassistant.auth.providers import homeassistant as hass_auth from homeassistant.config import get_default_config_dir +from homeassistant.config_entries import ConfigEntries from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -55,6 +56,9 @@ def run(args: Sequence[str] | None) -> None: async def run_command(args: argparse.Namespace) -> None: """Run the command.""" hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) + hass.config_entries = ConfigEntries(hass, {}) + # The device registry migration waits for the config entries to load + await hass.config_entries.async_initialize() dr.async_setup(hass) await asyncio.gather(dr.async_load(hass), er.async_load(hass)) hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 525201f80b75..635bbca59614 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -300,6 +300,8 @@ async def async_check_config(config_dir): hass = core.HomeAssistant(config_dir) loader.async_setup(hass) hass.config_entries = ConfigEntries(hass, {}) + # The device registry migration waits for the config entries to load + await hass.config_entries.async_initialize() dr.async_setup(hass) await ar.async_load(hass) await dr.async_load(hass) diff --git a/mypy.ini b/mypy.ini index 519bd1cb4c6b..e9ddf4e4135d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2037,6 +2037,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gatus.*] +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.generic_hygrostat.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2177,6 +2187,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_health.*] +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.google_photos.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2297,6 +2317,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.harman_luxury.*] +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.hdfury.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3107,6 +3137,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.led_infrared.*] +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.lektrico.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3217,6 +3257,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.litellm.*] +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.litterrobot.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3227,6 +3277,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.llama_cpp.*] +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.local_ip.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3687,6 +3747,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.neopool.*] +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.nest.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index 11af4d2b5af0..01d6af257e39 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -132,6 +132,7 @@ Every check has a code following the | `W7415` | [`home-assistant-sequential-executor-jobs`](#w7415-home-assistant-sequential-executor-jobs) | Sequential `async_add_executor_job` calls should be grouped | | `W7416` | [`home-assistant-missing-has-entity-name`](#w7416-home-assistant-missing-has-entity-name) | Entity class should set `_attr_has_entity_name = True` | | `W7429` | [`home-assistant-unnecessary-format-mac`](#w7429-home-assistant-unnecessary-format-mac) | `format_mac()` is unnecessary with `CONNECTION_NETWORK_MAC` | +| `W7430` | [`home-assistant-serial-port-selector-usb-dependency`](#w7430-home-assistant-serial-port-selector-usb-dependency) | Config flow using `SerialPortSelector` must declare `usb` in `dependencies` | ## `home_assistant_logger` checker @@ -849,3 +850,17 @@ Tuples used for direct comparison against `device.connections` (e.g. the `in` operator, set intersection) are not flagged because those comparisons bypass the device registry normalization and genuinely need `format_mac()` to match the stored normalized format. + + +## `home_assistant_serial_port_selector_usb_dependency` checker + +Detects config flows using `SerialPortSelector` whose `manifest.json` does +not declare `usb` as a hard dependency. + +### `W7430`: `home-assistant-serial-port-selector-usb-dependency` + +`SerialPortSelector` populates its port list via the `usb/list_serial_ports` +websocket command, which is only registered when the `usb` integration is set +up. The selector therefore requires `usb` as a hard dependency +(`"dependencies": ["usb"]`); `after_dependencies` is not sufficient because it +does not force `usb` to be set up. diff --git a/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py b/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py new file mode 100644 index 000000000000..cbdf75cde488 --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/checkers/config_flow/serial_port_usb_dependency.py @@ -0,0 +1,81 @@ +"""Checker for the usb dependency when a config flow uses SerialPortSelector. + +``SerialPortSelector`` populates its port list via the +``usb/list_serial_ports`` websocket command, which is only registered when the +``usb`` integration is set up. Integrations using the selector must therefore +declare ``usb`` as a hard dependency in ``manifest.json`` so it is guaranteed +to be set up; ``after_dependencies`` is not sufficient because it does not +force ``usb`` to be set up. +""" + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +from pylint_home_assistant.const import Module +from pylint_home_assistant.helpers.integration import read_manifest +from pylint_home_assistant.helpers.module_info import parse_module + + +class HassEnforceSerialPortSelectorUsbChecker(BaseChecker): + """Checker for the usb dependency when using SerialPortSelector.""" + + name = "home_assistant_serial_port_selector_usb_dependency" + priority = -1 + msgs = { + "W7430": ( + "Config flow uses SerialPortSelector but the integration does not " + "declare 'usb' in 'dependencies' in manifest.json", + "home-assistant-serial-port-selector-usb-dependency", + "SerialPortSelector populates its port list via the " + "'usb/list_serial_ports' websocket command, which is only " + "registered when the 'usb' integration is set up. The selector " + "therefore requires 'usb' as a hard dependency; " + "'after_dependencies' is not sufficient because it does not force " + "'usb' to be set up.", + ), + } + options = () + + def __init__(self, linter: PyLinter) -> None: + """Initialize the checker.""" + super().__init__(linter) + self._reported_modules: set[str] = set() + + def visit_call(self, node: nodes.Call) -> None: + """Check that SerialPortSelector usage declares the usb dependency.""" + func = node.func + if isinstance(func, nodes.Attribute): + name = func.attrname + elif isinstance(func, nodes.Name): + name = func.name + else: + return + if name != "SerialPortSelector": + return + + parsed = parse_module(node.root().name) + if parsed is None or parsed.module != Module.CONFIG_FLOW: + return + + module_name = node.root().name + if module_name in self._reported_modules: + return + + manifest = read_manifest(node.root()) + if manifest is None: + return + + if "usb" in manifest.get("dependencies", []): + return + + self._reported_modules.add(module_name) + self.add_message( + "home-assistant-serial-port-selector-usb-dependency", + node=node, + ) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceSerialPortSelectorUsbChecker(linter)) diff --git a/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py b/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py index 3d4f0efea9e3..0c29cf5ef95a 100644 --- a/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py +++ b/pylint/plugins/pylint_home_assistant/checkers/runtime_data.py @@ -4,6 +4,10 @@ New integrations should store per-entry data on ``entry.runtime_data`` (typed via a ``type`` alias) rather than the legacy ``hass.data[DOMAIN][entry.entry_id]`` dictionary pattern. The ``runtime_data`` approach is type-safe, automatically cleaned up on unload, and is the current Home Assistant core standard. + +Both the subscript form (``hass.data[DOMAIN]``) and the equivalent method-call +forms (``hass.data.setdefault(DOMAIN, ...)`` and ``hass.data.get(DOMAIN)``) are +flagged. """ from astroid import nodes @@ -54,22 +58,7 @@ class HassEnforceRuntimeDataChecker(BaseChecker): if not _is_hass_data_domain_access(node): return - parsed = parse_module(node.root().name) - if parsed is None: - return - - current_module = parsed.module or "" - if current_module in _SKIP_MODULES: - return - - # Only flag integrations that have a config flow (and thus can use - # entry.runtime_data). YAML-only integrations legitimately need - # hass.data[DOMAIN]. - if not has_config_flow(parsed.domain, node.root()): - return - - func = enclosing_function(node) - if func and func.name in _SKIP_FUNCTIONS: + if not self._should_flag(node): return # Don't flag deletion: del hass.data[DOMAIN] or hass.data[DOMAIN].pop(...) @@ -81,25 +70,78 @@ class HassEnforceRuntimeDataChecker(BaseChecker): self.add_message("home-assistant-use-runtime-data", node=node) + def visit_call(self, node: nodes.Call) -> None: + """Check for hass.data.setdefault(DOMAIN, ...) and hass.data.get(DOMAIN).""" + if not _is_hass_data_domain_call(node): + return + + if not self._should_flag(node): + return + + self.add_message("home-assistant-use-runtime-data", node=node) + + def _should_flag(self, node: nodes.NodeNG) -> bool: + """Return True if node is in an integration that should use runtime_data.""" + parsed = parse_module(node.root().name) + if parsed is None: + return False + + current_module = parsed.module or "" + if current_module in _SKIP_MODULES: + return False + + # Only flag integrations that have a config flow (and thus can use + # entry.runtime_data). YAML-only integrations legitimately need + # hass.data[DOMAIN]. + if not has_config_flow(parsed.domain, node.root()): + return False + + func = enclosing_function(node) + return not (func and func.name in _SKIP_FUNCTIONS) + + +def _is_hass_data(node: nodes.NodeNG) -> bool: + """Return True if node is hass.data or self.hass.data.""" + match node: + case nodes.Attribute( + expr=( + nodes.Name(name="hass") + | nodes.Attribute(expr=nodes.Name(name="self"), attrname="hass") + ), + attrname="data", + ): + return True + case _: + return False + def _is_hass_data_domain_access(node: nodes.Subscript) -> bool: """Return True if node is hass.data[DOMAIN] or self.hass.data[DOMAIN].""" match node: - case nodes.Subscript( - value=nodes.Attribute( - expr=( - nodes.Name(name="hass") - | nodes.Attribute(expr=nodes.Name(name="self"), attrname="hass") - ), - attrname="data", - ), - slice=nodes.Name(name="DOMAIN"), + case nodes.Subscript(value=value, slice=nodes.Name(name="DOMAIN")) if ( + _is_hass_data(value) ): return True case _: return False +def _is_hass_data_domain_call(node: nodes.Call) -> bool: + """Return True for hass.data.setdefault(DOMAIN, ...) or hass.data.get(DOMAIN). + + These read/write DOMAIN data just like the subscript form. Deletion helpers + such as ``hass.data.pop(DOMAIN)`` are intentionally not matched. + """ + match node: + case nodes.Call( + func=nodes.Attribute(expr=value, attrname="setdefault" | "get"), + args=[nodes.Name(name="DOMAIN"), *_], + ) if _is_hass_data(value): + return True + case _: + return False + + def register(linter: PyLinter) -> None: """Register the checker.""" linter.register_checker(HassEnforceRuntimeDataChecker(linter)) diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index f9e4b3d4ebec..bee392ebe525 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest from typing import Final -FRONTEND_VERSION: Final[str] = "20260624.4" +FRONTEND_VERSION: Final[str] = "20260624.6" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/pyproject.toml b/pyproject.toml index 112eb25df626..9a4dfa5693ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ dependencies = [ "PyJWT==2.12.1", # PyJWT has loose dependency. We want the latest one. "cryptography==48.0.1", - "Pillow==12.2.0", + "Pillow==12.3.0", "propcache==0.5.2", "pyOpenSSL==26.2.0", "orjson==3.11.9", @@ -74,7 +74,7 @@ dependencies = [ "typing-extensions>=4.15.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.11.25", + "uv==0.11.28", "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.4.1", @@ -648,7 +648,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.15.20" +required-version = ">=0.15.21" [tool.ruff.lint] select = [ diff --git a/requirements.txt b/requirements.txt index 4cc45c51c995..ae925cb00902 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,13 +30,13 @@ home-assistant-bluetooth==2.0.0 home-assistant-intents==2026.6.24 httpx==0.28.1 ifaddr==0.2.0 -infrared-protocols==6.3.1 +infrared-protocols==7.5.0 Jinja2==3.1.6 lru-dict==1.4.1 mutagen==1.48.1 orjson==3.11.9 packaging>=23.1 -Pillow==12.2.0 +Pillow==12.3.0 propcache==0.5.2 psutil-home-assistant==0.0.1 PyJWT==2.12.1 @@ -55,7 +55,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.15.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.11.25 +uv==0.11.28 voluptuous-openapi==0.4.1 voluptuous-serialize==2.7.0 voluptuous==0.15.2 diff --git a/requirements_all.txt b/requirements_all.txt index 07c3d7b2d673..ceb7f8943a28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.35 +AIOSomecomfort==0.0.38 # homeassistant.components.adax Adax-local==0.3.0 @@ -38,7 +38,7 @@ PSNAWP==3.0.3 # homeassistant.components.qrcode # homeassistant.components.seven_segments # homeassistant.components.sighthound -Pillow==12.2.0 +Pillow==12.3.0 # homeassistant.components.plex PlexAPI==4.15.16 @@ -99,7 +99,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.3 # homeassistant.components.vicare -PyViCare==2.60.2 +PyViCare==2.61.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -181,7 +181,7 @@ aio-ownet==0.0.5 aio-wattwaechter==1.0.0 # homeassistant.components.acaia -aioacaia==0.1.18 +aioacaia==0.2.0 # homeassistant.components.airq aioairq==0.4.8 @@ -193,7 +193,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==14.1.8 +aioamazondevices==14.2.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -206,7 +206,7 @@ aioapcaccess==1.0.0 aioaquacell==1.0.0 # homeassistant.components.vistapool -aioaquarite==0.5.1 +aioaquarite==0.6.1 # homeassistant.components.aseko_pool_live aioaseko==1.0.0 @@ -260,7 +260,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==45.5.2 +aioesphomeapi==45.6.0 # homeassistant.components.matrix # homeassistant.components.slack @@ -281,6 +281,9 @@ aiogithubapi==26.0.0 # homeassistant.components.guardian aioguardian==2026.01.1 +# homeassistant.components.harman_luxury +aioharmanluxury==0.2.3 + # homeassistant.components.harmony aioharmony==1.0.8 @@ -303,11 +306,14 @@ aiohue==4.8.1 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.15.1 +aioimmich==0.16.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 +# homeassistant.components.karakeep +aiokarakeep==0.3.0 + # homeassistant.components.kef aiokef==0.2.16 @@ -336,7 +342,7 @@ aiolyric==2.1.1 aiomealie==1.2.4 # homeassistant.components.melcloud_home -aiomelcloudhome==0.1.9 +aiomelcloudhome==0.2.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -401,7 +407,7 @@ aiorecollect==2023.09.0 aioridwell==2025.09.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.42 +aioruckus==0.46.3 # homeassistant.components.russound_rio # homeassistant.components.russound_rnet @@ -411,7 +417,7 @@ aiorussound==5.0.1 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.26.2 +aioshelly==13.27.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -566,7 +572,7 @@ asusrouter==1.21.3 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.46.2 +async-upnp-client==0.47.0 # homeassistant.components.arve asyncarve==0.1.1 @@ -606,7 +612,7 @@ av==17.0.1 avea==1.8.0 # homeassistant.components.axis -axis==72 +axis==74 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 @@ -648,7 +654,7 @@ beautifulsoup4==4.13.3 bizkaibus==0.1.1 # homeassistant.components.esphome -bleak-esphome==3.9.4 +bleak-esphome==3.9.7 # homeassistant.components.bluetooth bleak-retry-connector==4.6.1 @@ -823,7 +829,7 @@ deluge-client==1.10.2 demetriek==1.3.0 # homeassistant.components.denon_rs232 -denon-rs232==4.1.0 +denon-rs232==4.2.1 # homeassistant.components.denonavr denonavr==1.3.3 @@ -856,7 +862,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.9.0 +dsmr-parser==1.11.1 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 @@ -919,7 +925,7 @@ emoji==2.8.0 emulated-roku==0.3.0 # homeassistant.components.energieleser -energieleser==0.1.4 +energieleser==0.1.5 # homeassistant.components.huisbaasje energyflip-client==0.2.2 @@ -1071,6 +1077,9 @@ gardena-bluetooth==2.8.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 +# homeassistant.components.gatus +gatus-api==1.0.3 + # homeassistant.components.google gcal-sync==8.0.0 @@ -1133,6 +1142,9 @@ google-cloud-texttospeech==2.25.1 # homeassistant.components.google_generative_ai_conversation google-genai==1.59.0 +# homeassistant.components.google_health +google-health-api==0.5.1 + # homeassistant.components.google_travel_time google-maps-routing==0.6.15 @@ -1186,7 +1198,7 @@ growattServer==2.1.0 gspread==5.5.0 # homeassistant.components.guntamatic -guntamatic==1.9.0 +guntamatic==1.9.2 # homeassistant.components.profiler guppy3==3.1.7 @@ -1198,10 +1210,10 @@ h2==4.3.0 ha-ffmpeg==3.2.2 # homeassistant.components.iotawatt -ha-iotawattpy==0.1.2 +ha-iotawattpy==0.2.1 # homeassistant.components.philips_js -ha-philipsjs==3.2.4 +ha-philipsjs==3.2.5 # homeassistant.components.homeassistant_hardware ha-silabs-firmware-client==0.3.0 @@ -1213,11 +1225,14 @@ ha-xthings-cloud==1.0.5 habiticalib==0.4.7 # homeassistant.components.bluetooth -habluetooth==6.26.2 +habluetooth==6.26.5 # homeassistant.components.hanna hanna-cloud==0.0.7 +# homeassistant.components.harbor +harbor-python==1.2.1 + # homeassistant.components.cloud hass-nabucasa==2.2.0 @@ -1263,10 +1278,10 @@ hole==0.9.2 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.99 +holidays==0.100 # homeassistant.components.frontend -home-assistant-frontend==20260624.4 +home-assistant-frontend==20260624.6 # homeassistant.components.conversation home-assistant-intents==2026.6.24 @@ -1344,7 +1359,7 @@ ihcsdk==2.8.12 imeon_inverter_api==0.4.0 # homeassistant.components.imgw_pib -imgw_pib==2.4.0 +imgw_pib==2.4.3 # homeassistant.components.incomfort incomfort-client==0.7.0 @@ -1359,7 +1374,7 @@ influxdb-client==1.50.0 influxdb==5.3.2 # homeassistant.components.infrared -infrared-protocols==6.3.1 +infrared-protocols==7.5.0 # homeassistant.components.inkbird inkbird-ble==1.4.4 @@ -1368,7 +1383,7 @@ inkbird-ble==1.4.4 insteon-frontend-home-assistant==0.6.2 # homeassistant.components.intellifire -intellifire4py==4.4.0 +intellifire4py==4.5.0 # homeassistant.components.iometer iometer==1.0.2 @@ -1426,7 +1441,7 @@ knocki==0.4.2 knx-frontend==2026.6.23.203726 # homeassistant.components.knx -knx-telegram-store[sqlite]==0.3.2 +knx-telegram-store[sqlite,postgres]==0.10.1 # homeassistant.components.kraken krakenex==2.2.2 @@ -1518,6 +1533,9 @@ lw12==0.9.2 # homeassistant.components.scrape lxml==6.1.1 +# homeassistant.components.lyngdorf +lyngdorf==1.3.3 + # homeassistant.components.matrix matrix-nio==0.25.2 @@ -1580,7 +1598,7 @@ millheater==0.14.1 minio==7.1.12 # homeassistant.components.mitsubishi_comfort -mitsubishi-comfort==0.3.2 +mitsubishi-comfort==0.5.0 # homeassistant.components.moat moat-ble==0.1.1 @@ -1591,6 +1609,9 @@ moehlenhoff-alpha2==1.4.0 # homeassistant.components.route_b_smart_meter momonga==0.3.0 +# homeassistant.components.monarch_money +monarchmoneycommunity==1.5.1 + # homeassistant.components.monzo monzopy==1.5.1 @@ -1613,7 +1634,7 @@ mozart-api==6.2.0.44.0 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.3.6 +music-assistant-client==1.4.3 # homeassistant.components.tts mutagen==1.48.1 @@ -1624,9 +1645,6 @@ mutesync==0.0.1 # homeassistant.components.mvglive mvg==1.4.0 -# homeassistant.components.permobil -mypermobil==0.1.8 - # homeassistant.components.myuplink myuplink==0.7.0 @@ -1636,6 +1654,9 @@ nad-receiver==0.3.0 # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 +# homeassistant.components.neopool +neopool-modbus==3.6.0 + # homeassistant.components.ness_alarm nessclient==1.3.1 @@ -1740,7 +1761,7 @@ ondilo==0.5.0 onedrive-personal-sdk==0.1.7 # homeassistant.components.onvif -onvif-zeep-async==4.2.0 +onvif-zeep-async==4.2.1 # homeassistant.components.onvif onvif_parsers==2.3.0 @@ -1752,10 +1773,12 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.cloud +# homeassistant.components.litellm +# homeassistant.components.llama_cpp # homeassistant.components.open_router # homeassistant.components.openai_conversation # homeassistant.components.ovhcloud_ai_endpoints -openai==2.21.0 +openai==2.45.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1782,7 +1805,7 @@ openwrt-ubus-rpc==0.0.3 opower==0.18.6 # homeassistant.components.oralb -oralb-ble==1.1.0 +oralb-ble==1.1.1 # homeassistant.components.oru oru==0.1.11 @@ -1942,7 +1965,7 @@ py-schluter==0.1.7 py-sucks==0.9.11 # homeassistant.components.synology_dsm -py-synologydsm-api==2.10.2 +py-synologydsm-api==2.10.3 # homeassistant.components.unifi_access py-unifi-access==1.3.0 @@ -1954,7 +1977,7 @@ pyAtome==0.1.2 pyCEC==0.5.2 # homeassistant.components.control4 -pyControl4==1.5.0 +pyControl4==2.0.2 # homeassistant.components.duotecno pyDuotecno==2024.10.1 @@ -1969,7 +1992,7 @@ pyEmby==1.10 pyHik==0.4.3 # homeassistant.components.homee -pyHomee==1.4.1 +pyHomee==1.4.2 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -1987,7 +2010,7 @@ pyW215==0.8.0 pyW800rf32==0.4 # homeassistant.components.ccm15 -py_ccm15==1.0.0 +py_ccm15==1.1.2 # homeassistant.components.html5 py_vapid==1.9.4 @@ -2002,10 +2025,10 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.3.1 +pyairnow==1.4.0 # homeassistant.components.airobot -pyairobotrest==0.3.0 +pyairobotrest==0.4.0 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro @@ -2050,6 +2073,9 @@ pyblu==2.0.8 # homeassistant.components.neato pybotvac==0.0.29 +# homeassistant.components.fuelprices_dk +pybraendstofpriser==2.2.0 + # homeassistant.components.braviatv pybravia==0.4.1 @@ -2087,7 +2113,7 @@ pycsspeechtts==1.0.8 pycync==0.5.0 # homeassistant.components.daikin -pydaikin==2.18.1 +pydaikin==2.18.2 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -2232,7 +2258,7 @@ pyialarm==2.2.0 pyicloud==2.6.5 # homeassistant.components.imou -pyimouapi==1.2.8 +pyimouapi==1.3.0 # homeassistant.components.insteon pyinsteon==1.6.4 @@ -2301,7 +2327,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.4.1 +pylamarzocco==2.4.2 # homeassistant.components.lastfm pylast==5.1.0 @@ -2325,10 +2351,10 @@ pylitejet==0.6.3 pylitterbot==2025.5.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.28.0 +pylutron-caseta==0.29.0 # homeassistant.components.lutron -pylutron==0.4.1 +pylutron==0.4.2 # homeassistant.components.mailgun pymailgunner==1.4 @@ -2432,7 +2458,7 @@ pyotgw==2.2.3 pyotp==2.9.0 # homeassistant.components.overkiz -pyoverkiz[nexity]==2.0.3 +pyoverkiz[nexity]==2.1.0 # homeassistant.components.palazzetti pypalazzetti==0.1.20 @@ -2459,7 +2485,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.38 +pyportainer==1.0.42 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2486,7 +2512,7 @@ pyqwikswitch==0.93 pyrail==0.4.1 # homeassistant.components.rainbird -pyrainbird==6.3.1 +pyrainbird==6.5.0 # homeassistant.components.playstation_network pyrate-limiter==4.4.0 @@ -2516,11 +2542,14 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.saunum -pysaunum==0.6.0 +pysaunum==0.7.0 # homeassistant.components.schlage pyschlage==2025.9.0 +# homeassistant.components.scorpiontrack +pyscorpiontrack==0.1.1 + # homeassistant.components.sensibo pysensibo==1.2.1 @@ -2564,7 +2593,7 @@ pysmhi==2.0.0 pysml==0.1.8 # homeassistant.components.smlight -pysmlight==0.5.2 +pysmlight==0.5.3 # homeassistant.components.snmp pysnmp==7.1.27 @@ -2585,7 +2614,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.14.0 # homeassistant.components.stiebel_eltron -pystiebeleltron==0.2.5 +pystiebeleltron==0.5.0 # homeassistant.components.suez_water pysuezV2==2.0.7 @@ -2615,7 +2644,7 @@ python-awair==0.2.5 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==6.1.4 +python-bsblan==6.1.6 # homeassistant.components.citybikes python-citybikes==0.3.3 @@ -2627,7 +2656,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.7.1 +python-duco-connectivity==0.10.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 @@ -2663,7 +2692,7 @@ python-homewizard-energy==10.1.0 python-hpilo==4.4.3 # homeassistant.components.izone -python-izone==1.2.10 +python-izone==1.3.4 # homeassistant.components.joaoapps_join python-join-api==0.1.1 @@ -2712,7 +2741,7 @@ python-picnic-api2==1.3.4 python-pooldose==0.9.6 # homeassistant.components.hr_energy_qube -python-qube-heatpump==1.11.0 +python-qube-heatpump==1.12.0 # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2721,7 +2750,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==5.22.0 +python-roborock==5.30.0 # homeassistant.components.smarttub python-smarttub==0.0.47 @@ -2736,7 +2765,7 @@ python-snoo==0.8.3 python-songpal==0.16.2 # homeassistant.components.swisscom -python-swisscom-internet-box==0.1.1 +python-swisscom-internet-box==0.2.0 # homeassistant.components.tado python-tado==0.18.16 @@ -2785,7 +2814,7 @@ pytradfri[async]==9.0.1 pytrafikverket==1.1.1 # homeassistant.components.v2c -pytrydan==1.0.3 +pytrydan==1.0.4 # homeassistant.components.uptimerobot pyuptimerobot==25.0.0 @@ -2827,7 +2856,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.4.0 +pywmspro==0.4.2 # homeassistant.components.ws66i pyws66i==1.1 @@ -2890,7 +2919,7 @@ renault-api==0.5.12 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.21.3 +reolink-aio==0.21.4 # homeassistant.components.radio_frequency rf-protocols==4.3.0 @@ -2960,7 +2989,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.14.1 +sense-energy==0.14.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 @@ -3143,13 +3172,13 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.5.2 +tesla-fleet-api==1.7.2 # homeassistant.components.powerwall tesla-powerwall==0.5.3 # homeassistant.components.tesla_wall_connector -tesla-wall-connector==1.1.0 +tesla-wall-connector==1.2.0 # homeassistant.components.teslemetry teslemetry-stream==0.9.1 @@ -3191,10 +3220,10 @@ tololib==1.2.2 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2025.12.2 +total-connect-client==2026.7 # homeassistant.components.tplink_omada -tplink-omada-client==1.5.8 +tplink-omada-client==1.5.9 # homeassistant.components.transmission transmission-rpc==7.0.3 @@ -3212,7 +3241,7 @@ ttls==1.8.3 ttn_client==1.3.0 # homeassistant.components.tuya -tuya-device-handlers==0.0.24 +tuya-device-handlers==0.0.25 # homeassistant.components.tuya tuya-device-sharing-sdk==0.2.10 @@ -3226,9 +3255,6 @@ twilio==6.32.0 # homeassistant.components.twitch twitchAPI==4.2.1 -# homeassistant.components.monarch_money -typedmonarchmoney==0.7.0 - # homeassistant.components.ukraine_alarm uasiren==0.0.1 @@ -3236,7 +3262,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.4.0 +uiprotect==15.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1 @@ -3280,7 +3306,7 @@ vehicle==3.0.0 velbus-aio==2026.4.1 # homeassistant.components.venstar -venstarcolortouch==0.21 +venstarcolortouch==0.22 # homeassistant.components.viaggiatreno viaggiatreno_ha==0.2.4 @@ -3289,7 +3315,7 @@ viaggiatreno_ha==0.2.4 victron-ble-ha-parser==0.7.0 # homeassistant.components.victron_gx -victron-mqtt==2026.6.6 +victron-mqtt==2026.7.4 # homeassistant.components.victron_remote_monitoring victron-vrm==0.1.12 @@ -3359,7 +3385,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wiim -wiim==0.1.4 +wiim==0.1.5 # homeassistant.components.wirelesstag wirelesstagpy==0.8.1 @@ -3444,10 +3470,10 @@ zeroconf==0.150.0 zeversolar==0.3.2 # homeassistant.components.zha -zha-quirks==2.1.0 +zha-quirks==2.1.1 # homeassistant.components.zha -zha==2.0.0 +zha==2.0.1 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index bd0ad2d1b002..797ef3a7fa85 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,15 +8,15 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt # ast-serialize is an internal mypy dependency -ast-serialize==0.3.0 +ast-serialize==0.6.0 astroid==4.0.4 -coverage==7.14.3 +coverage==7.15.0 freezegun==1.5.5 # librt is an internal mypy dependency -librt==0.11.0 +librt==0.13.0 license-expression==30.4.3 mock-open==1.4.0 -mypy==2.1.0 +mypy==2.3.0 prek==0.2.28 pydantic==2.13.4 PyGithub==2.9.1 @@ -38,7 +38,7 @@ pytest==9.0.3 requests==2.34.2 requests-mock==1.12.1 respx==0.23.1 -syrupy==5.3.2 +syrupy==5.5.2 tqdm==4.67.1 types-aiofiles==24.1.0.20250822 types-atomicwrites==1.4.5.1 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8ef2ed276ee8..a4fecbd60d51 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.2 -ruff==0.15.20 +ruff==0.15.21 yamllint==1.38.0 zizmor==1.24.1 diff --git a/rootfs/etc/services.d/home-assistant/run b/rootfs/etc/services.d/home-assistant/run index 40ec07c15431..950c6a6250e3 100755 --- a/rootfs/etc/services.d/home-assistant/run +++ b/rootfs/etc/services.d/home-assistant/run @@ -5,9 +5,14 @@ cd /config || bashio::exit.nok "Can't find config folder!" -# Enable mimalloc for Home Assistant Core, unless disabled -if [[ -z "${DISABLE_JEMALLOC+x}" ]]; then - export LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" - export MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" +# Use mimalloc as Python's object allocator by default. It is bundled in CPython +# (3.13+), so no LD_PRELOAD or extra library is required, and it uses noticeably +# less memory than the previously preloaded jemalloc. Override or disable via +# PYTHONMALLOC, e.g. `PYTHONMALLOC=pymalloc` for the default allocator. +export PYTHONMALLOC="${PYTHONMALLOC:-mimalloc}" + +if [[ -n "${DISABLE_JEMALLOC+x}" ]]; then + bashio::log.warning "DISABLE_JEMALLOC is set but no longer has any effect: jemalloc has been replaced by mimalloc. Set PYTHONMALLOC=pymalloc to use the default allocator instead." fi + exec python3 -m homeassistant --config /config diff --git a/script/gen_copilot_instructions.py b/script/gen_copilot_instructions.py index 3e118479b69b..5f36d3ef2ed0 100755 --- a/script/gen_copilot_instructions.py +++ b/script/gen_copilot_instructions.py @@ -17,6 +17,7 @@ INTEGRATION_SKILL_FILE = Path(".claude/skills/ha-integration-knowledge/SKILL.md" INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE = Path( ".github/instructions/integrations.instructions.md" ) +PR_TEMPLATE_FILE = Path(".github/PULL_REQUEST_TEMPLATE.md") COPILOT_SPECIFIC_INSTRUCTIONS = """ # Copilot code review instructions @@ -25,6 +26,15 @@ COPILOT_SPECIFIC_INSTRUCTIONS = """ - Do not comment on code style, formatting or linting issues. - Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line). - A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR. +- Check that the PR description is complete and filled in according to the PR template included below. Every section and checklist item from the template must be present, except the `## Breaking change` section which is optional. No content from the template should be missing, except for HTML comments. Even unchecked checkboxes or empty sections must be present. This is a hard requirement. + +## Pull Request template + +The PR description must follow this template (from `.github/PULL_REQUEST_TEMPLATE.md`): + +```markdown +{pr_template} +``` """ INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS = """--- @@ -69,7 +79,15 @@ def generate_output() -> str: print(f"Error: {AGENTS_FILE} not found") sys.exit(1) - output_parts: list[str] = [GENERATED_MESSAGE, COPILOT_SPECIFIC_INSTRUCTIONS] + if not PR_TEMPLATE_FILE.exists(): + print(f"Error: {PR_TEMPLATE_FILE} not found") + sys.exit(1) + + copilot_instructions = COPILOT_SPECIFIC_INSTRUCTIONS.replace( + "{pr_template}", PR_TEMPLATE_FILE.read_text().strip() + ) + + output_parts: list[str] = [GENERATED_MESSAGE, copilot_instructions] # Add AGENTS.md content agents_content = AGENTS_FILE.read_text() diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 63de1db99152..e70e25cf2d87 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -239,6 +239,10 @@ azure-kusto-data==4.5.1 azure-kusto-ingest==4.5.1 coloredlogs==15.0.1 setuptools==81.0.0 + +# Pin cffi to 2.0.0 to avoid version mismatch with the pre-baked _cffi_backend in the base image. +# https://github.com/home-assistant/core/issues/175832 +cffi==2.0.0 """ GENERATED_MESSAGE = ( diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 9ca4987719f6..fb23e8b3c957 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -126,6 +126,7 @@ NO_IOT_CLASS = [ "temperature", "timer", "trace", + "vibration", "web_rtc", "webhook", "websocket_api", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 4a46a5ca06a3..b04a7c801e12 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -546,7 +546,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "london_underground", "lookin", "loqed", - "luci", "luftdaten", "lupusec", "lutron", @@ -621,7 +620,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nasweb", "neato", "nederlandse_spoorwegen", - "netatmo", "netdata", "netgear", "netgear_lte", @@ -684,7 +682,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "otbr", "otp", "ourgroceries", - "overkiz", "ovo_energy", "owntracks", "p1_monitor", @@ -862,7 +859,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "statsd", "steam_online", "steamist", - "stiebel_eltron", "stream", "streamlabswater", "subaru", @@ -1498,7 +1494,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "london_underground", "lookin", "loqed", - "luci", "luftdaten", "lupusec", "lutron", @@ -1576,7 +1571,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "nederlandse_spoorwegen", "nest", "ness_alarm", - "netatmo", "netdata", "netgear", "netgear_lte", @@ -1651,7 +1645,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "panel_iframe", "peco", "pencom", - "permobil", "persistent_notification", "person", "philips_js", @@ -1827,7 +1820,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "statsd", "steam_online", "steamist", - "stiebel_eltron", "stream", "streamlabswater", "stookwijzer", @@ -2084,6 +2076,7 @@ NO_QUALITY_SCALE = [ "timer", "trace", "usage_prediction", + "vibration", "web_rtc", "webhook", "websocket_api", @@ -2095,7 +2088,7 @@ SCHEMA = vol.Schema( { vol.Required("rules"): vol.Schema( { - vol.Optional(rule.name): vol.Any( + vol.Required(rule.name): vol.Any( vol.In(["todo", "done"]), vol.Schema( { diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 5ce6510ebd7b..db43686410e6 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -217,7 +217,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "surepetcare": {"surepy": {"async-timeout"}}, "tailwind": {"gotailwind": {"backoff"}}, "technove": {"python-technove": {"backoff"}}, - "tesla_wall_connector": {"tesla-wall-connector": {"backoff"}}, "tibber": {"gql": {"backoff"}}, "toon": {"toonapi": {"backoff"}}, "travisci": { @@ -270,6 +269,8 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = { "pbr": {"setuptools"} }, "coinbase": {"homeassistant": {"coinbase-advanced-py"}}, + # https://github.com/lawtancool/pyControl4 - ships tests/ in wheel + "control4": {"homeassistant": {"pycontrol4"}}, # https://github.com/u9n/dlms-cosem "dsmr": {"dsmr-parser": {"dlms-cosem"}}, # https://github.com/tkdrob/pyefergy diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 7369ed7b7310..f9f2d1df308d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -45,7 +45,6 @@ ALLOW_NAME_TRANSLATION = { "local_calendar", "local_ip", "local_todo", - "modbus", "nmap_tracker", "remote_calendar", "rpi_power", diff --git a/script/licenses.py b/script/licenses.py index 38a14d711c57..0d4dac8d84b7 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -83,6 +83,7 @@ OSI_APPROVED_LICENSES_SPDX = { "LGPL-3.0-only", "LGPL-3.0-or-later", "MIT", + "MIT-0", "MIT-CMU", "MPL-1.1", "MPL-2.0", @@ -204,9 +205,6 @@ EXCEPTIONS = { # fmt: off TODO = { "TravisPy": AwesomeVersion("0.3.5"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)'] - "aiocache": AwesomeVersion( - "0.12.3" - ), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved? } # fmt: on diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 19b2ab406674..226f375c2fbd 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN @@ -94,9 +93,9 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN): ) -class CannotConnect(HomeAssistantError): +class CannotConnect(Exception): """Error to indicate we cannot connect.""" -class InvalidAuth(HomeAssistantError): +class InvalidAuth(Exception): """Error to indicate there is invalid auth.""" diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 66209f77e6a1..e2d8952396b1 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -30,7 +30,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" @@ -81,7 +80,6 @@ async def test_form_invalid_auth( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" @@ -133,7 +131,6 @@ async def test_form_cannot_connect( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Name of the device" diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index cb96c9396c2b..df30a4b766dc 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -204,7 +204,14 @@ def test_entities_areas_area_true(hass: HomeAssistant) -> None: }, ) device_registry = mock_device_registry( - hass, {"mock-dev-id": DeviceEntry(id="mock-dev-id", area_id="mock-area-id")} + hass, + { + "mock-dev-id": DeviceEntry( + config_entry_id="mock-config-entry", + id="mock-dev-id", + area_id="mock-area-id", + ) + }, ) policy = {"area_ids": {"mock-area-id": {"read": True, "control": True}}} diff --git a/tests/common.py b/tests/common.py index 474863bfaa17..60000058bff4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -292,6 +292,7 @@ async def async_test_home_assistant( ) }, ) + hass.config_entries._initialized.set() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, hass.config_entries._async_shutdown, @@ -677,7 +678,7 @@ def mock_registry( if mock_entries is None: mock_entries = {} registry.deleted_entities = {} - registry.entities = er.EntityRegistryItems() + registry.entities = er.EntityRegistryItems(hass) registry._entities_data = registry.entities.data for key, entry in mock_entries.items(): registry.entities[key] = entry @@ -763,7 +764,7 @@ def mock_device_registry( mock_entries = {} for key, entry in mock_entries.items(): registry.devices[key] = entry - registry.deleted_devices = dr.DeviceRegistryItems() + registry.deleted_devices = dr.DeletedDeviceRegistryItems() hass.data[dr.DATA_REGISTRY] = registry return registry diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py index ff151f3b0969..e7b4fc2bf914 100644 --- a/tests/components/acaia/conftest.py +++ b/tests/components/acaia/conftest.py @@ -3,8 +3,8 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from aioacaia.acaiascale import AcaiaDeviceState from aioacaia.const import UnitMass as AcaiaUnitOfMass +from aioacaia.scale import AcaiaDeviceState import pytest from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index 9e3112606936..8ab2584f2948 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'LUNAR-DDEEFF', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ai_task/test_media_source.py b/tests/components/ai_task/test_media_source.py index f41992f74cde..883f3953562b 100644 --- a/tests/components/ai_task/test_media_source.py +++ b/tests/components/ai_task/test_media_source.py @@ -26,6 +26,9 @@ async def test_local_media_source(hass: HomeAssistant, init_components: None) -> ) assert source.url_prefix == "/ai_task" + +async def test_media_source_no_media_dirs(hass: HomeAssistant) -> None: + """Test an error is raised when no media directories are configured.""" hass.config.media_dirs = {} with pytest.raises( diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index a15cec0b466c..2a5add49a9bc 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -16,10 +16,11 @@ from homeassistant.components.ai_task import ( 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.components.llm import AssistAPI from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, llm +from homeassistant.helpers import chat_session from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -77,7 +78,7 @@ async def test_generate_data_preferred_entity( assert state is not None assert state.state == STATE_UNKNOWN - llm_api = llm.AssistAPI(hass) + llm_api = AssistAPI(hass) result = await async_generate_data( hass, task_name="Test Task", diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index 2a1e3dcc7fd7..b5af12e65a2e 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[indoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'I-9PSL', 'name': 'Airgradient', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '84fce612f5b8', 'sw_version': '3.1.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device_info[outdoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': 'O-1PPT', 'name': 'Airgradient', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '84fce612f5b8', 'sw_version': '3.1.1', 'via_device_id': None, diff --git a/tests/components/airnow/fixtures/response.json b/tests/components/airnow/fixtures/response.json index 91029f5531f2..63877e167a90 100644 --- a/tests/components/airnow/fixtures/response.json +++ b/tests/components/airnow/fixtures/response.json @@ -1,47 +1,47 @@ [ { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "O3", - "AQI": 44, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "OZONE", + "nowcastAQI": 44, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" }, { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM2.5", - "AQI": 37, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "PM2.5", + "nowcastAQI": 37, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" }, { - "DateObserved": "2020-12-20", - "HourObserved": 15, - "LocalTimeZone": "PST", - "ReportingArea": "Central LA CO", - "StateCode": "CA", - "Latitude": 34.0663, - "Longitude": -118.2266, - "ParameterName": "PM10", - "AQI": 11, - "Category": { - "Number": 1, - "Name": "Good" - } + "dateObserved": "2020-12-20", + "hourObserved": "15:00", + "localTimeZone": "PST", + "reportingAreaName": "Central LA CO", + "siteID": "060371103", + "siteName": "Los Angeles - N. Main Street", + "parameterName": "PM10", + "nowcastAQI": 11, + "aqiCategoryName": "Good", + "reportingAgency": "South Coast AQMD", + "lookupBehavior": "Closest Reading By Pollutant", + "consideredMonitors": "All", + "lookupBoundary": "50 Miles" } ] diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index d711f9c2eba1..72cb584adc6a 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -7,15 +7,15 @@ 'Category.Number': 1, 'DateObserved': '2020-12-20', 'HourObserved': 15, - 'Latitude': '**REDACTED**', + 'Latitude': None, 'LocalTimeZone': 'PST', - 'Longitude': '**REDACTED**', + 'Longitude': None, 'O3': 0.048, 'PM10': 12, 'PM2.5': 6.7, 'Pollutant': 'O3', 'ReportingArea': '**REDACTED**', - 'StateCode': '**REDACTED**', + 'StateCode': '', }), 'entry': dict({ 'data': dict({ diff --git a/tests/components/airobot/snapshots/test_init.ambr b/tests/components/airobot/snapshots/test_init.ambr index b7e2957b834c..7e62c1dff0c2 100644 --- a/tests/components/airobot/snapshots/test_init.ambr +++ b/tests/components/airobot/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'TE1', 'name': 'Test Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.44', 'via_device_id': None, diff --git a/tests/components/airobot/test_button.py b/tests/components/airobot/test_button.py index 529605836a99..4f638122926c 100644 --- a/tests/components/airobot/test_button.py +++ b/tests/components/airobot/test_button.py @@ -71,30 +71,6 @@ async def test_restart_button_error( mock_airobot_client.reboot_thermostat.assert_called_once() -@pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize( - "exception", - [AirobotConnectionError("Connection lost"), AirobotTimeoutError("Timeout")], -) -async def test_restart_button_connection_errors( - hass: HomeAssistant, - mock_airobot_client: AsyncMock, - exception: Exception, -) -> None: - """Test restart button handles connection/timeout errors gracefully.""" - mock_airobot_client.reboot_thermostat.side_effect = exception - - # Should not raise an error - connection errors during reboot are expected - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.test_thermostat_restart"}, - blocking=True, - ) - - mock_airobot_client.reboot_thermostat.assert_called_once() - - @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_recalibrate_co2_button( hass: HomeAssistant, @@ -112,12 +88,21 @@ async def test_recalibrate_co2_button( @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +@pytest.mark.parametrize( + "exception", + [ + AirobotError("Test error"), + AirobotConnectionError("Connection lost"), + AirobotTimeoutError("Timeout"), + ], +) async def test_recalibrate_co2_button_error( hass: HomeAssistant, mock_airobot_client: AsyncMock, + exception: Exception, ) -> None: """Test recalibrate CO2 sensor button error handling.""" - mock_airobot_client.recalibrate_co2_sensor.side_effect = AirobotError("Test error") + mock_airobot_client.recalibrate_co2_sensor.side_effect = exception with pytest.raises(HomeAssistantError): await hass.services.async_call( diff --git a/tests/components/airthings/snapshots/test_sensor.ambr b/tests/components/airthings/snapshots/test_sensor.ambr index b6764ea68b75..c69c031c1f60 100644 --- a/tests/components/airthings/snapshots/test_sensor.ambr +++ b/tests/components/airthings/snapshots/test_sensor.ambr @@ -380,24 +380,25 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Radon', 'platform': 'airthings', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'radon', + 'translation_key': None, 'unique_id': '2960000001_radonShortTermAvg', - 'unit_of_measurement': 'Bq/m³', + 'unit_of_measurement': , }) # --- # name: test_all_device_types[view_plus][sensor.living_room_radon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'radon', : 'Living Room Radon', : , - : 'Bq/m³', + : , }), 'context': , 'entity_id': 'sensor.living_room_radon', @@ -1255,24 +1256,25 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Radon', 'platform': 'airthings', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'radon', + 'translation_key': None, 'unique_id': '2930000002_radonShortTermAvg', - 'unit_of_measurement': 'Bq/m³', + 'unit_of_measurement': , }) # --- # name: test_all_device_types[wave_plus][sensor.office_radon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'radon', : 'Office Radon', : , - : 'Bq/m³', + : , }), 'context': , 'entity_id': 'sensor.office_radon', diff --git a/tests/components/airvisual_pro/snapshots/test_init.ambr b/tests/components/airvisual_pro/snapshots/test_init.ambr index e2fef8910baa..d0bf6ac327d8 100644 --- a/tests/components/airvisual_pro/snapshots/test_init.ambr +++ b/tests/components/airvisual_pro/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1826', 'via_device_id': None, diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index 0c49ce7864fa..28219ec758dc 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -23,7 +23,7 @@ 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"], + capabilities=["AUDIO_PLAYER", "MICROPHONE", "ALEXA_DEVICE_REBOOT"], device_family="mine", device_type="echo", household_device=False, @@ -87,7 +87,7 @@ TEST_DEVICE_1 = AmazonDevice( TEST_DEVICE_2_SN = "echo_test_2_serial_number" TEST_DEVICE_2 = AmazonDevice( account_name="Echo Test 2", - capabilities=["AUDIO_PLAYER", "MICROPHONE"], + capabilities=["AUDIO_PLAYER", "MICROPHONE", "ALEXA_DEVICE_REBOOT"], device_family="mine", device_type="echo", household_device=True, diff --git a/tests/components/alexa_devices/snapshots/test_button.ambr b/tests/components/alexa_devices/snapshots/test_button.ambr index b2dbec7a96f2..8482acd11f60 100644 --- a/tests/components/alexa_devices/snapshots/test_button.ambr +++ b/tests/components/alexa_devices/snapshots/test_button.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_all_entities[button.echo_test_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.echo_test_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'echo_test_serial_number-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.echo_test_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'Echo Test Restart', + }), + 'context': , + 'entity_id': 'button.echo_test_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[button.fake_email_gmail_com_test_routine-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 8c30470005f6..06073892f9ea 100644 --- a/tests/components/alexa_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -5,6 +5,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'device cluster members': dict({ 'echo_test_serial_number': 'echo_test_device_id', @@ -43,6 +44,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'device cluster members': dict({ 'echo_test_serial_number': 'echo_test_device_id', diff --git a/tests/components/alexa_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr index e4ae777da32b..ded6fc40e291 100644 --- a/tests/components/alexa_devices/snapshots/test_init.ambr +++ b/tests/components/alexa_devices/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'echo', 'name': 'Echo Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'echo_test_serial_number', 'sw_version': 'echo_test_software_version', 'via_device_id': None, diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr index cf333f088247..511de9b64ff3 100644 --- a/tests/components/alexa_devices/snapshots/test_services.ambr +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -7,6 +7,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'communication_settings': dict({ 'announcements': 'ON', @@ -83,6 +84,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'communication_settings': dict({ 'announcements': 'ON', @@ -159,6 +161,7 @@ 'capabilities': list([ 'AUDIO_PLAYER', 'MICROPHONE', + 'ALEXA_DEVICE_REBOOT', ]), 'communication_settings': dict({ 'announcements': 'ON', diff --git a/tests/components/alexa_devices/test_button.py b/tests/components/alexa_devices/test_button.py index a1bba9b7e1a9..265ae612b208 100644 --- a/tests/components/alexa_devices/test_button.py +++ b/tests/components/alexa_devices/test_button.py @@ -14,7 +14,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify from . import setup_integration -from .const import TEST_USERNAME +from .const import TEST_DEVICE_1, TEST_USERNAME from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -94,3 +94,21 @@ async def test_dynamic_entities( for routine in set(initial_routine) - set(updated_routines): entity_id = f"button.{slugify(TEST_USERNAME)}_{slugify(routine)}" assert hass.states.get(entity_id) is None + + +async def test_restart_button( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test restart button.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.{slugify(TEST_DEVICE_1.account_name)}_restart"}, + blocking=True, + ) + mock_amazon_devices_client.restart_device.assert_called_once() diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index dff813529d26..e798b3ceeb01 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -12,7 +12,12 @@ from homeassistant.components.alexa_devices.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -205,3 +210,17 @@ async def test_http2_stop_processing_called_on_unload( await hass.async_block_till_done() mock_amazon_devices_client.stop_http2_processing.assert_awaited_once() + + +async def test_http2_stop_processing_called_on_shutdown( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test stop_http2_processing is awaited when Home Assistant stops.""" + await setup_integration(hass, mock_config_entry) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_amazon_devices_client.stop_http2_processing.assert_awaited_once() diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 1a500da5ea85..7d63e61a3aa1 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -158,7 +158,9 @@ async def test_invalid_parameters( """Test invalid service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + config_entry_id=mock_config_entry.entry_id, + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) mock_device_registry( hass, @@ -214,7 +216,9 @@ async def test_invalid_info_skillparameters( """Test invalid info skill service parameters.""" device_entry = dr.DeviceEntry( - id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + config_entry_id=mock_config_entry.entry_id, + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) mock_device_registry( hass, @@ -278,21 +282,21 @@ async def test_config_entry_not_loaded( async def test_invalid_config_entry( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test that a non-existing entry ID in device config entries is skipped.""" + """Test that a device pointing to a non-existing config entry ID is skipped.""" - await setup_integration(hass, mock_config_entry) - - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + device_entry = dr.DeviceEntry( + config_entry_id="non_existing_entry_id", + id=TEST_DEVICE_1_ID, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, ) - assert device_entry - - device_entry.config_entries.clear() - device_entry.config_entries.add("non_existing_entry_id") + mock_device_registry( + hass, + {device_entry.id: device_entry}, + ) + await setup_integration(hass, mock_config_entry) with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( @@ -300,14 +304,14 @@ async def test_invalid_config_entry( "send_sound", { ATTR_SOUND: "bell_02", - ATTR_DEVICE_ID: device_entry.id, + ATTR_DEVICE_ID: TEST_DEVICE_1_ID, }, blocking=True, ) assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "config_entry_not_found" - assert exc_info.value.translation_placeholders == {"device_id": device_entry.id} + assert exc_info.value.translation_placeholders == {"device_id": TEST_DEVICE_1_ID} async def test_missing_config_entry( @@ -316,7 +320,7 @@ async def test_missing_config_entry( mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test missing config entry.""" + """Test that a device not owned by an Alexa config entry is rejected.""" await setup_integration(hass, mock_config_entry) @@ -325,7 +329,15 @@ async def test_missing_config_entry( ) assert device_entry - device_entry.config_entries.clear() + # Move the device to a config entry from a different integration + other_entry = MockConfigEntry(domain="other_domain", data={}) + other_entry.add_to_hass(hass) + device_registry.async_update_device( + device_entry.id, add_config_entry_id=other_entry.entry_id + ) + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=mock_config_entry.entry_id + ) # Call Service with pytest.raises(ServiceValidationError) as exc_info: diff --git a/tests/components/analytics_insights/fixtures/current_data.json b/tests/components/analytics_insights/fixtures/current_data.json index 9adc76144d5f..bc8f5cdc2967 100644 --- a/tests/components/analytics_insights/fixtures/current_data.json +++ b/tests/components/analytics_insights/fixtures/current_data.json @@ -1199,7 +1199,6 @@ "fritzbox_netmonitor": 4, "apprise": 2, "drop_connect": 1, - "permobil": 3, "norway_air": 3, "push": 2, "upc_connect": 2, diff --git a/tests/components/anthemav/snapshots/test_init.ambr b/tests/components/anthemav/snapshots/test_init.ambr index 1bd187b1c9fb..ecda7f5df8af 100644 --- a/tests/components/anthemav/snapshots/test_init.ambr +++ b/tests/components/anthemav/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Anthem AV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index fae45df65f99..bc2af2e73473 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -380,7 +380,7 @@ You are a voice assistant for Home Assistant. Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. - Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + Current time is 16:00:00. Today's date is 2024-06-03. ''', 'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc), diff --git a/tests/components/anthropic/test_ai_task.py b/tests/components/anthropic/test_ai_task.py index bfe4e2b4274e..59aa1f0e4987 100644 --- a/tests/components/anthropic/test_ai_task.py +++ b/tests/components/anthropic/test_ai_task.py @@ -579,3 +579,51 @@ async def test_generate_data_invalid_attachments( {"media_content_id": "media-source://media/doorbell_snapshot.txt"}, ], ) + + +async def test_generate_data_with_attachments_whitespace_instructions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test whitespace-only instructions with attachments produce no text block. + + The API rejects whitespace-only text blocks, so the user message should + contain only the attachment. + """ + entity_id = "ai_task.claude_ai_task" + + mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])] + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpg", + path=Path("doorbell_snapshot.jpg"), + ), + ], + ), + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions=" ", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + assert result.data == "Hi there!" + + input_messages = mock_create_stream.call_args[1]["messages"] + user_message = input_messages[-2] + assert user_message["role"] == "user" + assert isinstance(user_message["content"], list) + assert [block["type"] for block in user_message["content"]] == ["image"] diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index bf123f6771b1..2a0188c4bf1d 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -1,6 +1,7 @@ """Tests for the Anthropic integration.""" import datetime +from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -56,9 +57,14 @@ from homeassistant.components.anthropic.const import ( CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, ) -from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails +from homeassistant.components.anthropic.entity import ( + CitationDetails, + ContentDetails, + _convert_content, +) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler +from homeassistant.components.llm import LLMTools from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -319,7 +325,7 @@ async def test_prompt_caching_automatic( assert isinstance(system, str) -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) @pytest.mark.parametrize( ("tool_call_json_parts", "expected_call_tool_args"), [ @@ -356,7 +362,7 @@ async def test_function_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( @@ -416,7 +422,7 @@ async def test_function_call( ) -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -436,7 +442,7 @@ async def test_function_exception( ) mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( @@ -847,7 +853,7 @@ async def test_redacted_thinking( assert chat_log.content[1:] == snapshot -@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_extended_thinking_tool_call( mock_get_tools, hass: HomeAssistant, @@ -879,7 +885,7 @@ async def test_extended_thinking_tool_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) mock_create_stream.return_value = [ ( @@ -2392,3 +2398,103 @@ async def test_history_conversion( ) assert mock_create_stream.mock_calls[0][2]["messages"] == snapshot + + +async def test_history_conversion_skips_whitespace_content( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, +) -> None: + """Test that whitespace-only chat log content is not sent to the API. + + The API rejects text content blocks that contain only whitespace, and a + single such entry in a reused chat session would fail every following turn. + """ + conversation_id = "conversation_id" + mock_create_stream.return_value = [create_content_block(0, ["Yes, I am sure!"])] + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + chat_log.content = [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", content="\n" + ), + conversation.chat_log.UserContent(" "), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude_conversation", content="Round." + ), + ] + + await conversation.async_converse( + hass, + "Are you sure?", + conversation_id, + Context(), + agent_id="conversation.claude_conversation", + ) + + assert mock_create_stream.mock_calls[0][2]["messages"] == [ + {"role": "user", "content": "What shape is a donut?"}, + {"role": "assistant", "content": "Round."}, + {"role": "user", "content": "Are you sure?"}, + {"role": "assistant", "content": "Yes, I am sure!"}, + ] + + +def test_convert_content_whitespace_with_attachments() -> None: + """Test conversion of whitespace-only user content carrying attachments. + + Attachments are only appended to the last message afterwards, so an empty + user message is only created when the content is the last entry; earlier + whitespace-only entries are dropped even if they carry attachments. + """ + attachment = conversation.chat_log.Attachment( + media_content_id="media-source://media/doorbell_snapshot.jpg", + mime_type="image/jpg", + path=Path("doorbell_snapshot.jpg"), + ) + + # Not the last entry: dropped, surrounding user messages are combined + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent("Take a look"), + conversation.chat_log.UserContent(" ", attachments=[attachment]), + conversation.chat_log.UserContent("What do you see?"), + ] + ) + assert messages == [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Take a look"}, + {"type": "text", "text": "What do you see?"}, + ], + }, + ] + + # Last entry preceded by a user message: no text block is added, the + # attachments are appended to the preceding message afterwards + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent("Take a look"), + conversation.chat_log.UserContent(" ", attachments=[attachment]), + ] + ) + assert messages == [ + {"role": "user", "content": "Take a look"}, + ] + + # Last entry with no preceding user message: an empty message is created + # for the attachments to be appended to afterwards + messages, _ = _convert_content( + [ + conversation.chat_log.UserContent(" ", attachments=[attachment]), + ] + ) + assert messages == [ + {"role": "user", "content": []}, + ] diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 3c1505ff54f3..7a2b1379dcb6 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -716,7 +716,7 @@ async def test_migration_from_v2_1_to_v2_2( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index 057619a02463..ec7af18eef8f 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': 'basement', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'My water heater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'serial', 'sw_version': '2.14', 'via_device_id': None, diff --git a/tests/components/apcupsd/snapshots/test_init.ambr b/tests/components/apcupsd/snapshots/test_init.ambr index 3309d384ec75..e51ed8bfad07 100644 --- a/tests/components/apcupsd/snapshots/test_init.ambr +++ b/tests/components/apcupsd/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_async_setup_entry[mock_request_status0-mocked-config-entry-id][device_MyUPS_XXXXXXXXXXXX] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'MyUPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXXXXXXXXXX', 'sw_version': '3.14.14 (31 May 2016) unknown', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_async_setup_entry[mock_request_status1-mocked-config-entry-id][device_APC UPS_XXXX] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXX', 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_async_setup_entry[mock_request_status2-mocked-config-entry-id][device_APC UPS_] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_async_setup_entry[mock_request_status3-mocked-config-entry-id][device_APC UPS_Blank] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'APC UPS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index df95342e9bcf..7db0e2fc5679 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -51,6 +51,33 @@ async def test_api_list_state_entities( assert remote_data == local_data +@pytest.mark.parametrize( + ("entity_count", "expect_compression"), + [ + pytest.param(1, False, id="small-body-not-compressed"), + pytest.param(50, True, id="large-body-compressed"), + ], +) +async def test_api_states_compression_threshold( + hass: HomeAssistant, + mock_api_client: TestClient, + entity_count: int, + expect_compression: bool, +) -> None: + """Test that only state list responses above the size threshold are compressed.""" + for i in range(entity_count): + hass.states.async_set( + f"test.entity_{i}", "on", {"friendly_name": f"Entity {i}"} + ) + + resp = await mock_api_client.get( + const.URL_API_STATES, headers={"Accept-Encoding": "gzip, deflate"} + ) + + assert resp.status == HTTPStatus.OK + assert ("Content-Encoding" in resp.headers) is expect_compression + + async def test_api_get_state(hass: HomeAssistant, mock_api_client: TestClient) -> None: """Test if the debug interface allows us to get a state.""" hass.states.async_set("hello.world", "nice", {"attr": 1}) diff --git a/tests/components/aprilaire/snapshots/test_init.ambr b/tests/components/aprilaire/snapshots/test_init.ambr index e4fb26e5272c..96711abda099 100644 --- a/tests/components/aprilaire/snapshots/test_init.ambr +++ b/tests/components/aprilaire/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Aprilaire', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.05', 'via_device_id': None, diff --git a/tests/components/aqualogic/conftest.py b/tests/components/aqualogic/conftest.py index 2873f0f2c626..dda1d7c672da 100644 --- a/tests/components/aqualogic/conftest.py +++ b/tests/components/aqualogic/conftest.py @@ -1,14 +1,28 @@ -"""Fixtures for AquaLogic tests.""" +"""Fixtures for the AquaLogic integration tests.""" -from collections.abc import Callable +from collections.abc import AsyncGenerator, Generator +from datetime import timedelta from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components.aqualogic import AquaLogicProcessor +from homeassistant.components.aqualogic.const import DOMAIN, UPDATE_TOPIC +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers.dispatcher import dispatcher_send + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + entry_id="test_aqualogic_entry", + ) @pytest.fixture @@ -30,47 +44,69 @@ def mock_panel() -> MagicMock: @pytest.fixture -def mock_processor(mock_panel: MagicMock) -> MagicMock: - """Return a mock AquaLogicProcessor registered in hass.data.""" - with patch("homeassistant.components.aqualogic.AquaLogicProcessor") as mock_cls: - processor = MagicMock() +def mock_aqualogic_device() -> Generator[MagicMock]: + """Return a mock AquaLogic device that immediately triggers the data callback.""" + with patch( + "homeassistant.components.aqualogic.config_flow.AquaLogic" + ) as mock_al_class: + + def _fake_process(callback: object) -> None: + callback(mock_al_class.return_value) + + mock_al_class.return_value.process.side_effect = _fake_process + yield mock_al_class + + +@pytest.fixture +def mock_processor(hass: HomeAssistant, mock_panel: MagicMock) -> Generator[MagicMock]: + """Mock the AquaLogic processor thread.""" + with patch( + "homeassistant.components.aqualogic.AquaLogicProcessor" + ) as mock_processor_class: + processor = mock_processor_class.return_value processor.panel = mock_panel - mock_cls.return_value = processor + processor.data_changed.side_effect = lambda _: dispatcher_send( + hass, UPDATE_TOPIC + ) yield processor @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_panel: MagicMock -) -> AquaLogicProcessor: - """Set up the AquaLogic integration and run one pass of run() to register the callback. + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_processor: MagicMock, + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the AquaLogic integration for testing.""" + mock_config_entry.add_to_hass(hass) - AquaLogic is mocked so mock_panel becomes processor.panel. _shutdown is set before - run() so it exits after a single iteration, registering the data_changed callback - with panel.process() without starting a real network thread. - """ - with patch("homeassistant.components.aqualogic.AquaLogic") as mock_al: - mock_al.return_value = mock_panel - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, - ) + with patch("homeassistant.components.aqualogic.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - processor: AquaLogicProcessor = hass.data[DOMAIN] - processor._shutdown = True - processor.run() - return processor + + return mock_config_entry @pytest.fixture -def update_callback( - init_integration: AquaLogicProcessor, mock_panel: MagicMock -) -> Callable[[], None]: - """Return a callable that fires a panel data update through the registered callback. +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR, Platform.SWITCH] - Extracts the data_changed callback from the mock's process() call args so tests - trigger updates through the same path as real panel data arriving. - """ - callback = mock_panel.process.call_args[0][0] - return lambda: callback(mock_panel) + +@pytest.fixture +async def processor_run( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[tuple[AquaLogicProcessor, MagicMock]]: + """Provide a real AquaLogicProcessor for testing run() without starting the thread.""" + mock_config_entry.add_to_hass(hass) + with ( + patch("homeassistant.components.aqualogic.RECONNECT_INTERVAL", timedelta(0)), + patch("homeassistant.components.aqualogic.AquaLogic") as mock_al, + patch("homeassistant.components.aqualogic.PLATFORMS", []), + patch("homeassistant.components.aqualogic.AquaLogicProcessor.start"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + yield mock_config_entry.runtime_data, mock_al diff --git a/tests/components/aqualogic/test_config_flow.py b/tests/components/aqualogic/test_config_flow.py new file mode 100644 index 000000000000..1b75100d4189 --- /dev/null +++ b/tests/components/aqualogic/test_config_flow.py @@ -0,0 +1,191 @@ +"""Tests for the AquaLogic config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.aqualogic.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aqualogic.async_setup_entry", return_value=True + ) as mock: + yield mock + + +@pytest.mark.usefixtures("mock_aqualogic_device") +async def test_user_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form and create an entry.""" + 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: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AquaLogic" + assert result["data"] == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899} + mock_setup_entry.assert_called_once() + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_aqualogic_device: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle cannot connect error and allow retry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_aqualogic_device.return_value.connect.side_effect = OSError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_aqualogic_device.return_value.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_invalid_device( + hass: HomeAssistant, + mock_aqualogic_device: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle a device that does not speak the AquaLogic protocol.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_aqualogic_device.return_value.process.side_effect = None + with patch("homeassistant.components.aqualogic.config_flow._PROBE_TIMEOUT", 0): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_device"} + + def _fake_process(callback: object) -> None: + callback(mock_aqualogic_device.return_value) + + mock_aqualogic_device.return_value.process.side_effect = _fake_process + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort if the host/port 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: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_aqualogic_device") +async def test_import_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test importing from configuration.yaml creates a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "AquaLogic" + assert result["data"] == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899} + mock_setup_entry.assert_called_once() + + +async def test_import_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test we abort the import if we cannot connect.""" + with patch( + "homeassistant.components.aqualogic.config_flow.AquaLogic" + ) as mock_al_class: + mock_al_class.return_value.connect.side_effect = OSError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_flow_invalid_device(hass: HomeAssistant) -> None: + """Test we abort the import if the device does not speak the AquaLogic protocol.""" + with ( + patch("homeassistant.components.aqualogic.config_flow.AquaLogic"), + patch("homeassistant.components.aqualogic.config_flow._PROBE_TIMEOUT", 0), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort the import if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 8899}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/aqualogic/test_init.py b/tests/components/aqualogic/test_init.py index 14725a70fa82..a4f57a153438 100644 --- a/tests/components/aqualogic/test_init.py +++ b/tests/components/aqualogic/test_init.py @@ -1,91 +1,145 @@ """Tests for the AquaLogic integration setup.""" -from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -from homeassistant.components.aqualogic import DOMAIN -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.core import HomeAssistant +import pytest + +from homeassistant.components.aqualogic import AquaLogicProcessor +from homeassistant.components.aqualogic.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry -async def test_setup_creates_processor( - hass: HomeAssistant, mock_processor: MagicMock + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_processor: MagicMock, ) -> None: - """Test setup registers the processor in hass.data.""" - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, - ) + """Test loading and unloading the config entry starts and stops the processor.""" + 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() - assert hass.data[DOMAIN] is mock_processor + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_processor.start.assert_called_once() - -async def test_processor_starts_on_ha_start( - hass: HomeAssistant, mock_processor: MagicMock -) -> None: - """Test the processor thread starts when Home Assistant starts.""" - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, - ) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - - mock_processor.start_listen.assert_called_once() - - -async def test_processor_shuts_down_on_ha_stop( - hass: HomeAssistant, mock_processor: MagicMock -) -> None: - """Test the processor shuts down when Home Assistant stops.""" - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, - ) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + mock_processor.is_alive.return_value = False + 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 mock_processor.shutdown.assert_called_once() + mock_processor.join.assert_called_once_with(timeout=5) -async def test_processor_run_reconnects(hass: HomeAssistant) -> None: - """Test the processor reconnects after a dropped connection.""" +@pytest.mark.usefixtures("mock_aqualogic_device") +async def test_import_from_yaml( + hass: HomeAssistant, + mock_processor: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test importing from YAML creates a config entry and a deprecation issue.""" assert await async_setup_component( hass, DOMAIN, {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, ) await hass.async_block_till_done() - processor = hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data == {CONF_HOST: "1.2.3.4", CONF_PORT: 8899} + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue is not None + assert issue.issue_domain == DOMAIN + + +async def test_import_from_yaml_cannot_connect( + hass: HomeAssistant, + mock_processor: MagicMock, + mock_aqualogic_device: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a failed YAML import raises a specific issue instead of the migration notice.""" + mock_aqualogic_device.return_value.connect.side_effect = OSError + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_HOST: "1.2.3.4", CONF_PORT: 8899}}, + ) + await hass.async_block_till_done() + + assert hass.config_entries.async_entries(DOMAIN) == [] + + assert ( + issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + is None + ) + + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + ) + assert issue is not None + assert issue.issue_domain == DOMAIN + + +async def test_processor_run_exits_on_shutdown( + processor_run: tuple[AquaLogicProcessor, MagicMock], +) -> None: + """Test run() processes once then exits when shutdown is already set.""" + processor, mock_al = processor_run + processor._shutdown = True + processor.run() + mock_al.return_value.connect.assert_called_once_with("1.2.3.4", 8899) + + +async def test_processor_run_continues_after_unexpected_exception( + processor_run: tuple[AquaLogicProcessor, MagicMock], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the processor retries when process() raises an unexpected non-OSError exception.""" + processor, mock_al = processor_run connect_calls = 0 - def stop_on_second_connect(*args: object, **kwargs: object) -> None: + def stop_on_second_connect(*_args: object, **_kwargs: object) -> None: nonlocal connect_calls connect_calls += 1 if connect_calls >= 2: processor._shutdown = True - # Patch RECONNECT_INTERVAL to zero so time.sleep(0) returns immediately - with ( - patch("homeassistant.components.aqualogic.RECONNECT_INTERVAL", timedelta(0)), - patch("homeassistant.components.aqualogic.AquaLogic") as mock_al, - ): - mock_al.return_value.connect.side_effect = stop_on_second_connect - processor.run() + mock_al.return_value.connect.side_effect = stop_on_second_connect + mock_al.return_value.process.side_effect = RuntimeError("unexpected error") + processor.run() + + assert connect_calls == 2 + assert "Unexpected error in AquaLogic processor: RuntimeError" in caplog.text + + +async def test_processor_run_reconnects( + processor_run: tuple[AquaLogicProcessor, MagicMock], +) -> None: + """Test the processor reconnects after a dropped connection.""" + processor, mock_al = processor_run + connect_calls = 0 + + def stop_on_second_connect(*_args: object, **_kwargs: object) -> None: + nonlocal connect_calls + connect_calls += 1 + if connect_calls >= 2: + processor._shutdown = True + + mock_al.return_value.connect.side_effect = stop_on_second_connect + processor.run() assert connect_calls == 2 diff --git a/tests/components/aqualogic/test_sensor.py b/tests/components/aqualogic/test_sensor.py index 7f6981c51b4e..c4eb66853a38 100644 --- a/tests/components/aqualogic/test_sensor.py +++ b/tests/components/aqualogic/test_sensor.py @@ -1,37 +1,28 @@ """Tests for the AquaLogic sensor platform.""" -from collections.abc import Callable from unittest.mock import MagicMock import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component @pytest.fixture -async def init_sensors( - hass: HomeAssistant, init_integration: AquaLogicProcessor -) -> None: - """Set up the AquaLogic sensor platform.""" - assert await async_setup_component( - hass, - "sensor", - {"sensor": {"platform": DOMAIN}}, - ) - await hass.async_block_till_done() +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] -@pytest.mark.usefixtures("init_sensors") +@pytest.mark.usefixtures("init_integration") async def test_sensors( hass: HomeAssistant, + mock_processor: MagicMock, snapshot: SnapshotAssertion, - update_callback: Callable[[], None], ) -> None: - """Test all sensor entities are created and report correct state.""" - update_callback() + """Test sensor entities are created and report correct state.""" + mock_processor.data_changed(mock_processor.panel) await hass.async_block_till_done() states = { @@ -41,16 +32,33 @@ async def test_sensors( assert states == snapshot -@pytest.mark.usefixtures("init_sensors") +@pytest.mark.usefixtures("init_integration") async def test_sensors_imperial_units( hass: HomeAssistant, - update_callback: Callable[[], None], - mock_panel: MagicMock, + mock_processor: MagicMock, ) -> None: """Test sensors report imperial units when the panel is not metric.""" - mock_panel.is_metric = False - update_callback() + mock_processor.panel.is_metric = False + mock_processor.data_changed(mock_processor.panel) await hass.async_block_till_done() + # Use salt_level (g/L → PPM) to verify imperial branch without HA unit conversion state = hass.states.get("sensor.aqualogic_salt_level") assert state.attributes["unit_of_measurement"] == "PPM" + + +@pytest.mark.usefixtures("init_integration") +async def test_sensors_no_panel( + hass: HomeAssistant, + mock_processor: MagicMock, +) -> None: + """Test sensors revert to unknown when the panel becomes unavailable.""" + mock_processor.data_changed(mock_processor.panel) + await hass.async_block_till_done() + assert hass.states.get("sensor.aqualogic_air_temperature").state == "25.5" + + mock_processor.panel = None + mock_processor.data_changed(None) + await hass.async_block_till_done() + + assert hass.states.get("sensor.aqualogic_air_temperature").state == STATE_UNKNOWN diff --git a/tests/components/aqualogic/test_switch.py b/tests/components/aqualogic/test_switch.py index a36ad59199f4..ed1bd7d53d54 100644 --- a/tests/components/aqualogic/test_switch.py +++ b/tests/components/aqualogic/test_switch.py @@ -6,32 +6,34 @@ from aqualogic.core import States import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.aqualogic import DOMAIN, AquaLogicProcessor +from homeassistant.components.aqualogic.const import UPDATE_TOPIC from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers.dispatcher import async_dispatcher_send @pytest.fixture -async def init_switches( - hass: HomeAssistant, init_integration: AquaLogicProcessor -) -> None: - """Set up the AquaLogic switch platform.""" - assert await async_setup_component( - hass, - "switch", - {"switch": {"platform": DOMAIN}}, - ) - await hass.async_block_till_done() +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] -@pytest.mark.usefixtures("init_switches") +@pytest.mark.usefixtures("init_integration") async def test_switches( hass: HomeAssistant, snapshot: SnapshotAssertion, ) -> None: - """Test all switch entities are created and report correct state.""" + """Test switch entities are created and report correct state.""" + async_dispatcher_send(hass, UPDATE_TOPIC) + await hass.async_block_till_done() + states = { state.entity_id: state for state in sorted(hass.states.async_all("switch"), key=lambda s: s.entity_id) @@ -39,7 +41,7 @@ async def test_switches( assert states == snapshot -@pytest.mark.usefixtures("init_switches") +@pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( ("service", "expected_state"), [ @@ -61,3 +63,41 @@ async def test_turn( blocking=True, ) mock_panel.set_state.assert_called_once_with(States.LIGHTS, expected_state) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + pytest.param(SERVICE_TURN_ON, id="turn_on"), + pytest.param(SERVICE_TURN_OFF, id="turn_off"), + ], +) +async def test_turn_no_panel( + hass: HomeAssistant, + mock_processor: MagicMock, + service: str, +) -> None: + """Test that turning a switch does nothing when the panel is unavailable.""" + panel = mock_processor.panel + mock_processor.panel = None + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.aqualogic_lights"}, + blocking=True, + ) + panel.set_state.assert_not_called() + + +@pytest.mark.usefixtures("init_integration") +async def test_is_on_no_panel( + hass: HomeAssistant, + mock_processor: MagicMock, +) -> None: + """Test switch reports off when panel is unavailable.""" + mock_processor.panel = None + async_dispatcher_send(hass, UPDATE_TOPIC) + await hass.async_block_till_done() + + assert hass.states.get("switch.aqualogic_lights").state == STATE_OFF diff --git a/tests/components/aqvify/snapshots/test_init.ambr b/tests/components/aqvify/snapshots/test_init.ambr index fe3d2c055820..1cef36dab3f8 100644 --- a/tests/components/aqvify/snapshots/test_init.ambr +++ b/tests/components/aqvify/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://app.aqvify.com', 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'Device 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'DeviceKey_1', 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://app.aqvify.com', 'connections': set({ }), @@ -54,7 +53,6 @@ 'model_id': None, 'name': 'Device 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'DeviceKey_2', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index ff4430613080..3a85803d7219 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -40,6 +40,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_get_pipelines, async_update_pipeline, ) +from homeassistant.components.llm import LLMTools from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import ( @@ -1822,8 +1823,9 @@ async def test_chat_log_tts_streaming( with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", - return_value=[mock_tool], + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), ), patch( "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", diff --git a/tests/components/assist_satellite/test_llm.py b/tests/components/assist_satellite/test_llm.py new file mode 100644 index 000000000000..9a65b35b9f8a --- /dev/null +++ b/tests/components/assist_satellite/test_llm.py @@ -0,0 +1,34 @@ +"""Tests for the assist_satellite LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def init_integrations(hass: HomeAssistant) -> None: + """Set up the integrations; assist_satellite registers the broadcast intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "llm", {}) + assert await async_setup_component(hass, "assist_satellite", {}) + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_broadcast_tool_offered(hass: HomeAssistant) -> None: + """Test the broadcast intent is exposed as an LLM tool.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "HassBroadcast" in [tool.name for tool in result.tools] diff --git a/tests/components/asuswrt/snapshots/test_init.ambr b/tests/components/asuswrt/snapshots/test_init.ambr index 6b344d260c39..8f0901e3a5ad 100644 --- a/tests/components/asuswrt/snapshots/test_init.ambr +++ b/tests/components/asuswrt/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://myrouter.asuswrt.com:80', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'myrouter.asuswrt.com', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'FAKE_FIRMWARE', 'via_device_id': None, diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr index 9d94ae9ffdc6..456a4468708d 100644 --- a/tests/components/august/snapshots/test_binary_sensor.ambr +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -2,8 +2,8 @@ # name: test_doorbell_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.august.com', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'tmt100 Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr index 8af45cae68c8..a9f1292c9b43 100644 --- a/tests/components/august/snapshots/test_lock.ambr +++ b/tests/components/august/snapshots/test_lock.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.august.com', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'online_with_doorsense Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, diff --git a/tests/components/aurora/__init__.py b/tests/components/aurora/__init__.py index eca5281f6312..5be18fffb385 100644 --- a/tests/components/aurora/__init__.py +++ b/tests/components/aurora/__init__.py @@ -6,7 +6,7 @@ from tests.common import MockConfigEntry async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Fixture for setting up the component.""" + """Fixture for setting up the integration.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 938af58b0f64..8347f066fadf 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2481,6 +2481,7 @@ async def test_extraction_functions( "sensor.trigger_state", "sensor.trigger_numeric_state", "sensor.trigger_event", + "light.bla", "light.condition_state", "light.in_both", "light.in_first", diff --git a/tests/components/autoskope/snapshots/test_device_tracker.ambr b/tests/components/autoskope/snapshots/test_device_tracker.ambr index 8b66511a8d4f..686ff63b8efa 100644 --- a/tests/components/autoskope/snapshots/test_device_tracker.ambr +++ b/tests/components/autoskope/snapshots/test_device_tracker.ambr @@ -46,8 +46,8 @@ : 'mdi:car', : list([ ]), - : 50.1109221, - : 8.6821267, + : 50.1109221, + : 8.6821267, : , : , }), diff --git a/tests/components/aws_s3/snapshots/test_sensor.ambr b/tests/components/aws_s3/snapshots/test_sensor.ambr index ed0d8379e987..09fb233a6af9 100644 --- a/tests/components/aws_s3/snapshots/test_sensor.ambr +++ b/tests/components/aws_s3/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensor.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Bucket test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr index 663c52dd36c4..20531509f452 100644 --- a/tests/components/axis/snapshots/test_hub.ambr +++ b/tests/components/axis/snapshots/test_hub.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_entry[api_discovery_items0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', 'sw_version': '9.10.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device_registry_entry[api_discovery_items1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00:40:8c:12:34:56', 'sw_version': '9.80.1', 'via_device_id': None, diff --git a/tests/components/blebox/test_binary_sensor.py b/tests/components/blebox/test_binary_sensor.py index ea9585f0a746..1ba01a7ef02a 100644 --- a/tests/components/blebox/test_binary_sensor.py +++ b/tests/components/blebox/test_binary_sensor.py @@ -62,7 +62,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]: product = feature.product type(product).name = PropertyMock(return_value="My input sensor") type(product).model = PropertyMock(return_value="inputSensorD") - return feature, "binary_sensor.my_input_sensor" + return feature, "binary_sensor.my_input_sensor_input" @pytest.mark.parametrize( @@ -87,7 +87,7 @@ def inputsensor_fixture() -> tuple[AsyncMock, str]: pytest.param( "inputsensor", "BleBox-inputSensorD-aa11bb22cc33-0.input", - "My input sensor", + "My input sensor Input", None, STATE_ON, "My input sensor", diff --git a/tests/components/blebox/test_button.py b/tests/components/blebox/test_button.py index 6e9a5c3323bb..1ec63623141b 100644 --- a/tests/components/blebox/test_button.py +++ b/tests/components/blebox/test_button.py @@ -7,17 +7,16 @@ import blebox_uniapi import pytest from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from .conftest import async_setup_entity, mock_feature query_translation_key_matching = [ - ("up", "up"), - ("down", "down"), - ("fav", "fav"), - ("open", "open"), - ("close", "close"), - ("unknown_action", None), + ("up", "up", "button.my_tvliftbox_up", "My tvLiftBox Up"), + ("down", "down", "button.my_tvliftbox_down", "My tvLiftBox Down"), + ("fav", "fav", "button.my_tvliftbox_favorite", "My tvLiftBox Favorite"), + ("open", "open", "button.my_tvliftbox_open", "My tvLiftBox Open"), + ("close", "close", "button.my_tvliftbox_close", "My tvLiftBox Close"), + ("unknown_action", None, "button.my_tvliftbox", "My tvLiftBox"), ] @@ -58,13 +57,15 @@ async def test_tvliftbox_init( @pytest.mark.parametrize( - ("query_string", "expected_translation_key"), + ("query_string", "expected_translation_key", "expected_entity_id", "expected_name"), query_translation_key_matching, ids=[q[0] for q in query_translation_key_matching], ) async def test_button_translation_key( query_string: str, expected_translation_key: str | None, + expected_entity_id: str, + expected_name: str, tvliftbox: tuple[blebox_uniapi.button.Button, str], hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -72,13 +73,12 @@ async def test_button_translation_key( """Test that the correct translation_key is assigned based on query_string.""" caplog.set_level(logging.ERROR) - feature_mock, entity_id = tvliftbox + feature_mock, _ = tvliftbox feature_mock.query_string = query_string - await async_setup_entity(hass, entity_id) - - state = hass.states.get(entity_id) - assert state is not None - - entity = er.async_get(hass).async_get(entity_id) + entity = await async_setup_entity(hass, expected_entity_id) assert entity is not None assert entity.translation_key == expected_translation_key + + state = hass.states.get(expected_entity_id) + assert state is not None + assert state.name == expected_name diff --git a/tests/components/blebox/test_climate.py b/tests/components/blebox/test_climate.py index 6aa7c02ebcbe..7d55d8447b09 100644 --- a/tests/components/blebox/test_climate.py +++ b/tests/components/blebox/test_climate.py @@ -135,128 +135,58 @@ async def test_update(saunabox, hass: HomeAssistant, config) -> None: assert state.state == HVACMode.OFF -async def test_on_when_below_desired(saunabox, hass: HomeAssistant) -> None: - """Test when temperature is below desired.""" +async def test_set_hvac_mode_heat(saunabox, hass: HomeAssistant) -> None: + """Test that setting HVAC mode to heat calls async_on.""" feature_mock, entity_id = saunabox - feature_mock.is_on = False - await async_setup_entity(hass, entity_id) - - def turn_on(): - feature_mock.is_on = True - feature_mock.is_heating = True - feature_mock.desired = 64.8 - feature_mock.current = 25.7 - feature_mock.mode = 1 - feature_mock.async_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( - "climate", - SERVICE_SET_HVAC_MODE, - {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, - blocking=True, - ) - feature_mock.async_off.assert_not_called() - state = hass.states.get(entity_id) - - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_TEMPERATURE] == 64.8 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.7 - assert state.state == HVACMode.HEAT - - -async def test_on_when_above_desired(saunabox, hass: HomeAssistant) -> None: - """Test when temperature is below desired.""" - - feature_mock, entity_id = saunabox - - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - def turn_on(): - feature_mock.is_on = True - feature_mock.is_heating = False - feature_mock.desired = 23.4 - feature_mock.current = 28.7 - - feature_mock.mode = 1 - feature_mock.async_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( "climate", SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) + + feature_mock.async_on.assert_called_once_with() feature_mock.async_off.assert_not_called() - state = hass.states.get(entity_id) - - assert state.attributes[ATTR_TEMPERATURE] == 23.4 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 28.7 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert state.state == HVACMode.HEAT -async def test_off(saunabox, hass: HomeAssistant) -> None: - """Test turning off.""" +async def test_set_hvac_mode_off(saunabox, hass: HomeAssistant) -> None: + """Test that setting HVAC mode to off calls async_off.""" feature_mock, entity_id = saunabox - feature_mock.is_on = True - feature_mock.is_heating = False await async_setup_entity(hass, entity_id) - def turn_off(): - feature_mock.is_on = False - feature_mock.is_heating = False - feature_mock.desired = 29.8 - feature_mock.current = 22.7 - - feature_mock.async_off = AsyncMock(side_effect=turn_off) await hass.services.async_call( "climate", SERVICE_SET_HVAC_MODE, {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - feature_mock.async_on.assert_not_called() - state = hass.states.get(entity_id) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF - assert state.attributes[ATTR_TEMPERATURE] == 29.8 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.7 - assert state.state == HVACMode.OFF + feature_mock.async_off.assert_called_once_with() + feature_mock.async_on.assert_not_called() async def test_set_thermo(saunabox, hass: HomeAssistant) -> None: - """Test setting thermostat.""" + """Test that setting the temperature calls async_set_temperature.""" feature_mock, entity_id = saunabox - feature_mock.is_on = False - feature_mock.is_heating = False await async_setup_entity(hass, entity_id) - def set_temp(temp): - feature_mock.is_on = True - feature_mock.is_heating = True - feature_mock.desired = 29.2 - feature_mock.current = 29.1 - - feature_mock.async_set_temperature = AsyncMock(side_effect=set_temp) await hass.services.async_call( "climate", SERVICE_SET_TEMPERATURE, {"entity_id": entity_id, ATTR_TEMPERATURE: 43.21}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_TEMPERATURE] == 29.2 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 29.1 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.state == HVACMode.HEAT + feature_mock.async_set_temperature.assert_called_once_with(43.21) async def test_update_failure( @@ -280,55 +210,30 @@ async def test_update_failure( assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_reding_hvac_actions( - saunabox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test hvac action for given device(mock) state.""" - - caplog.set_level(logging.ERROR) +async def test_hvac_action_heating(saunabox, hass: HomeAssistant) -> None: + """Test hvac_action reflects a heating device state.""" feature_mock, entity_id = saunabox + + feature_mock.is_on = True + feature_mock.hvac_action = 1 + feature_mock.mode = 1 await async_setup_entity(hass, entity_id) - def set_temperature(temp): - feature_mock.is_on = True - feature_mock.hvac_action = 1 - feature_mock.mode = 1 - - feature_mock.async_set_temperature = AsyncMock(side_effect=set_temperature) - - await hass.services.async_call( - "climate", - SERVICE_SET_TEMPERATURE, - {"entity_id": entity_id, ATTR_TEMPERATURE: 43.21}, - blocking=True, - ) state = hass.states.get(entity_id) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.HEAT] -async def test_thermo_off( - thermobox, hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test hvac action off fir given device state.""" - caplog.set_level(logging.ERROR) +async def test_hvac_action_off(thermobox, hass: HomeAssistant) -> None: + """Test hvac_action reflects a device that is off.""" feature_mock, entity_id = thermobox + + feature_mock.is_on = False + feature_mock.hvac_action = 0 await async_setup_entity(hass, entity_id) - def set_off(): - feature_mock.is_on = False - feature_mock.hvac_action = 0 - - feature_mock.async_off = AsyncMock(side_effect=set_off) - - await hass.services.async_call( - "climate", - SERVICE_SET_HVAC_MODE, - {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, - blocking=True, - ) state = hass.states.get(entity_id) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.COOL] diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index 56a3bec3e293..b057e178d56d 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -259,21 +259,16 @@ async def test_open(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 3 # manually stopped await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def open_gate(): - feature_mock.state = 1 # opening - - feature_mock.async_open = AsyncMock(side_effect=open_gate) await hass.services.async_call( "cover", SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_open.assert_called_once_with() @pytest.mark.parametrize("feature", ALL_COVER_FIXTURES, indirect=["feature"]) @@ -282,18 +277,13 @@ async def test_close(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 4 # open await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.OPEN - def close(): - feature_mock.state = 0 # closing - - feature_mock.async_close = AsyncMock(side_effect=close) await hass.services.async_call( "cover", SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) - assert hass.states.get(entity_id).state == CoverState.CLOSING + + feature_mock.async_close.assert_called_once_with() @pytest.mark.parametrize("feature", FIXTURES_SUPPORTING_STOP, indirect=["feature"]) @@ -302,18 +292,13 @@ async def test_stop(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 1 # opening await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.OPENING - def stop(): - feature_mock.state = 2 # manually stopped - - feature_mock.async_stop = AsyncMock(side_effect=stop) await hass.services.async_call( "cover", SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True ) - assert hass.states.get(entity_id).state == CoverState.OPEN + + feature_mock.async_stop.assert_called_once_with() @pytest.mark.parametrize( @@ -355,23 +340,16 @@ async def test_set_position(feature, hass: HomeAssistant) -> None: feature_mock, entity_id = feature - feature_mock.state = 3 # closed await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def set_position(position): - assert position == 99 # inverted - feature_mock.state = 1 # opening - # feature_mock.current = position - - feature_mock.async_set_position = AsyncMock(side_effect=set_position) await hass.services.async_call( "cover", SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, ATTR_POSITION: 1}, blocking=True, ) # almost closed - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_set_position.assert_called_once_with(99) # inverted async def test_unknown_position(shutterbox, hass: HomeAssistant) -> None: @@ -540,29 +518,23 @@ async def test_set_tilt_position(shutterbox, hass: HomeAssistant) -> None: feature_mock, entity_id = shutterbox - feature_mock.state = 3 await async_setup_entity(hass, entity_id) - assert hass.states.get(entity_id).state == CoverState.CLOSED - def set_tilt(tilt_position): - assert tilt_position == 20 - feature_mock.state = 1 - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt) await hass.services.async_call( "cover", SERVICE_SET_COVER_TILT_POSITION, {"entity_id": entity_id, ATTR_TILT_POSITION: 80}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + + feature_mock.async_set_tilt_position.assert_called_once_with(20) @pytest.mark.parametrize( - ("is_tilt_180", "expected_tilt_position", "expected_tilt_reported"), + ("is_tilt_180", "expected_tilt_position"), [ - pytest.param(False, 0, 100, id="tilt_90"), - pytest.param(True, 50, 50, id="tilt_180"), + pytest.param(False, 0, id="tilt_90"), + pytest.param(True, 50, id="tilt_180"), ], ) async def test_open_tilt( @@ -570,7 +542,6 @@ async def test_open_tilt( hass: HomeAssistant, is_tilt_180: bool, expected_tilt_position: int, - expected_tilt_reported: int, ) -> None: """Test opening tilt for 90-degree and 180-degree tilt shutters.""" feature_mock, entity_id = shutterbox @@ -578,42 +549,27 @@ async def test_open_tilt( feature_mock.tilt_current = 100 await async_setup_entity(hass, entity_id) - def set_tilt_position(tilt_position): - assert tilt_position == expected_tilt_position - feature_mock.tilt_current = tilt_position - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt_position) - await hass.services.async_call( "cover", SERVICE_OPEN_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert ( - state.attributes[ATTR_CURRENT_TILT_POSITION] == expected_tilt_reported - ) # inverted + + feature_mock.async_set_tilt_position.assert_called_once_with(expected_tilt_position) async def test_close_tilt(shutterbox, hass: HomeAssistant) -> None: """Test closing tilt.""" feature_mock, entity_id = shutterbox - feature_mock.tilt_current = 0 await async_setup_entity(hass, entity_id) - def set_tilt_position(tilt_position): - assert tilt_position == 100 - feature_mock.tilt_current = tilt_position - - feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt_position) - await hass.services.async_call( "cover", SERVICE_CLOSE_COVER_TILT, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # inverted + + feature_mock.async_set_tilt_position.assert_called_once_with(100) diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index 8d0bdd74291a..031f4bca861a 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock import blebox_uniapi import pytest -from homeassistant.components.blebox.const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -19,7 +18,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, STATE_ON, STATE_UNKNOWN, ) @@ -103,20 +101,9 @@ async def test_dimmer_on(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = False - feature_mock.brightness = 0 # off feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 254 - feature_mock.brightness = 254 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -124,9 +111,7 @@ async def test_dimmer_on(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes[ATTR_BRIGHTNESS] == 254 + feature_mock.async_on.assert_called_once_with(254) async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: @@ -134,21 +119,9 @@ async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = False - feature_mock.brightness = 0 # off feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 202 - feature_mock.brightness = 202 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - def apply(value, brightness): assert value == 254 return brightness @@ -161,9 +134,7 @@ async def test_dimmer_on_with_brightness(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_BRIGHTNESS] == 202 - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with(202) async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: @@ -171,17 +142,8 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: feature_mock, entity_id = dimmer - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - def turn_off(): - feature_mock.is_on = False - feature_mock.brightness = 0 # off - - feature_mock.async_off = AsyncMock(side_effect=turn_off) await hass.services.async_call( "light", SERVICE_TURN_OFF, @@ -189,9 +151,7 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - assert state.attributes[ATTR_BRIGHTNESS] is None + feature_mock.async_off.assert_called_once_with() @pytest.fixture(name="wlightbox_s") @@ -264,19 +224,9 @@ async def test_wlightbox_s_on(wlightbox_s, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox_s - feature_mock.is_on = False feature_mock.sensible_on_value = 254 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(brightness): - assert brightness == 254 - feature_mock.brightness = 254 # on - feature_mock.is_on = True # on - - feature_mock.async_on = AsyncMock(side_effect=turn_on) await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -284,9 +234,7 @@ async def test_wlightbox_s_on(wlightbox_s, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_BRIGHTNESS] == 254 - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with(254) @pytest.fixture(name="wlightbox") @@ -360,12 +308,7 @@ async def test_wlightbox_on_color_temp( transient_temp = value return [0x00, 0x39, 0xB0, 0xFF] - def turn_on(_: list[int]) -> None: - feature_mock.is_on = True - feature_mock.color_temp = transient_temp - feature_mock.return_color_temp_with_brightness = return_color_temp_with_brightness - feature_mock.async_on = AsyncMock(side_effect=turn_on) await async_setup_entity(hass, entity_id) await hass.services.async_call( @@ -375,12 +318,8 @@ async def test_wlightbox_on_color_temp( blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON assert 0 <= transient_temp <= 255 - - kelvin_actual = state.attributes[ATTR_COLOR_TEMP_KELVIN] - assert LIGHT_MIN_KELVINS <= kelvin_actual <= LIGHT_MAX_KELVINS + feature_mock.async_on.assert_called_once_with([0x00, 0x39, 0xB0, 0xFF]) async def test_wlightbox_init( @@ -431,35 +370,8 @@ async def test_wlightbox_on_rgbw(wlightbox, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - assert value == [193, 210, 243, 199] - feature_mock.white_value = 0xC7 # on - feature_mock.rgbw_hex = "c1d2f3c7" - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - - def apply_white(value, white): - assert value == "00010203" - assert white == 0xC7 - return "000102c7" - - feature_mock.apply_white = apply_white - - def apply_color(value, color_value): - assert value == "000102c7" - assert color_value == "c1d2f3" - return "c1d2f3c7" - - feature_mock.apply_color = apply_color - feature_mock.sensible_on_value = "00010203" - await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -467,9 +379,7 @@ async def test_wlightbox_on_rgbw(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes[ATTR_RGBW_COLOR] == (0xC1, 0xD2, 0xF3, 0xC7) + feature_mock.async_on.assert_called_once_with([193, 210, 243, 199]) async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> None: @@ -477,20 +387,8 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non feature_mock, entity_id = wlightbox - feature_mock.is_on = False - await async_setup_entity(hass, entity_id) - - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - assert value == "f1e2d3e4" - feature_mock.white_value = 0xE4 - feature_mock.rgbw_hex = value - - feature_mock.async_on = AsyncMock(side_effect=turn_on) feature_mock.sensible_on_value = "f1e2d3e4" + await async_setup_entity(hass, entity_id) await hass.services.async_call( "light", @@ -499,9 +397,7 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass: HomeAssistant) -> Non blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_RGBW_COLOR] == (0xF1, 0xE2, 0xD3, 0xE4) - assert state.state == STATE_ON + feature_mock.async_on.assert_called_once_with("f1e2d3e4") async def test_wlightbox_turn_on_with_zero_brightness_turns_off( @@ -511,23 +407,8 @@ async def test_wlightbox_turn_on_with_zero_brightness_turns_off( feature_mock, entity_id = wlightbox - feature_mock.is_on = True - feature_mock.rgbw_hex = "c1d2f3c7" - feature_mock.white_value = 0xC7 await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - feature_mock.apply_brightness = MagicMock(return_value=[0, 0, 0, 0]) - - def turn_off(): - feature_mock.is_on = False - feature_mock.white_value = 0x0 - feature_mock.rgbw_hex = "00000000" - - feature_mock.async_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "light", SERVICE_TURN_ON, @@ -535,31 +416,17 @@ async def test_wlightbox_turn_on_with_zero_brightness_turns_off( blocking=True, ) - feature_mock.async_off.assert_called_once() + feature_mock.async_off.assert_called_once_with() feature_mock.async_on.assert_not_called() - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: """Test light off.""" feature_mock, entity_id = wlightbox - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_ON - - def turn_off(): - feature_mock.is_on = False - feature_mock.white_value = 0x0 - feature_mock.rgbw_hex = "00000000" - - feature_mock.async_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "light", SERVICE_TURN_OFF, @@ -567,9 +434,7 @@ async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_RGBW_COLOR] is None - assert state.state == STATE_OFF + feature_mock.async_off.assert_called_once_with() @pytest.mark.parametrize("feature", ALL_LIGHT_FIXTURES, indirect=["feature"]) @@ -623,18 +488,8 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: feature_mock, entity_id = wlightbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - - def turn_on(value): - feature_mock.is_on = True - feature_mock.effect = "POLICE" - - feature_mock.async_on = AsyncMock(side_effect=turn_on) - with pytest.raises(HomeAssistantError) as info: await hass.services.async_call( "light", @@ -644,6 +499,7 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: ) assert info.value.translation_key == "effect_not_found" + feature_mock.async_api_command.assert_not_called() await hass.services.async_call( "light", @@ -652,8 +508,7 @@ async def test_wlightbox_on_effect(wlightbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.attributes[ATTR_EFFECT] == "POLICE" + feature_mock.async_api_command.assert_called_once_with("effect", 2) @pytest.mark.parametrize( diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index 45d1d9dc1675..5a24da079bcf 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -304,10 +304,7 @@ async def test_open_status_sensor_none_value( """Test that a None native_value yields an unknown state.""" feature_mock, entity_id = open_status_sensor - def set_none(): - feature_mock.native_value = None - - feature_mock.async_update = AsyncMock(side_effect=set_none) + feature_mock.native_value = None await async_setup_entity(hass, entity_id) state = hass.states.get(entity_id) @@ -380,10 +377,7 @@ async def test_co2_definition_sensor_none_value( """Test that a None native_value yields an unknown state.""" feature_mock, entity_id = co2_definition_sensor - def set_none(): - feature_mock.native_value = None - - feature_mock.async_update = AsyncMock(side_effect=set_none) + feature_mock.native_value = None await async_setup_entity(hass, entity_id) state = hass.states.get(entity_id) diff --git a/tests/components/blebox/test_switch.py b/tests/components/blebox/test_switch.py index 959e68b43e2f..ccd66fad439e 100644 --- a/tests/components/blebox/test_switch.py +++ b/tests/components/blebox/test_switch.py @@ -106,14 +106,8 @@ async def test_switchbox_on(switchbox, hass: HomeAssistant) -> None: feature_mock, entity_id = switchbox - feature_mock.is_on = False await async_setup_entity(hass, entity_id) - def turn_on(): - feature_mock.is_on = True - - feature_mock.async_turn_on = AsyncMock(side_effect=turn_on) - await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -121,8 +115,7 @@ async def test_switchbox_on(switchbox, hass: HomeAssistant) -> None: blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_ON + feature_mock.async_turn_on.assert_called_once_with() async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None: @@ -130,22 +123,16 @@ async def test_switchbox_off(switchbox, hass: HomeAssistant) -> None: feature_mock, entity_id = switchbox - feature_mock.is_on = True await async_setup_entity(hass, entity_id) - def turn_off(): - feature_mock.is_on = False - - feature_mock.async_turn_off = AsyncMock(side_effect=turn_off) - await hass.services.async_call( "switch", SERVICE_TURN_OFF, {"entity_id": entity_id}, blocking=True, ) - state = hass.states.get(entity_id) - assert state.state == STATE_OFF + + feature_mock.async_turn_off.assert_called_once_with() def relay_mock(relay_id=0): @@ -263,14 +250,8 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass: HomeAssistant) -> No feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = False - feature_mocks[1].is_on = False await async_setup_entities(hass, entity_ids) - def turn_on0(): - feature_mocks[0].is_on = True - - feature_mocks[0].async_turn_on = AsyncMock(side_effect=turn_on0) await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -278,8 +259,8 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass: HomeAssistant) -> No blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_ON - assert hass.states.get(entity_ids[1]).state == STATE_OFF + feature_mocks[0].async_turn_on.assert_called_once_with() + feature_mocks[1].async_turn_on.assert_not_called() async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: @@ -287,14 +268,8 @@ async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = False - feature_mocks[1].is_on = False await async_setup_entities(hass, entity_ids) - def turn_on1(): - feature_mocks[1].is_on = True - - feature_mocks[1].async_turn_on = AsyncMock(side_effect=turn_on1) await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -302,8 +277,8 @@ async def test_switchbox_d_second_on(switchbox_d, hass: HomeAssistant) -> None: blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_OFF - assert hass.states.get(entity_ids[1]).state == STATE_ON + feature_mocks[0].async_turn_on.assert_not_called() + feature_mocks[1].async_turn_on.assert_called_once_with() async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: @@ -311,14 +286,8 @@ async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = True - feature_mocks[1].is_on = True await async_setup_entities(hass, entity_ids) - def turn_off0(): - feature_mocks[0].is_on = False - - feature_mocks[0].async_turn_off = AsyncMock(side_effect=turn_off0) await hass.services.async_call( "switch", SERVICE_TURN_OFF, @@ -326,8 +295,8 @@ async def test_switchbox_d_first_off(switchbox_d, hass: HomeAssistant) -> None: blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_OFF - assert hass.states.get(entity_ids[1]).state == STATE_ON + feature_mocks[0].async_turn_off.assert_called_once_with() + feature_mocks[1].async_turn_off.assert_not_called() async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None: @@ -335,22 +304,17 @@ async def test_switchbox_d_second_off(switchbox_d, hass: HomeAssistant) -> None: feature_mocks, entity_ids = switchbox_d - feature_mocks[0].is_on = True - feature_mocks[1].is_on = True await async_setup_entities(hass, entity_ids) - def turn_off1(): - feature_mocks[1].is_on = False - - feature_mocks[1].async_turn_off = AsyncMock(side_effect=turn_off1) await hass.services.async_call( "switch", SERVICE_TURN_OFF, {"entity_id": entity_ids[1]}, blocking=True, ) - assert hass.states.get(entity_ids[0]).state == STATE_ON - assert hass.states.get(entity_ids[1]).state == STATE_OFF + + feature_mocks[0].async_turn_off.assert_not_called() + feature_mocks[1].async_turn_off.assert_called_once_with() async def test_switchbox_with_name(hass: HomeAssistant) -> None: diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index b6f4b1f8ce1e..c8aa6610df5f 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -12,6 +12,9 @@ from habluetooth import BluetoothScanningMode, HaScanner # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS + +# pylint: disable-next=no-name-in-module +from habluetooth.const import STALE_ROAM_FACTOR import pytest from homeassistant import config_entries @@ -402,9 +405,26 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) - # Should switch to hci1 since the previous advertisement is stale - # even though the signal is poor because the device is now - # likely unreachable via hci0 + # Should not roam yet: a single missed reception interval must not hand a + # stationary device to a comparable scanner before the roam gate + # (STALE_ROAM_FACTOR stale windows) + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + + (10 + TRACKER_BUFFERING_WOBBLE_SECONDS) * STALE_ROAM_FACTOR + + 1, + HCI1_SOURCE_ADDRESS, + ) + # Past the roam gate (STALE_ROAM_FACTOR stale windows): switch to hci1 + # since the previous advertisement is stale even though the signal is poor + # because the device is now likely unreachable via hci0 assert ( bluetooth.async_ble_device_from_address(hass, address) is switchbot_device_poor_signal_hci1 diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py index a55bf63f2270..fd61edf8d3dd 100644 --- a/tests/components/broadlink/test_remote.py +++ b/tests/components/broadlink/test_remote.py @@ -3,19 +3,32 @@ from base64 import b64decode from unittest.mock import call +from broadlink.exceptions import BroadlinkException +from freezegun.api import FrozenDateTimeFactory +import pytest + from homeassistant.components.broadlink.const import DOMAIN +from homeassistant.components.broadlink.updater import BroadlinkRMUpdateManager from homeassistant.components.remote import ( DOMAIN as REMOTE_DOMAIN, SERVICE_SEND_COMMAND, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON, Platform +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import get_device +from tests.common import async_fire_time_changed + REMOTE_DEVICES = ["Entrance", "Living Room", "Office", "Garage"] IR_PACKET = ( @@ -78,6 +91,60 @@ async def test_remote_send_command( assert mock_setup.api.auth.call_count == 1 +@pytest.mark.parametrize( + ("error", "ticks_to_unavailable"), + [ + # OSError flips availability on the first failure (fast path). + (OSError("connection refused"), 1), + # A generic BroadlinkException keeps the entity available across the + # first three failed cycles and only flips once SCAN_INTERVAL * 3 has + # elapsed since the last successful update. + (BroadlinkException("update failed"), 4), + ], +) +async def test_remote_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + error: Exception, + ticks_to_unavailable: int, +) -> None: + """Test the remote becomes unavailable on disconnect and recovers on reconnect.""" + device = get_device("Garage") + mock_setup = await device.setup_entry(hass) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_setup.entry.unique_id)} + ) + entries = er.async_entries_for_device(entity_registry, device_entry.id) + remote = next(entry for entry in entries if entry.domain == Platform.REMOTE) + + assert hass.states.get(remote.entity_id).state == STATE_ON + + mock_setup.api.check_sensors.side_effect = error + + for _ in range(ticks_to_unavailable - 1): + freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(remote.entity_id).state == STATE_ON + + freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(remote.entity_id).state == STATE_UNAVAILABLE + + mock_setup.api.check_sensors.side_effect = None + + freezer.tick(BroadlinkRMUpdateManager.SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(remote.entity_id).state == STATE_ON + + async def test_remote_turn_off_turn_on( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index 5ff351d5dbf8..d76c41d35864 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -126,6 +126,7 @@ }), 'states': dict({ '1': dict({ + 'cooling_operating_mode': None, 'current_temperature': dict({ 'data_type': 0, 'data_type_family': '', diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 7cff47e68598..e0fe8c1d5dbb 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -1,9 +1,10 @@ """Tests for the BSB-LAN climate platform.""" from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock -from bsblan import BSBLANError, HeatingCircuitStatus +from bsblan import BSBLANError, HeatingCircuitStatus, StaticState from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -12,6 +13,8 @@ from homeassistant.components.bsblan.const import DOMAIN from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, DOMAIN as CLIMATE_DOMAIN, PRESET_ECO, PRESET_NONE, @@ -33,6 +36,81 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat ENTITY_ID = "climate.heating_circuit_1" +def _temp_param(value: str) -> dict[str, Any]: + """Build a raw BSB-LAN temperature parameter payload.""" + return { + "name": "", + "value": value, + "unit": "°C", + "desc": "", + "dataType": 0, + "readonly": 0, + "error": 0, + } + + +@pytest.mark.parametrize( + ("static_data", "expected_min", "expected_max"), + [ + pytest.param( + { + "heating_protective_setpoint": _temp_param("10.0"), + "comfort_setpoint_max": _temp_param("26.0"), + }, + 10.0, + 26.0, + id="standard_device", + ), + pytest.param( + { + "min_temp": _temp_param("8.0"), + "max_temp": _temp_param("20.0"), + }, + 8.0, + 20.0, + id="pps_device", + ), + pytest.param( + { + "heating_protective_setpoint": _temp_param("---"), + "comfort_setpoint_max": _temp_param("---"), + "min_temp": _temp_param("8.0"), + "max_temp": _temp_param("20.0"), + }, + 8.0, + 20.0, + id="inactive_preferred_source", + ), + pytest.param( + { + "heating_protective_setpoint": _temp_param("---"), + "comfort_setpoint_max": _temp_param("---"), + }, + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + id="all_sources_inactive", + ), + ], +) +async def test_climate_min_max_temperature( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + static_data: dict[str, Any], + expected_min: float, + expected_max: float, +) -> None: + """Test min/max temperature bounds resolved from per-circuit static values.""" + mock_bsblan.static_values.return_value = StaticState.model_validate(static_data) + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == expected_min + assert state.attributes["max_temp"] == expected_max + + async def test_celsius_fahrenheit( hass: HomeAssistant, mock_bsblan: AsyncMock, diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index 9ec11356c596..2b1fac2eef83 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -1,5 +1,6 @@ """Tests for the BSBLan integration.""" +import asyncio from datetime import timedelta from unittest.mock import MagicMock @@ -291,6 +292,37 @@ async def test_coordinator_dhw_config_update_error( assert mock_bsblan.hot_water_schedule.called +async def test_setup_does_not_block_on_slow_fetch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test setup does not wait for the background slow-data fetch.""" + release = asyncio.Event() + config_value = mock_bsblan.hot_water_config.return_value + + async def _blocking_config(*args: object, **kwargs: object) -> object: + await release.wait() + return config_value + + mock_bsblan.hot_water_config.side_effect = _blocking_config + + mock_config_entry.add_to_hass(hass) + try: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Setup finished even though the slow-data fetch is still pending. + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not mock_bsblan.hot_water_schedule.called + finally: + # Release the fetch so it can complete and clean up. + release.set() + await hass.async_block_till_done() + + assert mock_bsblan.hot_water_schedule.called + + async def test_coordinator_slow_first_fetch_failure( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/bsblan/test_services.py b/tests/components/bsblan/test_services.py index 62f26c97f076..859484bcd21a 100644 --- a/tests/components/bsblan/test_services.py +++ b/tests/components/bsblan/test_services.py @@ -44,6 +44,19 @@ def device_entry( return device +@pytest.fixture +def water_heater_device_entry( + device_registry: dr.DeviceRegistry, + setup_integration: None, +) -> dr.DeviceEntry: + """Get the water heater sub-device entry for testing.""" + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{TEST_DEVICE_MAC}-water-heater")} + ) + assert device is not None + return device + + @pytest.mark.usefixtures("setup_integration") @pytest.mark.parametrize( ("service_data", "expected_schedules"), @@ -119,13 +132,13 @@ def device_entry( async def test_set_hot_water_schedule( hass: HomeAssistant, mock_bsblan: MagicMock, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, service_data: dict[str, Any], expected_schedules: dict[str, DaySchedule], ) -> None: """Test setting hot water schedule with various configurations.""" # Call the service with device_id and slot fields - service_call_data = {"device_id": device_entry.id} + service_call_data = {"device_id": water_heater_device_entry.id} service_call_data.update(service_data) await hass.services.async_call( @@ -216,7 +229,7 @@ async def test_no_config_entry_for_device( async def test_config_entry_not_loaded( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test error when config entry is not loaded.""" await hass.config_entries.async_unload(mock_config_entry.entry_id) @@ -226,7 +239,7 @@ async def test_config_entry_not_loaded( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": time(6, 0), "end_time": time(8, 0)}, ], @@ -241,12 +254,34 @@ async def test_config_entry_not_loaded( async def test_api_error( hass: HomeAssistant, mock_bsblan: MagicMock, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test error when BSB-LAN API call fails.""" mock_bsblan.set_hot_water_schedule.side_effect = BSBLANError("API Error") with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + "set_hot_water_schedule", + { + "device_id": water_heater_device_entry.id, + "monday_slots": [ + {"start_time": time(6, 0), "end_time": time(8, 0)}, + ], + }, + blocking=True, + ) + + assert exc_info.value.translation_key == "set_schedule_failed" + + +@pytest.mark.usefixtures("setup_integration") +async def test_set_hot_water_schedule_rejects_main_device( + hass: HomeAssistant, + device_entry: dr.DeviceEntry, +) -> None: + """Test that picking the main device for hot water schedule is rejected.""" + with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( DOMAIN, "set_hot_water_schedule", @@ -259,7 +294,7 @@ async def test_api_error( blocking=True, ) - assert exc_info.value.translation_key == "set_schedule_failed" + assert exc_info.value.translation_key == "not_a_water_heater_device" @pytest.mark.usefixtures("setup_integration") @@ -276,7 +311,7 @@ async def test_api_error( ) async def test_time_validation_errors( hass: HomeAssistant, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, start_time: time | str, end_time: time | str, expected_error: str, @@ -287,7 +322,7 @@ async def test_time_validation_errors( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": start_time, "end_time": end_time}, ], @@ -302,7 +337,7 @@ async def test_time_validation_errors( async def test_unprovided_days_are_none( hass: HomeAssistant, mock_bsblan: MagicMock, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test that unprovided days are sent as None to BSB-LAN API.""" # Only provide Monday and Tuesday, leave other days unprovided @@ -310,7 +345,7 @@ async def test_unprovided_days_are_none( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": time(6, 0), "end_time": time(8, 0)}, ], @@ -346,7 +381,7 @@ async def test_unprovided_days_are_none( async def test_string_time_formats( hass: HomeAssistant, mock_bsblan: MagicMock, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test service with string time formats.""" # Test with string time formats @@ -354,7 +389,7 @@ async def test_string_time_formats( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": "06:00:00", "end_time": "08:00:00"}, # With seconds ], @@ -382,7 +417,7 @@ async def test_string_time_formats( @pytest.mark.usefixtures("setup_integration") async def test_non_standard_time_types( hass: HomeAssistant, - device_entry: dr.DeviceEntry, + water_heater_device_entry: dr.DeviceEntry, ) -> None: """Test service with non-standard time types raises error.""" # Test with integer time values - schema validation will reject these @@ -391,7 +426,7 @@ async def test_non_standard_time_types( DOMAIN, "set_hot_water_schedule", { - "device_id": device_entry.id, + "device_id": water_heater_device_entry.id, "monday_slots": [ {"start_time": 600, "end_time": 800}, ], diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index b730ddad688f..d7ef7f0718f3 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -1,5 +1,6 @@ """Tests for the BSB-LAN water heater platform.""" +import asyncio from datetime import timedelta from unittest.mock import AsyncMock, MagicMock @@ -392,6 +393,41 @@ async def test_water_heater_custom_temperature_limits_from_config( ) # Custom maximum from nominal_setpoint_max +async def test_water_heater_temperature_limits_update_after_slow_fetch( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test temperature limits update when the background fetch completes.""" + release = asyncio.Event() + config = mock_bsblan.hot_water_config.return_value + config.reduced_setpoint.value = 15.0 + config.nominal_setpoint_max.value = 75.0 + + async def _blocking_config(*args: object, **kwargs: object) -> object: + await release.wait() + return config + + mock_bsblan.hot_water_config.side_effect = _blocking_config + + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.WATER_HEATER] + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 10.0 + assert state.attributes["max_temp"] == 65.0 + + release.set() + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 15.0 + assert state.attributes["max_temp"] == 75.0 + + async def test_turn_on( hass: HomeAssistant, mock_bsblan: AsyncMock, diff --git a/tests/components/calendar/test_llm.py b/tests/components/calendar/test_llm.py new file mode 100644 index 000000000000..056ff357d69e --- /dev/null +++ b/tests/components/calendar/test_llm.py @@ -0,0 +1,172 @@ +"""Tests for the calendar LLM tools platform.""" + +from datetime import timedelta + +from freezegun import freeze_time +import pytest + +from homeassistant.components import calendar, llm as llm_component +from homeassistant.components.calendar import llm as calendar_llm +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant, SupportsResponse +from homeassistant.helpers import entity_registry as er, llm +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import async_mock_service + +ENTITY_ID = "calendar.test_calendar" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a calendar.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "calendar", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Mock Calendar Name"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_tools_no_exposed_calendar(hass: HomeAssistant) -> None: + """Test no calendar tool is offered when no calendar is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "calendar_get_events" not in [tool.name for tool in result.tools] + assert calendar_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert calendar_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: + """Test the calendar get events tool is exposed and works via the platform.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next( + (tool for tool in result.tools if tool.name == "calendar_get_events"), None + ) + assert tool is not None + assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] + + calls = async_mock_service( + hass, + domain=calendar.DOMAIN, + service=calendar.SERVICE_GET_EVENTS, + schema=calendar.SERVICE_GET_EVENTS_SCHEMA, + response={ + ENTITY_ID: { + "events": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ] + } + }, + supports_response=SupportsResponse.ONLY, + ) + + tool_input = llm.ToolInput( + tool_name="calendar_get_events", + tool_args={"calendar": "Mock Calendar Name", "range": "today"}, + ) + now = dt_util.now() + with freeze_time(now): + response = await tool.async_call(hass, tool_input, llm_context) + + assert len(calls) == 1 + call = calls[0] + assert call.domain == calendar.DOMAIN + assert call.service == calendar.SERVICE_GET_EVENTS + assert call.data == { + "entity_id": [ENTITY_ID], + "start_date_time": now, + "end_date_time": dt_util.start_of_local_day(now) + timedelta(days=1), + } + + assert response == { + "success": True, + "result": [ + { + "start": "2025-09-17", + "end": "2025-09-18", + "summary": "Home Assistant 12th birthday", + "description": "", + "all_day": True, + }, + { + "start": "2025-09-17T14:00:00-05:00", + "end": "2025-09-18T15:00:00-05:00", + "summary": "Champagne", + "description": "", + }, + ], + } + + # The "week" range searches seven days out. + calls.clear() + tool_input.tool_args["range"] = "week" + with freeze_time(now): + await tool.async_call(hass, tool_input, llm_context) + assert call.domain == calendar.DOMAIN + assert calls[0].data["end_date_time"] == ( + dt_util.start_of_local_day(now) + timedelta(days=7) + ) + + +async def test_calendar_get_events_tool_not_found(hass: HomeAssistant) -> None: + """Test the tool reports when the requested calendar no longer matches.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") + + # Unexpose after the tool (and its calendar enum) was built, so the call-time + # match no longer finds the calendar. + async_expose_entity(hass, "conversation", ENTITY_ID, False) + response = await tool.async_call( + hass, + llm.ToolInput( + "calendar_get_events", {"calendar": "Mock Calendar Name", "range": "today"} + ), + llm_context, + ) + assert response == {"success": False, "error": "Calendar not found"} + + +async def test_calendar_get_events_tool_uses_aliases( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test exposed calendar aliases are offered as valid tool values.""" + entry = entity_registry.async_get_or_create( + "calendar", "test", "aliased", suggested_object_id="aliased" + ) + entity_registry.async_update_entity(entry.entity_id, aliases={"Family Calendar"}) + hass.states.async_set(entry.entity_id, "on") + async_expose_entity(hass, "conversation", entry.entity_id, True) + + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tool = next(tool for tool in result.tools if tool.name == "calendar_get_events") + assert "Family Calendar" in tool.parameters.schema["calendar"].container diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index dcd7b1faa835..2864c85a3ddb 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -331,10 +331,14 @@ def target_calendars( label_on_devices = label_registry.async_create("label_on_devices") device_calendar_1 = dr.DeviceEntry( - id="device_calendar_1", labels=[label_on_devices.label_id] + config_entry_id="mock-config-entry", + id="device_calendar_1", + labels=[label_on_devices.label_id], ) device_calendar_2 = dr.DeviceEntry( - id="device_calendar_2", labels=[label_on_devices.label_id] + config_entry_id="mock-config-entry", + id="device_calendar_2", + labels=[label_on_devices.label_id], ) mock_device_registry( hass, diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr index 226426353755..83dec59d147b 100644 --- a/tests/components/cambridge_audio/snapshots/test_init.ambr +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.20.218', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Cambridge Audio CXNv2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0020c2d8', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/casper_glow/snapshots/test_init.ambr b/tests/components/casper_glow/snapshots/test_init.ambr index 235ab505621d..5ffd48e71d33 100644 --- a/tests/components/casper_glow/snapshots/test_init.ambr +++ b/tests/components/casper_glow/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': 'G01', 'name': 'Jar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/chess_com/snapshots/test_init.ambr b/tests/components/chess_com/snapshots/test_init.ambr index 32d14022d105..2097ba150e13 100644 --- a/tests/components/chess_com/snapshots/test_init.ambr +++ b/tests/components/chess_com/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Joost', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/cielo_home/snapshots/test_sensor.ambr b/tests/components/cielo_home/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..fc17c32ff764 --- /dev/null +++ b/tests/components/cielo_home/snapshots/test_sensor.ambr @@ -0,0 +1,114 @@ +# serializer version: 1 +# name: test_all_entities[sensor.living_room_living_room_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_living_room_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'cielo_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'device_1-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'humidity', + : 'Living Room Humidity', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.living_room_living_room_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_living_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'cielo_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'device_1-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.living_room_living_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_living_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- diff --git a/tests/components/cielo_home/test_sensor.py b/tests/components/cielo_home/test_sensor.py new file mode 100644 index 000000000000..aff732ae9154 --- /dev/null +++ b/tests/components/cielo_home/test_sensor.py @@ -0,0 +1,89 @@ +"""Tests for the Cielo Home sensor platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("mock_cielo_client", "mock_cielo_device_api") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all sensor entities.""" + with patch("homeassistant.components.cielo_home.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) + + +@pytest.mark.usefixtures("mock_cielo_client") +@pytest.mark.parametrize( + ("temperature_unit", "hass_units", "expected_unit"), + [ + pytest.param( + "°F", + US_CUSTOMARY_SYSTEM, + UnitOfTemperature.FAHRENHEIT, + id="fahrenheit", + ), + pytest.param( + "unknown", + METRIC_SYSTEM, + UnitOfTemperature.CELSIUS, + id="unknown_unit", + ), + pytest.param( + None, + METRIC_SYSTEM, + UnitOfTemperature.CELSIUS, + id="none_unit", + ), + ], +) +async def test_temperature_sensor_unit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cielo_device_api: MagicMock, + entity_registry: er.EntityRegistry, + temperature_unit: str | None, + hass_units: UnitSystem, + expected_unit: str, +) -> None: + """Test temperature sensor reports the correct unit.""" + mock_cielo_device_api.temperature_unit.return_value = temperature_unit + hass.config.units = hass_units + + 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() + + state = hass.states.get("sensor.living_room_living_room_temperature") + assert state is not None + assert state.attributes.get("unit_of_measurement") == expected_unit + + entry = entity_registry.async_get("sensor.living_room_living_room_temperature") + assert entry is not None + assert entry.unit_of_measurement == expected_unit diff --git a/tests/components/citybikes/snapshots/test_sensor.ambr b/tests/components/citybikes/snapshots/test_sensor.ambr index 3da6b71e0b81..91bb5626036b 100644 --- a/tests/components/citybikes/snapshots/test_sensor.ambr +++ b/tests/components/citybikes/snapshots/test_sensor.ambr @@ -7,8 +7,8 @@ 'free_ebikes': 2, : 'Station 1', : 'mdi:bike', - 'latitude': 40.0, - 'longitude': -73.0, + : 40.0, + : -73.0, 'timestamp': '2026-03-22T00:00:00Z', 'uid': 'uid-1', : 'bikes', @@ -29,8 +29,8 @@ 'free_ebikes': None, : 'Station 1', : 'mdi:bike', - 'latitude': 40.0, - 'longitude': -73.0, + : 40.0, + : -73.0, 'timestamp': '2026-03-22T00:00:00Z', 'uid': 'uid-1', : 'bikes', diff --git a/tests/components/climate/test_llm.py b/tests/components/climate/test_llm.py new file mode 100644 index 000000000000..85103a855768 --- /dev/null +++ b/tests/components/climate/test_llm.py @@ -0,0 +1,58 @@ +"""Tests for the climate LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.climate import llm as climate_llm +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "climate.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a climate entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "climate", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test climate"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the climate platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed climate entity.""" + assert "HassClimateSetTemperature" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no climate entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassClimateSetTemperature" not in await _tool_names(hass) + assert climate_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert climate_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index e1cf19020867..72b13befc4ce 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -21,7 +21,7 @@ ## Active Integrations - Built-in integrations: 22 + Built-in integrations: 23 Custom integrations: 1
Built-in integrations @@ -42,6 +42,7 @@ homeassistant | Home Assistant Core Integration http | HTTP intent | Intent + llm | LLM media_source | Media Source mock_no_info_integration | mock_no_info_integration repairs | Repairs @@ -156,7 +157,7 @@ ## Active Integrations - Built-in integrations: 22 + Built-in integrations: 23 Custom integrations: 0
Built-in integrations @@ -177,6 +178,7 @@ homeassistant | Home Assistant Core Integration http | HTTP intent | Intent + llm | LLM media_source | Media Source mock_no_info_integration | mock_no_info_integration repairs | Repairs diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index ce450b89cc6a..6d3d588027ae 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -959,6 +959,8 @@ async def test_websocket_status( "alexa_default_expose": DEFAULT_EXPOSED_DOMAINS, "alexa_report_state": True, "google_report_state": True, + "onboarded_items": [], + "onboarding_postponed_until": None, "remote_allow_remote_enable": True, "remote_enabled": False, "cloud_ice_servers_enabled": True, @@ -989,6 +991,8 @@ async def test_websocket_status( "remote_certificate": None, "http_use_ssl": False, "active_subscription": True, + "onboarding_completed": False, + "onboarding_postponed": False, } @@ -1235,6 +1239,106 @@ async def test_websocket_update_preferences_no_token( assert response["error"]["code"] == "alexa_relink" +async def test_websocket_cloud_onboarding_postpone( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, + freezer: FrozenDateTimeFactory, +) -> None: + """Test postponing onboarding.""" + client = await hass_ws_client(hass) + + assert cloud.client.prefs.onboarding_postponed is False + + await client.send_json_auto_id({"type": "cloud/onboarding/postpone"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["onboarding_postponed"] is True + assert cloud.client.prefs.onboarding_postponed_until is not None + + freezer.tick(datetime.timedelta(hours=25)) + + await client.send_json_auto_id({"type": "cloud/status"}) + response = await client.receive_json() + + assert response["result"]["onboarding_postponed"] is False + + +async def test_websocket_cloud_onboarding_complete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, +) -> None: + """Test completing onboarding items.""" + client = await hass_ws_client(hass) + + assert cloud.client.prefs.onboarded_items == [] + assert cloud.client.prefs.onboarding_completed is False + + # Complete a subset of items + await client.send_json_auto_id( + {"type": "cloud/onboarding/complete", "items": ["remote", "backup"]} + ) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.prefs.onboarded_items == ["remote", "backup"] + assert response["result"]["onboarding_completed"] is False + + # Already-completed items are ignored, only new ones are added + await client.send_json_auto_id( + { + "type": "cloud/onboarding/complete", + "items": ["remote", "voice", "streaming"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.prefs.onboarding_completed is True + assert response["result"]["onboarding_completed"] is True + assert cloud.client.prefs.onboarded_items == [ + "remote", + "backup", + "voice", + "streaming", + ] + + # Completing already-completed items is a no-op + await client.send_json_auto_id( + {"type": "cloud/onboarding/complete", "items": ["remote", "backup"]} + ) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.prefs.onboarded_items == [ + "remote", + "backup", + "voice", + "streaming", + ] + + +async def test_websocket_cloud_onboarding_complete_invalid_item( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, +) -> None: + """Test completing an invalid onboarding item.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "cloud/onboarding/complete", "items": ["remote", "invalid"]} + ) + response = await client.receive_json() + + assert not response["success"] + + async def test_enabling_webhook( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1789,6 +1893,8 @@ async def test_support_package_requires_admin( {"type": "cloud/update_prefs", "alexa_report_state": True}, {"type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"}, {"type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"}, + {"type": "cloud/onboarding/postpone"}, + {"type": "cloud/onboarding/complete", "items": ["remote"]}, ], ) async def test_ws_commands_require_admin( diff --git a/tests/components/common.py b/tests/components/common.py index c8e0a869aa4d..98087ae1e18d 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -90,7 +90,12 @@ async def target_entities( "Test Label" ) - device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id}) + device = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="test_device", + area_id=area.id, + labels={label.label_id}, + ) mock_device_registry(hass, {device.id: device}) entity_reg = er.async_get(hass) diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 12dec880c4f5..5b8ea0bbd05b 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -134,7 +134,7 @@ async def test_websocket_core_update(hass: HomeAssistant, client) -> None: "type": "config/core/update", "latitude": 60, "longitude": 50, - "elevation": 25, + "elevation": 25.6, "location_name": "Huis", "unit_system": "imperial", "time_zone": "America/New_York", diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 4c0f5f18e3bc..153d4f5c685f 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -61,6 +61,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -84,6 +86,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [], "created_at": utcnow().timestamp(), @@ -119,6 +123,8 @@ async def test_list_devices( "area_id": None, "config_entries": [entry.entry_id], "config_entries_subentries": {entry.entry_id: [None]}, + "config_entry_id": entry.entry_id, + "config_subentry_id": None, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -307,7 +313,7 @@ async def test_remove_config_entry_from_device( entry_2.supports_remove_device = True entry_2.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -315,11 +321,14 @@ async def test_remove_config_entry_from_device( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + # Identifiers and connections are unique per config entry, so the two config + # entries get separate devices even though they share a connection + assert device_entry_1.id != device_entry.id + assert device_entry.config_entries == {entry_2.entry_id} - # Try removing a config entry from the device, it should fail because + # Try removing the config entry from the device, it should fail because # async_remove_config_entry_device returns False - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -327,26 +336,21 @@ async def test_remove_config_entry_from_device( # Make async_remove_config_entry_device return True can_remove = True - # Remove the 1st config entry - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) - - assert response["success"] - assert response["result"]["config_entries"] == [entry_2.entry_id] - - # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { - entry_2.entry_id - } - - # Remove the 2nd config entry + # Remove the config entry, this was the device's only config entry so the + # device is removed response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None - # This was the last config entry, the device is removed + # This was the only config entry, the device is removed assert not device_registry.async_get(device_entry.id) + # The device belonging to the other config entry is untouched + assert device_registry.async_get(device_entry_1.id).config_entries == { + entry_1.entry_id + } + async def test_remove_config_entry_from_device_fails( hass: HomeAssistant, @@ -396,38 +400,38 @@ async def test_remove_config_entry_from_device_fails( entry_3.supports_remove_device = True entry_3.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_entry = device_registry.async_get_or_create( + device_entry_3 = device_registry.async_get_or_create( config_entry_id=entry_3.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - entry_1.entry_id, - entry_2.entry_id, - entry_3.entry_id, - } + # Identifiers and connections are unique per config entry, so each config entry + # gets its own device even though they share a connection + assert device_entry_1.config_entries == {entry_1.entry_id} + assert device_entry_2.config_entries == {entry_2.entry_id} + assert device_entry_3.config_entries == {entry_3.entry_id} fake_entry_id = "abc123" assert entry_1.entry_id != fake_entry_id fake_device_id = "abc123" - assert device_entry.id != fake_device_id + assert device_entry_3.id != fake_device_id # Try removing a non existing config entry from the device - response = await ws_client.remove_device(device_entry.id, fake_entry_id) + response = await ws_client.remove_device(device_entry_3.id, fake_entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry_1.id, entry_1.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -443,22 +447,22 @@ async def test_remove_config_entry_from_device_fails( assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to - response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) - - assert response["success"] - assert set(response["result"]["config_entries"]) == { - entry_1.entry_id, - entry_3.entry_id, - } - - response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) + response = await ws_client.remove_device(device_entry_3.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" + # Removing a config entry which supports removal removes the device, since it is + # the device's only config entry + response = await ws_client.remove_device(device_entry_2.id, entry_2.entry_id) + + assert response["success"] + assert response["result"] is None + assert not device_registry.async_get(device_entry_2.id) + # Try removing a config entry which can't be loaded from a device - allowed - response = await ws_client.remove_device(device_entry.id, entry_3.entry_id) + response = await ws_client.remove_device(device_entry_3.id, entry_3.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -517,7 +521,7 @@ async def test_remove_config_entry_from_device_if_integration_remove( entry_2.supports_remove_device = True entry_2.add_to_hass(hass) - device_registry.async_get_or_create( + device_entry_1 = device_registry.async_get_or_create( config_entry_id=entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -525,11 +529,14 @@ async def test_remove_config_entry_from_device_if_integration_remove( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + # Identifiers and connections are unique per config entry, so the two config + # entries get separate devices even though they share a connection + assert device_entry_1.id != device_entry.id + assert device_entry.config_entries == {entry_2.entry_id} - # Try removing a config entry from the device, it should fail because + # Try removing the config entry from the device, it should fail because # async_remove_config_entry_device returns False - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -537,22 +544,17 @@ async def test_remove_config_entry_from_device_if_integration_remove( # Make async_remove_config_entry_device return True can_remove = True - # Remove the 1st config entry - response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) - - assert response["success"] - assert response["result"]["config_entries"] == [entry_2.entry_id] - - # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { - entry_2.entry_id - } - - # Remove the 2nd config entry + # Remove the config entry, this was the device's only config entry so the + # device is removed response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None - # This was the last config entry, the device is removed + # This was the only config entry, the device is removed assert not device_registry.async_get(device_entry.id) + + # The device belonging to the other config entry is untouched + assert device_registry.async_get(device_entry_1.id).config_entries == { + entry_1.entry_id + } diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index 5fabcbd9954b..8796370cf782 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -5,7 +5,7 @@ from datetime import timedelta import pytest from homeassistant.components import configurator -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import EntityStateAttribute from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import Unauthorized from homeassistant.util import dt as dt_util @@ -40,7 +40,7 @@ async def test_request_least_info(hass: HomeAssistant) -> None: async def test_request_all_info(hass: HomeAssistant) -> None: """Test request config with all possible info.""" exp_attr = { - ATTR_FRIENDLY_NAME: "Test Request", + EntityStateAttribute.FRIENDLY_NAME: "Test Request", configurator.ATTR_DESCRIPTION: """config description [link name](link url) @@ -48,7 +48,7 @@ async def test_request_all_info(hass: HomeAssistant) -> None: ![Description image](config image url)""", configurator.ATTR_SUBMIT_CAPTION: "config submit caption", configurator.ATTR_FIELDS: [], - configurator.ATTR_ENTITY_PICTURE: "config entity picture", + EntityStateAttribute.ENTITY_PICTURE: "config entity picture", configurator.ATTR_CONFIGURE_ID: configurator.async_request_config( hass, name="Test Request", diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1ccb6de16684..8fe4d8d26b76 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -895,14 +895,6 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.coordinator.get_supervisor_client", return_value=supervisor_client, ), - patch( - "homeassistant.components.hassio.issues.get_supervisor_client", - return_value=supervisor_client, - ), - patch( - "homeassistant.components.hassio.jobs.get_supervisor_client", - return_value=supervisor_client, - ), patch( "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, diff --git a/tests/components/control4/conftest.py b/tests/components/control4/conftest.py index 38300b88f0aa..de66348dfcc2 100644 --- a/tests/components/control4/conftest.py +++ b/tests/components/control4/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Control4 tests.""" from collections.abc import AsyncGenerator, Generator +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -54,16 +55,18 @@ def mock_c4_account() -> Generator[MagicMock]: ), ): mock_account = mock_account_class.return_value - mock_account.getAccountBearerToken = AsyncMock() - mock_account.getAccountControllers = AsyncMock( + mock_account.get_account_bearer_token = AsyncMock() + mock_account.get_account_controllers = AsyncMock( return_value={ "controllerCommonName": "control4_model_00AA00AA00AA", "href": "https://apis.control4.com/account/v3/rest/accounts/000000", "name": "Name", } ) - mock_account.getDirectorBearerToken = AsyncMock(return_value={"token": "test"}) - mock_account.getControllerOSVersion = AsyncMock(return_value="3.2.0") + mock_account.get_director_bearer_token = AsyncMock( + return_value={"token": "test", "validSeconds": 86400} + ) + mock_account.get_controller_os_version = AsyncMock(return_value="3.2.0") yield mock_account @@ -80,14 +83,12 @@ def mock_c4_director() -> Generator[MagicMock]: ), ): mock_director = mock_director_class.return_value - # Multi-platform setup: media room, climate room, shared devices - # Note: The API returns JSON strings, so we load fixtures as strings - mock_director.getAllItemInfo = AsyncMock( - return_value=load_fixture("director_all_items.json", DOMAIN) - ) - mock_director.getUiConfiguration = AsyncMock( - return_value=load_fixture("ui_configuration.json", DOMAIN) + all_items = json.loads(load_fixture("director_all_items.json", DOMAIN)) + mock_director.get_all_item_info = AsyncMock(return_value=all_items) + mock_director.get_ui_configuration = AsyncMock( + return_value=json.loads(load_fixture("ui_configuration.json", DOMAIN)) ) + mock_director.get_item_variables = AsyncMock(return_value=[]) yield mock_director @@ -157,12 +158,12 @@ def mock_c4_climate() -> Generator[MagicMock]: "homeassistant.components.control4.climate.C4Climate", autospec=True ) as mock_class: mock_instance = mock_class.return_value - mock_instance.setHvacMode = AsyncMock() - mock_instance.setHeatSetpointF = AsyncMock() - mock_instance.setCoolSetpointF = AsyncMock() - mock_instance.setFanMode = AsyncMock() - mock_instance.setHeatSetpointC = AsyncMock() - mock_instance.setCoolSetpointC = AsyncMock() + mock_instance.set_hvac_mode = AsyncMock() + mock_instance.set_heat_setpoint_f = AsyncMock() + mock_instance.set_cool_setpoint_f = AsyncMock() + mock_instance.set_fan_mode = AsyncMock() + mock_instance.set_heat_setpoint_c = AsyncMock() + mock_instance.set_cool_setpoint_c = AsyncMock() yield mock_instance diff --git a/tests/components/control4/test_climate.py b/tests/components/control4/test_climate.py index d7e348db9bb5..6f6f628cbfd5 100644 --- a/tests/components/control4/test_climate.py +++ b/tests/components/control4/test_climate.py @@ -253,7 +253,7 @@ async def test_set_hvac_mode( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) - mock_c4_climate.setHvacMode.assert_called_once_with(expected_c4_mode) + mock_c4_climate.set_hvac_mode.assert_called_once_with(expected_c4_mode) @pytest.mark.parametrize( @@ -265,7 +265,7 @@ async def test_set_hvac_mode( temperature=72.5, humidity=45, ), - "setHeatSetpointF", + "set_heat_setpoint_f", id="heat", ), pytest.param( @@ -275,7 +275,7 @@ async def test_set_hvac_mode( temperature=74.0, cool_setpoint=72.0, ), - "setCoolSetpointF", + "set_cool_setpoint_f", id="cool", ), ], @@ -330,8 +330,8 @@ async def test_set_temperature_range_auto_mode( }, blocking=True, ) - mock_c4_climate.setHeatSetpointF.assert_called_once_with(65.0) - mock_c4_climate.setCoolSetpointF.assert_called_once_with(78.0) + mock_c4_climate.set_heat_setpoint_f.assert_called_once_with(65.0) + mock_c4_climate.set_cool_setpoint_f.assert_called_once_with(78.0) @pytest.mark.parametrize("mock_climate_variables", [{}]) @@ -482,7 +482,7 @@ async def test_set_fan_mode( blocking=True, ) # Verify the Control4 API is called with the C4 format ("On" not "on") - mock_c4_climate.setFanMode.assert_called_once_with("On") + mock_c4_climate.set_fan_mode.assert_called_once_with("On") @pytest.mark.parametrize( @@ -530,14 +530,14 @@ async def test_fan_mode_not_supported( [ pytest.param( _make_climate_data(hvac_state="Off", hvac_mode="Heat"), - "setHeatSetpointF", - "setHeatSetpointC", + "set_heat_setpoint_f", + "set_heat_setpoint_c", id="fahrenheit_heat_calls_F_not_C", ), pytest.param( _make_climate_data(hvac_state="Cool", hvac_mode="Cool"), - "setCoolSetpointF", - "setCoolSetpointC", + "set_cool_setpoint_f", + "set_cool_setpoint_c", id="fahrenheit_cool_calls_F_not_C", ), ], diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py index 773f692c2ef3..0e99ef1e4ade 100644 --- a/tests/components/control4/test_config_flow.py +++ b/tests/components/control4/test_config_flow.py @@ -80,7 +80,7 @@ async def test_user_flow_errors( DOMAIN, context={"source": SOURCE_USER} ) - mock_c4_account.getAccountBearerToken.side_effect = exception + mock_c4_account.get_account_bearer_token.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -94,7 +94,7 @@ async def test_user_flow_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_c4_account.getAccountBearerToken.side_effect = None + mock_c4_account.get_account_bearer_token.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -130,7 +130,7 @@ async def test_user_flow_director_errors( DOMAIN, context={"source": SOURCE_USER} ) - mock_c4_director.getAllItemInfo.side_effect = exception + mock_c4_director.get_all_item_info.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -144,7 +144,7 @@ async def test_user_flow_director_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_c4_director.getAllItemInfo.side_effect = None + mock_c4_director.get_all_item_info.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/control4/test_cover.py b/tests/components/control4/test_cover.py index 55b42165a3ef..84c81083905f 100644 --- a/tests/components/control4/test_cover.py +++ b/tests/components/control4/test_cover.py @@ -93,7 +93,7 @@ def mock_c4_blind() -> Generator[MagicMock]: mock_instance.open = AsyncMock() mock_instance.close = AsyncMock() mock_instance.stop = AsyncMock() - mock_instance.setLevelTarget = AsyncMock() + mock_instance.set_level_target = AsyncMock() yield mock_instance @@ -245,14 +245,14 @@ async def test_set_cover_position( hass: HomeAssistant, mock_c4_blind: MagicMock, ) -> None: - """Test setting cover position calls setLevelTarget with the requested value.""" + """Test setting cover position calls set_level_target with the requested value.""" await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 75}, blocking=True, ) - mock_c4_blind.setLevelTarget.assert_called_once_with(75) + mock_c4_blind.set_level_target.assert_called_once_with(75) @pytest.mark.parametrize("mock_cover_variables", [{}]) diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 164a6aafe510..1af92459d047 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -25,14 +25,22 @@ from homeassistant.components.conversation.chat_log import ( ChatLogEventType, async_subscribe_chat_logs, ) +from homeassistant.components.llm import LLMTools from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, llm +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed +@pytest.fixture(autouse=True) +async def setup_llm(hass: HomeAssistant) -> None: + """Set up the llm integration so the Assist API can pull its tools.""" + assert await async_setup_component(hass, "llm", {}) + + async def test_cleanup( hass: HomeAssistant, mock_conversation_input: ConversationInput, @@ -434,67 +442,67 @@ async def test_tool_call( ) mock_tool.async_call.return_value = "Test response" - with patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools: - mock_get_tools.return_value = [mock_tool] + with ( + patch( + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + content = AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), + ], + ) - with ( - chat_session.async_get_chat_session(hass) as session, - async_get_chat_log(hass, session, mock_conversation_input) as chat_log, - ): - await chat_log.async_provide_llm_data( - mock_conversation_input.as_llm_context("test"), - user_llm_hass_api="assist", - user_llm_prompt=None, + tool_call_tasks = { + tool_call_id: hass.async_create_task( + chat_log.llm_api.async_call_tool(content.tool_calls[0]), + tool_call_id, ) - content = AssistantContent( - agent_id=mock_conversation_input.agent_id, - content="", - tool_calls=[ - llm.ToolInput( - id="mock-tool-call-id", - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ), - llm.ToolInput( - id="mock-tool-call-id-2", - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ), - ], + for tool_call_id in prerun_tool_tasks + } + + with pytest.raises(ValueError): + chat_log.async_add_assistant_content_without_tools(content) + + results = [ + tool_result_content + async for tool_result_content in chat_log.async_add_assistant_content( + content, tool_call_tasks=tool_call_tasks or None ) + ] - tool_call_tasks = { - tool_call_id: hass.async_create_task( - chat_log.llm_api.async_call_tool(content.tool_calls[0]), - tool_call_id, - ) - for tool_call_id in prerun_tool_tasks - } - - with pytest.raises(ValueError): - chat_log.async_add_assistant_content_without_tools(content) - - results = [ - tool_result_content - async for tool_result_content in chat_log.async_add_assistant_content( - content, tool_call_tasks=tool_call_tasks or None - ) - ] - - assert results[0] == ToolResultContent( - agent_id=mock_conversation_input.agent_id, - tool_call_id="mock-tool-call-id", - tool_result="Test response", - tool_name="test_tool", - ) - assert results[1] == ToolResultContent( - agent_id=mock_conversation_input.agent_id, - tool_call_id="mock-tool-call-id-2", - tool_result="Test response", - tool_name="test_tool", - ) + assert results[0] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result="Test response", + tool_name="test_tool", + ) + assert results[1] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id-2", + tool_result="Test response", + tool_name="test_tool", + ) @freeze_time("2025-10-31 12:00:00") @@ -514,12 +522,13 @@ async def test_tool_call_exception( with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools, + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - mock_get_tools.return_value = [mock_tool] await chat_log.async_provide_llm_data( mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", @@ -712,8 +721,10 @@ async def test_add_delta_content_stream( with ( patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools, + "homeassistant.components.llm.async_get_tools", + new_callable=AsyncMock, + return_value=LLMTools(tools=[mock_tool]), + ), chat_session.async_get_chat_session(hass) as session, async_get_chat_log( hass, @@ -724,7 +735,6 @@ async def test_add_delta_content_stream( ), ) as chat_log, ): - mock_get_tools.return_value = [mock_tool] await chat_log.async_provide_llm_data( mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index ab0acd3d1fac..3b852d7883fc 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -351,7 +351,7 @@ async def test_expose_flag_automatically_set( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - with patch("homeassistant.components.http.start_http_server_and_save_config"): + with patch("homeassistant.components.http.HomeAssistantHTTP.start"): await hass.async_start() # After setting up conversation, the expose flag should now be set on all entities diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 61f63f4a6e9f..8ec76c557497 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -6,7 +6,6 @@ from typing import Any import pytest from homeassistant.components.counter import ( - ATTR_EDITABLE, ATTR_INITIAL, ATTR_MAXIMUM, ATTR_MINIMUM, @@ -24,7 +23,13 @@ from homeassistant.components.counter import ( SERVICE_SET_VALUE, VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME +from homeassistant.const import ( + ATTR_EDITABLE, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_NAME, +) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component diff --git a/tests/components/counter/test_reproduce_state.py b/tests/components/counter/test_reproduce_state.py index 6b985b5a6872..7cd959dcf111 100644 --- a/tests/components/counter/test_reproduce_state.py +++ b/tests/components/counter/test_reproduce_state.py @@ -15,44 +15,34 @@ async def test_reproducing_states( """Test reproducing Counter states.""" hass.states.async_set("counter.entity", "5", {}) hass.states.async_set( - "counter.entity_attr", - "8", - {"minimum": 5, "maximum": 15, "step": 3}, + "counter.entity_attr", "8", {"minimum": 5, "maximum": 15, "step": 3} ) - configure_calls = async_mock_service(hass, DOMAIN, "set_value") + set_value_calls = async_mock_service(hass, DOMAIN, "set_value") - # These calls should do nothing as entities already in desired state + # These calls should do nothing as entity already in desired state await async_reproduce_state( hass, [ State("counter.entity", "5"), - State( - "counter.entity_attr", - "8", - {"minimum": 5, "maximum": 15, "step": 3}, - ), + State("counter.entity_attr", "8"), ], ) - assert len(configure_calls) == 0 + assert len(set_value_calls) == 0 # Test invalid state is handled await async_reproduce_state(hass, [State("counter.entity", "not_supported")]) assert "not_supported" in caplog.text - assert len(configure_calls) == 0 + assert len(set_value_calls) == 0 # Make sure correct services are called await async_reproduce_state( hass, [ State("counter.entity", "2"), - State( - "counter.entity_attr", - "7", - {"minimum": 3, "maximum": 21, "step": 5}, - ), + State("counter.entity_attr", "7", {"minimum": 3, "maximum": 21, "step": 5}), # Should not raise State("counter.non_existing", "6"), ], @@ -60,16 +50,10 @@ async def test_reproducing_states( valid_calls = [ {"entity_id": "counter.entity", "value": "2"}, - { - "entity_id": "counter.entity_attr", - "value": "7", - "minimum": 3, - "maximum": 21, - "step": 5, - }, + {"entity_id": "counter.entity_attr", "value": "7"}, ] - assert len(configure_calls) == 2 - for call in configure_calls: + assert len(set_value_calls) == 2 + for call in set_value_calls: assert call.domain == "counter" assert call.data in valid_calls valid_calls.remove(call.data) diff --git a/tests/components/data_grand_lyon/conftest.py b/tests/components/data_grand_lyon/conftest.py index 5cbf124aa2d5..672ec2baba59 100644 --- a/tests/components/data_grand_lyon/conftest.py +++ b/tests/components/data_grand_lyon/conftest.py @@ -5,6 +5,7 @@ from datetime import datetime from unittest.mock import AsyncMock, patch from data_grand_lyon_ha import ( + TclParkAndRide, TclPassage, TclPassageType, TclStop, @@ -17,9 +18,11 @@ import pytest from homeassistant.components.data_grand_lyon.const import ( CONF_LINE, + CONF_PARK_ID, CONF_STATION_ID, CONF_STOP_ID, DOMAIN, + SUBENTRY_TYPE_PARK_AND_RIDE, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION, ) @@ -138,6 +141,36 @@ MOCK_VELOV_STATIONS = [ ] +MOCK_PARK_AND_RIDE = TclParkAndRide( + id="P+R Gorge de Loup", + gid=10, + nom="Gorge de Loup", + capacite=240, + place_handi=6, + horaires="24h/24", + p_surv=True, + nb_tot_place_dispo=42, + last_update=datetime(2026, 4, 10, 14, 0), + last_update_fme=datetime(2026, 4, 10, 13, 55), +) + +MOCK_PARK_AND_RIDES = [ + MOCK_PARK_AND_RIDE, + TclParkAndRide( + id="P+R Oullins La Saulaie", + gid=20, + nom="Oullins La Saulaie", + capacite=420, + place_handi=10, + horaires="5h-1h", + p_surv=False, + nb_tot_place_dispo=120, + last_update=datetime(2026, 4, 10, 14, 0), + last_update_fme=datetime(2026, 4, 10, 13, 55), + ), +] + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -175,6 +208,20 @@ def mock_velov_subentries() -> list[ConfigSubentryData]: ] +@pytest.fixture +def mock_park_and_ride_subentries() -> list[ConfigSubentryData]: + """Mock park-and-ride subentries.""" + return [ + ConfigSubentryData( + data={CONF_PARK_ID: "P+R Gorge de Loup"}, + subentry_id="park_1", + subentry_type=SUBENTRY_TYPE_PARK_AND_RIDE, + title="Gorge de Loup", + unique_id="park_and_ride_P+R Gorge de Loup", + ) + ] + + @pytest.fixture def mock_config_entry( mock_subentries: list[ConfigSubentryData], @@ -201,6 +248,19 @@ def mock_velov_config_entry( ) +@pytest.fixture +def mock_park_and_ride_config_entry( + mock_park_and_ride_subentries: list[ConfigSubentryData], +) -> MockConfigEntry: + """Create a mock config entry with park-and-ride subentries.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=mock_park_and_ride_subentries, + ) + + @pytest.fixture def mock_tcl_client() -> Generator[AsyncMock]: """Mock DataGrandLyonClient for coordinator and config flow.""" @@ -218,4 +278,5 @@ def mock_tcl_client() -> Generator[AsyncMock]: client.get_tcl_passages.return_value = MOCK_DEPARTURES client.get_tcl_stops.return_value = MOCK_TCL_STOPS client.get_velov_stations.return_value = MOCK_VELOV_STATIONS + client.get_tcl_park_and_rides.return_value = MOCK_PARK_AND_RIDES yield client diff --git a/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr b/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr index 05986de52c95..83f752ab32be 100644 --- a/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr +++ b/tests/components/data_grand_lyon/snapshots/test_diagnostics.ambr @@ -32,6 +32,8 @@ 'version': 1, }), 'coordinator_data': dict({ + 'park_and_rides': dict({ + }), 'stops': dict({ 'stop_1': list([ dict({ @@ -61,6 +63,59 @@ }), }) # --- +# name: test_config_entry_diagnostics_with_park_and_ride + dict({ + 'config_entry': dict({ + 'data': dict({ + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'data_grand_lyon', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + dict({ + 'data': dict({ + 'park_id': 'P+R Gorge de Loup', + }), + 'subentry_type': 'park_and_ride', + 'title': 'Gorge de Loup', + 'unique_id': 'park_and_ride_P+R Gorge de Loup', + }), + ]), + 'title': 'Data Grand Lyon', + 'unique_id': None, + 'version': 1, + }), + 'coordinator_data': dict({ + 'park_and_rides': dict({ + 'park_1': dict({ + 'capacite': 240, + 'gid': 10, + 'horaires': '24h/24', + 'id': 'P+R Gorge de Loup', + 'last_update': '2026-04-10T14:00:00', + 'last_update_fme': '2026-04-10T13:55:00', + 'nb_tot_place_dispo': 42, + 'nom': 'Gorge de Loup', + 'p_surv': True, + 'place_handi': 6, + }), + }), + 'stops': dict({ + }), + 'velov_stations': dict({ + }), + }), + }) +# --- # name: test_config_entry_diagnostics_with_velov dict({ 'config_entry': dict({ @@ -93,6 +148,8 @@ 'version': 1, }), 'coordinator_data': dict({ + 'park_and_rides': dict({ + }), 'stops': dict({ }), 'velov_stations': dict({ diff --git a/tests/components/data_grand_lyon/snapshots/test_sensor.ambr b/tests/components/data_grand_lyon/snapshots/test_sensor.ambr index 4820fb4e75d3..9f44f5f8da5e 100644 --- a/tests/components/data_grand_lyon/snapshots/test_sensor.ambr +++ b/tests/components/data_grand_lyon/snapshots/test_sensor.ambr @@ -482,6 +482,159 @@ 'state': 'unavailable', }) # --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_accessible_parking_spaces-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.gorge_de_loup_accessible_parking_spaces', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Accessible parking spaces', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Accessible parking spaces', + 'platform': 'data_grand_lyon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'accessible_spaces', + 'unique_id': 'park_and_ride_P+R Gorge de Loup-accessible_spaces', + 'unit_of_measurement': 'spaces', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_accessible_parking_spaces-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Gorge de Loup Accessible parking spaces', + : 'spaces', + }), + 'context': , + 'entity_id': 'sensor.gorge_de_loup_accessible_parking_spaces', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_available_parking_spaces-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.gorge_de_loup_available_parking_spaces', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Available parking spaces', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Available parking spaces', + 'platform': 'data_grand_lyon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'available_spaces', + 'unique_id': 'park_and_ride_P+R Gorge de Loup-available_spaces', + 'unit_of_measurement': 'spaces', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_available_parking_spaces-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Gorge de Loup Available parking spaces', + : 'spaces', + }), + 'context': , + 'entity_id': 'sensor.gorge_de_loup_available_parking_spaces', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.gorge_de_loup_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Capacity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Capacity', + 'platform': 'data_grand_lyon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'park_and_ride_capacity', + 'unique_id': 'park_and_ride_P+R Gorge de Loup-capacity', + 'unit_of_measurement': 'spaces', + }) +# --- +# name: test_park_and_ride_all_entities[sensor.gorge_de_loup_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Gorge de Loup Capacity', + : 'spaces', + }), + 'context': , + 'entity_id': 'sensor.gorge_de_loup_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '240', + }) +# --- # name: test_velov_all_entities[sensor.velo_v_1001_available_bikes-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/data_grand_lyon/test_config_flow.py b/tests/components/data_grand_lyon/test_config_flow.py index a18dfcf03c2e..c64879ef0182 100644 --- a/tests/components/data_grand_lyon/test_config_flow.py +++ b/tests/components/data_grand_lyon/test_config_flow.py @@ -8,9 +8,11 @@ import pytest from homeassistant import config_entries from homeassistant.components.data_grand_lyon.const import ( CONF_LINE, + CONF_PARK_ID, CONF_STATION_ID, CONF_STOP_ID, DOMAIN, + SUBENTRY_TYPE_PARK_AND_RIDE, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION, ) @@ -596,3 +598,126 @@ async def test_velov_station_subentry_picker_load_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason + + +# Park-and-ride subentry tests + + +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_park_and_ride_subentry_picker_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test adding a park-and-ride subentry by picking one from the list.""" + mock_config_entry.add_to_hass(hass) + 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_TYPE_PARK_AND_RIDE), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert mock_tcl_client.get_tcl_park_and_rides.await_count == 1 + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PARK_ID: "P+R Gorge de Loup"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gorge de Loup" + assert result["data"] == {CONF_PARK_ID: "P+R Gorge de Loup"} + assert result["unique_id"] == "park_and_ride_P+R Gorge de Loup" + + +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_park_and_ride_subentry_custom_value_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test adding a park-and-ride by typing an ID not present in the list.""" + mock_config_entry.add_to_hass(hass) + 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_TYPE_PARK_AND_RIDE), + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PARK_ID: "P+R Unknown"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "P+R Unknown" + assert result["data"] == {CONF_PARK_ID: "P+R Unknown"} + assert result["unique_id"] == "park_and_ride_P+R Unknown" + + +async def test_park_and_ride_subentry_already_configured( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test park-and-ride subentry aborts if same facility already exists.""" + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_park_and_ride_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_PARK_ID: "P+R Gorge de Loup"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + ( + ClientResponseError(request_info=None, history=(), status=500), + "cannot_connect", + ), + ( + ClientResponseError(request_info=None, history=(), status=401), + "invalid_auth", + ), + (ClientConnectionError("boom"), "cannot_connect"), + (TimeoutError("boom"), "cannot_connect"), + (RuntimeError("boom"), "unknown"), + ], +) +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_park_and_ride_subentry_picker_load_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, + side_effect: Exception, + reason: str, +) -> None: + """Test picker aborts with the right reason when loading park-and-rides fails.""" + 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_tcl_client.get_tcl_park_and_rides.side_effect = side_effect + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_PARK_AND_RIDE), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason diff --git a/tests/components/data_grand_lyon/test_diagnostics.py b/tests/components/data_grand_lyon/test_diagnostics.py index 28ceda0f7326..424036bac889 100644 --- a/tests/components/data_grand_lyon/test_diagnostics.py +++ b/tests/components/data_grand_lyon/test_diagnostics.py @@ -44,3 +44,20 @@ async def test_config_entry_diagnostics_with_velov( assert await get_diagnostics_for_config_entry( hass, hass_client, mock_velov_config_entry ) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id")) + + +async def test_config_entry_diagnostics_with_park_and_ride( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics with park-and-ride data.""" + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_park_and_ride_config_entry + ) == snapshot(exclude=props("created_at", "modified_at", "entry_id", "subentry_id")) diff --git a/tests/components/data_grand_lyon/test_sensor.py b/tests/components/data_grand_lyon/test_sensor.py index cfb6706af47b..d00f4b47c235 100644 --- a/tests/components/data_grand_lyon/test_sensor.py +++ b/tests/components/data_grand_lyon/test_sensor.py @@ -344,3 +344,121 @@ async def test_coordinator_mixed_partial_failure( await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + + +# Park-and-ride sensor tests + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_park_and_ride_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test all park-and-ride sensor entities (state, attributes, registry).""" + with patch("homeassistant.components.data_grand_lyon.PLATFORMS", [Platform.SENSOR]): + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_park_and_ride_config_entry.entry_id + ) + + +async def test_park_and_ride_sensor_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that diagnostic park-and-ride sensors are disabled by default.""" + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + for unique_id in ("park_and_ride_P+R Gorge de Loup-accessible_spaces",): + entry = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entry is not None, unique_id + reg_entry = entity_registry.async_get(entry) + assert reg_entry is not None, unique_id + assert reg_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + for unique_id in ( + "park_and_ride_P+R Gorge de Loup-available_spaces", + "park_and_ride_P+R Gorge de Loup-capacity", + ): + entry = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entry is not None, unique_id + reg_entry = entity_registry.async_get(entry) + assert reg_entry is not None, unique_id + assert reg_entry.disabled_by is None + + +async def test_park_and_ride_sensor_no_data( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that park-and-ride sensors are unavailable when facility not found.""" + mock_tcl_client.get_tcl_park_and_rides.return_value = [] + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gorge_de_loup_available_parking_spaces") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_coordinator_park_and_ride_auth_error( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator triggers reauth on park-and-ride auth failure.""" + mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientResponseError( + Mock(), (), status=401 + ) + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert any(flow["context"].get("source") == SOURCE_REAUTH for flow in flows) + + +async def test_coordinator_park_and_ride_http_error( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator raises UpdateFailed on non-auth HTTP errors for P+R.""" + mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientResponseError( + Mock(), (), status=500 + ) + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_park_and_ride_fetch_error( + hass: HomeAssistant, + mock_park_and_ride_config_entry: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator raises UpdateFailed on park-and-ride fetch error.""" + mock_tcl_client.get_tcl_park_and_rides.side_effect = ClientConnectionError( + "API down" + ) + mock_park_and_ride_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_park_and_ride_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_park_and_ride_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py index ad036b68e001..bca5b30f9aa2 100644 --- a/tests/components/deconz/conftest.py +++ b/tests/components/deconz/conftest.py @@ -87,16 +87,27 @@ def fixture_config_entry_source() -> str: @pytest.fixture(name="mock_put_request") def fixture_put_request( - aioclient_mock: AiohttpClientMocker, config_entry_data: MappingProxyType[str, Any] -) -> Callable[[str, str], AiohttpClientMocker]: + aioclient_mock: AiohttpClientMocker, + config_entry_data: MappingProxyType[str, Any], +) -> Callable[..., AiohttpClientMocker]: """Mock a deCONZ put request.""" _host = config_entry_data[CONF_HOST] _port = config_entry_data[CONF_PORT] _api_key = config_entry_data[CONF_API_KEY] - def __mock_requests(path: str, host: str = "") -> AiohttpClientMocker: + def __mock_requests( + path: str, + host: str = "", + *, + exc: Exception | type[Exception] | None = None, + ) -> AiohttpClientMocker: url = f"http://{host or _host}:{_port}/api/{_api_key}{path}" - aioclient_mock.put(url, json={}, headers={"content-type": CONTENT_TYPE_JSON}) + aioclient_mock.put( + url, + json={}, + exc=exc, + headers={"content-type": CONTENT_TYPE_JSON}, + ) return aioclient_mock return __mock_requests @@ -129,14 +140,17 @@ def fixture_get_request( sensor_payload = {"0": sensor_payload} data.setdefault("sensors", sensor_payload) - def __mock_requests(host: str = "") -> None: + def __mock_requests( + host: str = "", + *, + exc: Exception | type[Exception] | None = None, + ) -> None: url = f"http://{host or _host}:{_port}/api/{_api_key}" aioclient_mock.get( url, json=deconz_payload | {"config": config_payload}, - headers={ - "content-type": CONTENT_TYPE_JSON, - }, + exc=exc, + headers={"content-type": CONTENT_TYPE_JSON}, ) return __mock_requests diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr index 884ce49edb69..829e3da21da9 100644 --- a/tests/components/deconz/snapshots/test_hub.ambr +++ b/tests/components/deconz/snapshots/test_hub.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'deCONZ mock gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 32a6510db08c..0ebb4bb191d3 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -3,6 +3,7 @@ from collections.abc import Callable from typing import Any +from pydeconz.errors import RequestError import pytest import voluptuous as vol @@ -22,6 +23,7 @@ from homeassistant.components.deconz.services import ( ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from .test_hub import BRIDGE_ID @@ -111,7 +113,8 @@ async def test_configure_service_with_entity_and_field( @pytest.mark.usefixtures("config_entry_setup") async def test_configure_service_with_faulty_bridgeid( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test that service fails on a bad bridge id.""" aioclient_mock.clear_requests() @@ -122,9 +125,15 @@ async def test_configure_service_with_faulty_bridgeid( SERVICE_DATA: {"on": True}, } - await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + service_data=data, + blocking=True, + ) + assert err.value.translation_key == "gateway_not_found" assert len(aioclient_mock.mock_calls) == 0 @@ -141,9 +150,10 @@ async def test_configure_service_with_faulty_field(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("config_entry_setup") async def test_configure_service_with_faulty_entity( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test that service on a non existing entity.""" + """Test that service fails on a non-existing entity.""" aioclient_mock.clear_requests() data = { @@ -151,16 +161,24 @@ async def test_configure_service_with_faulty_entity( SERVICE_DATA: {}, } - await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + service_data=data, + blocking=True, + ) + assert err.value.translation_key == "entity_not_found" + assert err.value.translation_placeholders == {"entity_id": "light.nonexisting"} assert len(aioclient_mock.mock_calls) == 0 @pytest.mark.parametrize("config_entry_options", [{CONF_MASTER_GATEWAY: False}]) @pytest.mark.usefixtures("config_entry_setup") async def test_calling_service_with_no_master_gateway_fails( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test that service call fails when no master gateway exist.""" aioclient_mock.clear_requests() @@ -170,9 +188,15 @@ async def test_calling_service_with_no_master_gateway_fails( SERVICE_DATA: {"on": True}, } - await hass.services.async_call(DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + service_data=data, + blocking=True, + ) + assert err.value.translation_key == "no_master_gateway" assert len(aioclient_mock.mock_calls) == 0 @@ -390,3 +414,57 @@ async def test_remove_orphaned_entries_service( ) == 2 # Light and switch battery ) + + +@pytest.mark.usefixtures("config_entry_setup") +async def test_configure_service_request_error( + hass: HomeAssistant, + mock_put_request: Callable[..., AiohttpClientMocker], +) -> None: + """Test configure service handles API request errors.""" + + data = { + SERVICE_FIELD: "/lights/2", + CONF_BRIDGE_ID: BRIDGE_ID, + SERVICE_DATA: {"on": True}, + } + + mock_put_request( + "/lights/2", + exc=RequestError("Request failed"), + ) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + service_data=data, + blocking=True, + ) + + assert exc_info.value.translation_key == "configure_failed" + + +async def test_service_refresh_devices_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + mock_requests: Callable[..., None], +) -> None: + """Test refresh service handles request failures.""" + + aioclient_mock.clear_requests() + mock_requests(exc=TimeoutError) + + hub = config_entry_setup.runtime_data + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_DEVICE_REFRESH, + service_data={CONF_BRIDGE_ID: BRIDGE_ID}, + blocking=True, + ) + + assert exc_info.value.translation_key == "device_refresh_failed" + assert hub.ignore_state_updates is False diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index e2aca44ee2b1..112af447333b 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -615,3 +615,31 @@ async def test_browse( assert msg["result"]["title"] == "media" assert msg["result"]["media_class"] == "directory" assert len(msg["result"]["children"]) + + +async def test_search( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the media player search delegates to media source.""" + entity = "media_player.browse" + + await async_setup_component(hass, "media_source", {"media_source": {}}) + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": entity, + "search_query": "test", + } + ) + + msg = await websocket_client.receive_json() + assert msg["success"] + assert [item["title"] for item in msg["result"]["result"]] == ["test.mp3"] diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index f5330670ddd0..0208c1e9dce1 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -137,18 +137,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, derivative_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the derivative config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source device is not removed when the source entity is removed.""" assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() @@ -160,15 +152,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.derivative.async_unload_entry", wraps=derivative.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -177,8 +166,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") assert derivative_entity_entry.device_id is None - # Check that the derivative config entry is not in the device + # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert derivative_config_entry.entry_id not in sensor_device.config_entries # Check that the derivative config entry is not removed @@ -380,7 +370,7 @@ async def test_migration_1_2( sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.2 removes derivative config entry from device.""" + """Test migration from v1.2 keeps the derivative entity linked to the source device.""" derivative_config_entry = MockConfigEntry( data={}, @@ -399,22 +389,13 @@ async def test_migration_1_2( ) derivative_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(derivative_config_entry.entry_id) await hass.async_block_till_done() assert derivative_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the derivative config entry is not on the source device and the + # derivative entity is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index d54da57b38af..367a327b81a7 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,5 +1,6 @@ """The test for light device automation.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import attr @@ -11,14 +12,23 @@ from homeassistant import loader from homeassistant.components import automation, device_automation from homeassistant.components.device_automation import ( DOMAIN, + DeviceAutomationType, InvalidDeviceAutomationConfig, toggle_entity, ) +from homeassistant.components.device_automation.helpers import ( + _resolve_device_id, + async_validate_device_automation_config, +) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound from homeassistant.requirements import RequirementsNotFound @@ -1745,3 +1755,137 @@ async def test_async_get_device_automations_platform_reraises_exceptions( await device_automation.async_get_device_automation_platform( hass, "test", device_automation.DeviceAutomationType.TRIGGER ) + + +COMPOSITE_ID = "composite0000000000000000000000" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_device_automation_resolves_legacy_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A device automation legacy id resolves to the split owning its domain's entry. + + Automations for an entity platform domain are left as the composite id, which the + restored composite device and async_entries_for_device handle directly. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.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": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + await ar.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + by_entry = { + d.config_entry_id: d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + + # A config-entry domain resolves to the split owning that domain's config entry + assert ( + _resolve_device_id(hass, COMPOSITE_ID, "domain_a") == by_entry[entry_a.entry_id] + ) + assert ( + _resolve_device_id(hass, COMPOSITE_ID, "domain_b") == by_entry[entry_b.entry_id] + ) + + # An entity platform domain is left unresolved, even when a split has such entities + entity_registry.async_get_or_create( + "light", + "domain_a", + "unique", + config_entry=entry_a, + device_id=by_entry[entry_a.entry_id], + ) + assert _resolve_device_id(hass, COMPOSITE_ID, "light") == COMPOSITE_ID + + # An unknown domain is returned unchanged + assert _resolve_device_id(hass, COMPOSITE_ID, "not_present") == COMPOSITE_ID + + +async def test_validate_config_rewrites_composite_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration: None, +) -> None: + """Validating a device automation rewrites a composite id to its domain's split.""" + fake_entry = MockConfigEntry(domain="fake_integration") + fake_entry.add_to_hass(hass) + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + device_fake = device_registry.async_get_or_create( + config_entry_id=fake_entry.entry_id, identifiers={("fake_integration", "1")} + ) + device_other = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("other", "1")} + ) + entity = entity_registry.async_get_or_create( + "light", "fake_integration", "u", device_id=device_fake.id + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_fake.id] = attr.evolve( + device_fake, composite_device_id=old_id + ) + device_registry.devices[device_other.id] = attr.evolve( + device_other, composite_device_id=old_id + ) + assert old_id not in device_registry.devices + + validated = await async_validate_device_automation_config( + hass, + { + "platform": "device", + "domain": "fake_integration", + "device_id": old_id, + "entity_id": entity.entity_id, + "type": "turned_on", + }, + vol.Schema( + {vol.Required("device_id"): str, vol.Required("domain"): str}, + extra=vol.ALLOW_EXTRA, + ), + DeviceAutomationType.TRIGGER, + ) + assert validated["device_id"] == device_fake.id diff --git a/tests/components/device_tracker/test_entity.py b/tests/components/device_tracker/test_entity.py index 230398378a0e..c2ffa6bcfe4a 100644 --- a/tests/components/device_tracker/test_entity.py +++ b/tests/components/device_tracker/test_entity.py @@ -3,6 +3,7 @@ from collections.abc import Generator from typing import Any +import attr import pytest from homeassistant.components.device_tracker import ( @@ -1611,6 +1612,96 @@ async def test_register_mac_ignored( assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION +async def test_scanner_entity_attaches_to_split_of_composite_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that a scanner entity attaches to its config entry's split device.""" + mac = TEST_MAC_ADDRESS + other_entry = MockConfigEntry(domain="other") + other_entry.add_to_hass(hass) + old_id = "composite00000000000000000000000" + own_split = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={(TEST_DOMAIN, "own")}, + ) + other_split = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", "x")}, + ) + # Simulate a migration split: both devices share the pre-migration composite id + device_registry.devices[own_split.id] = attr.evolve( + own_split, composite_device_id=old_id + ) + device_registry.devices[other_split.id] = attr.evolve( + other_split, composite_device_id=old_id + ) + # async_get_device now resolves the shared MAC to the synthesized composite + composite = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert composite is not None + assert composite.id == old_id + assert old_id not in device_registry.devices + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.composite_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + # Attached to its own split, not the un-assignable composite id + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id == own_split.id + + +async def test_scanner_entity_composite_device_without_own_split( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A composite with no split owned by the scanner's config entry attaches nothing. + + The composite id is not a real device and can't be assigned to an entity, so with no + split to resolve to the entity is added without a device instead of raising. + """ + mac = TEST_MAC_ADDRESS + other_entry_1 = MockConfigEntry(domain="other_1") + other_entry_1.add_to_hass(hass) + other_entry_2 = MockConfigEntry(domain="other_2") + other_entry_2.add_to_hass(hass) + old_id = "composite00000000000000000000000" + # Both splits belong to other config entries, none to the scanner's + for entry, identifier in ((other_entry_1, "one"), (other_entry_2, "two")): + split = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + identifiers={("other", identifier)}, + ) + device_registry.devices[split.id] = attr.evolve( + split, composite_device_id=old_id + ) + composite = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert composite is not None + assert composite.id == old_id + assert old_id not in device_registry.devices + + scanner_entity = MockScannerEntity(mac_address=mac, unique_id=f"{mac}_scanner") + scanner_entity.entity_id = "device_tracker.composite_scanner" + await create_mock_platform(hass, config_entry, [scanner_entity]) + + # Added without a device rather than raising on the un-assignable composite id + entity_entry = entity_registry.async_get("device_tracker.composite_scanner") + assert entity_entry is not None + assert entity_entry.device_id is None + + async def test_connected_device_registered( hass: HomeAssistant, config_entry: MockConfigEntry, diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 69cf0adba2b5..d4539a2dfdd1 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device[mock_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_device[mock_ipv6_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://[2001:db8::1]', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, @@ -72,8 +70,8 @@ # name: test_device[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ }), @@ -94,7 +92,6 @@ 'model_id': '2730', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': '5.6.1', 'via_device_id': None, diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 261372857ade..fe820e8b6d38 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -616,6 +616,46 @@ async def test_setup_and_stop(hass: HomeAssistant) -> None: resolve_iface_call.assert_called_once() +async def test_discovered_service_info(hass: HomeAssistant) -> None: + """Test getting the discovered DHCP devices from the cache.""" + saved_callback: Callable[[aiodhcpwatcher.DHCPRequest], None] | None = None + + async def mock_start( + callback: Callable[[aiodhcpwatcher.DHCPRequest], None], + if_indexes: list[int] | None = None, + ) -> None: + """Mock start.""" + nonlocal saved_callback + saved_callback = callback + + with ( + patch("homeassistant.components.dhcp.aiodhcpwatcher.async_start", mock_start), + patch("homeassistant.components.dhcp.DiscoverHosts"), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert dhcp.async_discovered_service_info(hass) == [] + + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.2", "happy", "44:44:33:11:23:12")) + saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.1", "Sad", "44:44:33:11:23:13")) + + assert dhcp.async_discovered_service_info(hass) == [ + DhcpServiceInfo( + ip="4.3.2.2", + hostname="happy", + macaddress="444433112312", + ), + DhcpServiceInfo( + ip="4.3.2.1", + hostname="sad", + macaddress="444433112313", + ), + ] + + async def test_setup_fails_as_root( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/diagnostics/test_util.py b/tests/components/diagnostics/test_util.py index 6f1c1b2e1995..004d4d6f904e 100644 --- a/tests/components/diagnostics/test_util.py +++ b/tests/components/diagnostics/test_util.py @@ -5,8 +5,10 @@ from datetime import datetime from homeassistant.components.diagnostics import ( REDACTED, async_redact_data, + device_entry_as_dict, entity_entry_as_dict, ) +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry @@ -88,3 +90,35 @@ def test_entity_entry_as_dict() -> None: assert result["original_name"] == "Test Sensor" assert result["supported_features"] == 0 assert result["created_at"] == created + + +def test_device_entry_as_dict() -> None: + """Test device_entry_as_dict.""" + created = datetime.fromisoformat("2024-01-01T00:00:00+00:00") + entry = DeviceEntry( + config_entry_id="mock-config-entry-id", + created_at=created, + identifiers={("test", "unique123")}, + modified_at=created, + name="Test Device", + ) + + result = device_entry_as_dict(entry) + + assert isinstance(result, dict) + # Internal bookkeeping and composite-device migration attributes are excluded + for attribute in ( + "_cache", + "_composite_subentries", + "_pending_move", + "_suggested_area", + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", + ): + assert attribute not in result + assert result["config_entry_id"] == "mock-config-entry-id" + assert result["identifiers"] == [["test", "unique123"]] + assert result["name"] == "Test Device" + assert result["created_at"] == created diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 62fdaa25a289..6629f18032c8 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -34,15 +34,9 @@ def dsmr_connection_fixture() -> Generator[tuple[MagicMock, MagicMock, MagicMock connection_factory = MagicMock(wraps=connection_factory) - with ( - patch( - "homeassistant.components.dsmr.sensor.create_dsmr_reader", - connection_factory, - ), - patch( - "homeassistant.components.dsmr.sensor.create_tcp_dsmr_reader", - connection_factory, - ), + with patch( + "homeassistant.components.dsmr.sensor.create_dsmr_reader", + connection_factory, ): yield (connection_factory, transport, protocol) closed.set() @@ -156,15 +150,9 @@ def dsmr_connection_send_validate_fixture() -> Generator[ protocol.wait_closed = wait_closed - with ( - patch( - "homeassistant.components.dsmr.config_flow.create_dsmr_reader", - connection_factory, - ), - patch( - "homeassistant.components.dsmr.config_flow.create_tcp_dsmr_reader", - connection_factory, - ), + with patch( + "homeassistant.components.dsmr.config_flow.create_dsmr_reader", + connection_factory, ): yield (connection_factory, transport, protocol) @@ -207,14 +195,8 @@ def rfxtrx_dsmr_connection_send_validate_fixture() -> Generator[ protocol.wait_closed = wait_closed - with ( - patch( - "homeassistant.components.dsmr.config_flow.create_rfxtrx_dsmr_reader", - connection_factory, - ), - patch( - "homeassistant.components.dsmr.config_flow.create_rfxtrx_tcp_dsmr_reader", - connection_factory, - ), + with patch( + "homeassistant.components.dsmr.config_flow.create_rfxtrx_dsmr_reader", + connection_factory, ): yield (connection_factory, transport, protocol) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index eaadb4d4ae63..222e24971c2a 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -32,44 +32,33 @@ async def test_setup_network( hass: HomeAssistant, dsmr_connection_send_validate_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: - """Test we can setup network.""" + """Test we can setup a network connection via a socket URL.""" 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 result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Network"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_network" assert result["errors"] == {} with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "10.10.0.1", - "port": 1234, + "port": "socket://10.10.0.1:1234", "dsmr_version": "2.2", }, ) await hass.async_block_till_done() entry_data = { - "host": "10.10.0.1", - "port": 1234, + "port": "socket://10.10.0.1:1234", "dsmr_version": "2.2", "protocol": "dsmr_protocol", } assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "10.10.0.1:1234" + assert result["title"] == "socket://10.10.0.1:1234" assert result["data"] == {**entry_data, **SERIAL_DATA} @@ -80,7 +69,7 @@ async def test_setup_network_rfxtrx( MagicMock, MagicMock, MagicMock ], ) -> None: - """Test we can setup network.""" + """Test we can setup a network connection via a socket URL for rfxtrx.""" (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture result = await hass.config_entries.flow.async_init( @@ -89,15 +78,6 @@ async def test_setup_network_rfxtrx( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Network"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_network" assert result["errors"] == {} # set-up DSMRProtocol to yield no valid telegram, @@ -108,22 +88,20 @@ async def test_setup_network_rfxtrx( result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "10.10.0.1", - "port": 1234, + "port": "socket://10.10.0.1:1234", "dsmr_version": "2.2", }, ) await hass.async_block_till_done() entry_data = { - "host": "10.10.0.1", - "port": 1234, + "port": "socket://10.10.0.1:1234", "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", } assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "10.10.0.1:1234" + assert result["title"] == "socket://10.10.0.1:1234" assert result["data"] == {**entry_data, **SERIAL_DATA} @@ -207,15 +185,6 @@ async def test_setup_serial( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): @@ -248,15 +217,6 @@ async def test_setup_serial_rfxtrx( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} # set-up DSMRProtocol to yield no valid telegram, @@ -302,15 +262,6 @@ async def test_setup_serial_fail( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} with patch( @@ -323,7 +274,7 @@ async def test_setup_serial_fail( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" + assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} @@ -362,15 +313,6 @@ async def test_setup_serial_timeout( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): @@ -379,7 +321,7 @@ async def test_setup_serial_timeout( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" + assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_communicate"} @@ -406,15 +348,6 @@ async def test_setup_serial_wrong_telegram( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"type": "Serial"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" assert result["errors"] == {} protocol.telegram = {} @@ -426,7 +359,7 @@ async def test_setup_serial_wrong_telegram( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "setup_serial" + assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_communicate"} diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index c67bdbfd444f..60792ace9f23 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -1380,8 +1380,12 @@ async def test_tcp( await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - assert connection_factory.call_args_list[0][0][0] == "localhost" - assert connection_factory.call_args_list[0][0][1] == "1234" + # Legacy host/port entries are combined into a socket URL in memory and + # opened with the keep-alive watchdog; the stored entry is left untouched so + # a downgrade keeps working. + assert mock_entry.data["host"] == "localhost" + assert connection_factory.call_args_list[0][0][0] == "socket://localhost:1234" + assert connection_factory.call_args_list[0][1]["keep_alive_interval"] == 60 async def test_rfxtrx_tcp( @@ -1409,8 +1413,66 @@ async def test_rfxtrx_tcp( await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + # Legacy host/port entries keep using the TCP reader (with keep-alive); the + # stored entry is left untouched so a downgrade keeps working. + assert mock_entry.data["host"] == "localhost" assert connection_factory.call_args_list[0][0][0] == "localhost" - assert connection_factory.call_args_list[0][0][1] == "1234" + assert connection_factory.call_args_list[0][0][1] == 1234 + assert connection_factory.call_args_list[0][1]["keep_alive_interval"] == 60 + + +async def test_tcp_socket_url( + hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] +) -> None: + """A socket:// port should be opened with the keep-alive watchdog.""" + (connection_factory, _transport, _protocol) = dsmr_connection_fixture + + entry_data = { + "port": "socket://localhost:1234", + "dsmr_version": "2.2", + "protocol": "dsmr_protocol", + "serial_id": "1234", + "serial_id_gas": "5678", + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert connection_factory.call_args_list[0][0][0] == "socket://localhost:1234" + assert connection_factory.call_args_list[0][1]["keep_alive_interval"] == 60 + + +async def test_serial_no_keep_alive( + hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] +) -> None: + """A local serial device should use the plain reader without keep-alive.""" + (connection_factory, _transport, _protocol) = dsmr_connection_fixture + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "2.2", + "protocol": "dsmr_protocol", + "serial_id": "1234", + "serial_id_gas": "5678", + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert connection_factory.call_args_list[0][0][0] == "/dev/ttyUSB0" + assert "keep_alive_interval" not in connection_factory.call_args_list[0][1] @patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 7ed85c31a643..b6963ad8a761 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -10,6 +10,9 @@ from duco_connectivity import ( ApiEndpointInfo, ApiInfo, BoardInfo, + ConfigNode, + ConfigNodeOverview, + ConfigValueString, DiagComponent, KnownActionName, LanInfo, @@ -103,6 +106,19 @@ def load_nodes_fixture(filename: str) -> list[Node]: return [_node_from_dict(node) for node in load_json_array_fixture(filename, DOMAIN)] +def node_configs_from_nodes(nodes: list[Node]) -> ConfigNodeOverview: + """Build node config names from node fixtures.""" + return ConfigNodeOverview( + nodes=[ + ConfigNode( + node_id=node.node_id, + name=ConfigValueString(node.general.name), + ) + for node in nodes + ] + ) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -236,6 +252,7 @@ def mock_duco_client( client.async_get_board_info.return_value = mock_board_info client.async_get_lan_info.return_value = mock_lan_info client.async_get_nodes.return_value = mock_nodes + client.async_get_node_configs.return_value = node_configs_from_nodes(mock_nodes) client.async_get_node_actions.return_value = mock_node_actions client.async_get_time_filter_remaining.return_value = 180 client.async_get_diagnostics.return_value = [ diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index f0807955a020..24f906507d37 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -1,9 +1,13 @@ """Tests for the Duco integration setup.""" +from datetime import timedelta from unittest.mock import ANY, AsyncMock, patch from duco_connectivity import ( BoardInfo, + ConfigNode, + ConfigNodeOverview, + ConfigValueString, DiagComponent, DucoConnectionError, DucoError, @@ -12,16 +16,42 @@ from duco_connectivity import ( Node, NodeListActionItemList, ) +from freezegun.api import FrozenDateTimeFactory import pytest 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 homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import TEST_HOST, TEST_MAC, UNSUPPORTED_BOARD_INFOS +from .conftest import ( + TEST_HOST, + TEST_MAC, + UNSUPPORTED_BOARD_INFOS, + node_configs_from_nodes, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + + +def _get_duco_node_device(device_registry: dr.DeviceRegistry) -> dr.DeviceEntry: + """Return the primary Duco node device used in setup tests.""" + device = device_registry.async_get_device(identifiers={("duco", f"{TEST_MAC}_1")}) + assert device is not None + return device + + +def _node_configs_with_primary_name( + mock_nodes: list[Node], + primary_name: str, +) -> ConfigNodeOverview: + """Return node configs with a custom name for the primary Duco node.""" + return ConfigNodeOverview( + nodes=[ + ConfigNode(node_id=1, name=ConfigValueString(primary_name)), + *node_configs_from_nodes(mock_nodes[1:]).nodes, + ] + ) @pytest.mark.parametrize( @@ -128,6 +158,27 @@ async def test_setup_entry_ignores_lan_info_failures( assert mock_config_entry.state is ConfigEntryState.LOADED +async def test_setup_entry_ignores_node_name_config_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + device_registry: dr.DeviceRegistry, +) -> None: + """Test setup falls back to API node names when node config fetch fails.""" + mock_duco_client.async_get_node_configs.side_effect = DucoError("node config error") + 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 + + device = _get_duco_node_device(device_registry) + assert device.name == mock_nodes[0].general.name + assert mock_duco_client.async_get_node_configs.call_count == 1 + + @pytest.mark.parametrize("unsupported_board_info", UNSUPPORTED_BOARD_INFOS) async def test_setup_entry_unsupported_board_info( hass: HomeAssistant, @@ -243,3 +294,62 @@ async def test_setup_entry_creates_http_client( session=ANY, host=TEST_HOST, ) + + +async def test_setup_entry_uses_configured_node_name( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + device_registry: dr.DeviceRegistry, +) -> None: + """Test setup uses the configurable Duco node name.""" + mock_duco_client.async_get_node_configs.return_value = ( + _node_configs_with_primary_name(mock_nodes, "Kitchen") + ) + 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 = _get_duco_node_device(device_registry) + assert device.name == "Kitchen" + assert mock_duco_client.async_get_node_configs.call_count == 1 + + +async def test_node_name_refresh_updates_device_registry_name( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test Duco node names update on reload, not during periodic polling.""" + mock_duco_client.async_get_node_configs.side_effect = [ + _node_configs_with_primary_name(mock_nodes, "Kitchen"), + _node_configs_with_primary_name(mock_nodes, "Living Room"), + ] + 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 = _get_duco_node_device(device_registry) + assert device.name == "Kitchen" + assert mock_duco_client.async_get_node_configs.call_count == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + device = _get_duco_node_device(device_registry) + assert device.name == "Kitchen" + assert mock_duco_client.async_get_node_configs.call_count == 1 + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = _get_duco_node_device(device_registry) + assert device.name == "Living Room" + assert mock_duco_client.async_get_node_configs.call_count == 2 diff --git a/tests/components/eafm/snapshots/test_init.ambr b/tests/components/eafm/snapshots/test_init.ambr index 39a5978315c8..eb58e3e1be52 100644 --- a/tests/components/eafm/snapshots/test_init.ambr +++ b/tests/components/eafm/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'My station Water Level Stage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/earn_e_p1/snapshots/test_init.ambr b/tests/components/earn_e_p1/snapshots/test_init.ambr index 9e04b5e3a66b..8279e82605b9 100644 --- a/tests/components/earn_e_p1/snapshots/test_init.ambr +++ b/tests/components/earn_e_p1/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'EARN-E P1 Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'E0012345678901234', 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr index 0e847da73ad7..2b20d774c0a3 100644 --- a/tests/components/ecovacs/snapshots/test_init.ambr +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices_in_dr[E1234567890000000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'yna5xi', 'name': 'Ozmo 950', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'E1234567890000000001', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/edifier_infrared/conftest.py b/tests/components/edifier_infrared/conftest.py index 25eec8b6654a..7cc22ec7b2a2 100644 --- a/tests/components/edifier_infrared/conftest.py +++ b/tests/components/edifier_infrared/conftest.py @@ -77,6 +77,11 @@ def mock_edifier_code_to_command() -> Generator[None]: autospec=True, side_effect=lambda self: self, ), + patch( + "infrared_protocols.codes.edifier.s3000pro.EdifierS3000ProCode.to_command", + autospec=True, + side_effect=lambda self: self, + ), ): yield diff --git a/tests/components/edifier_infrared/test_config_flow.py b/tests/components/edifier_infrared/test_config_flow.py index f12b3b33899e..119cfa8ad0c1 100644 --- a/tests/components/edifier_infrared/test_config_flow.py +++ b/tests/components/edifier_infrared/test_config_flow.py @@ -26,6 +26,7 @@ from tests.components.infrared import EMITTER_ENTITY_ID (EdifierModel.R1280T, EdifierCommandSet.R1280T), (EdifierModel.S360DB, EdifierCommandSet.S360DB), (EdifierModel.RC20G, EdifierCommandSet.RC20G), + (EdifierModel.S3000PRO, EdifierCommandSet.S3000PRO), ], ) @pytest.mark.usefixtures("mock_infrared_emitter_entity") diff --git a/tests/components/egauge/snapshots/test_sensor.ambr b/tests/components/egauge/snapshots/test_sensor.ambr index d9753970cd96..aaffbdacb28f 100644 --- a/tests/components/egauge/snapshots/test_sensor.ambr +++ b/tests/components/egauge/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors.12 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'egauge-home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ABC123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/electrasmart/snapshots/test_init.ambr b/tests/components/electrasmart/snapshots/test_init.ambr index 97b1d33f77f7..0a6a6bea0920 100644 --- a/tests/components/electrasmart/snapshots/test_init.ambr +++ b/tests/components/electrasmart/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index a7e18e93b641..c255e649e531 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_buttons[button.frenck_identify-identify-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -139,8 +138,8 @@ # name: test_buttons[button.frenck_restart-restart-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -165,7 +164,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index fdc397c8436d..97fa93346ef4 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -10,7 +10,7 @@ 27.316, 47.743, ), - : 7000, + : 6993, : 2900, : tuple( 255, @@ -41,7 +41,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : 7000, + : 6993, : 2900, : list([ , @@ -80,8 +80,8 @@ # name: test_light_state_temperature[key-light-state].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -106,7 +106,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -195,8 +194,8 @@ # name: test_light_state_temperature[light-strip-state-color-temperature].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -221,7 +220,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -310,8 +308,8 @@ # name: test_light_state_temperature[light-strip-state].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -336,7 +334,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CN11A1A00001', 'sw_version': '1.0.3 (192)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index cd079124f2a9..4e0ae5d822ee 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -60,8 +60,8 @@ # name: test_sensors[sensor.frenck_battery-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -86,7 +86,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -156,8 +155,8 @@ # name: test_sensors[sensor.frenck_battery_voltage-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -182,7 +181,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -252,8 +250,8 @@ # name: test_sensors[sensor.frenck_charging_current-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -278,7 +276,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -345,8 +342,8 @@ # name: test_sensors[sensor.frenck_charging_power-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -371,7 +368,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -441,8 +437,8 @@ # name: test_sensors[sensor.frenck_charging_voltage-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -467,7 +463,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index 71b5c3dc3d44..4d48cc9bb37d 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_switches[switch.frenck_energy_saving-energy_saving-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -78,7 +78,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -137,8 +136,8 @@ # name: test_switches[switch.frenck_studio_mode-battery_bypass-key-light-mini].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -163,7 +162,6 @@ 'model_id': None, 'name': 'Frenck', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GW24L1A02987', 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/energieleser/conftest.py b/tests/components/energieleser/conftest.py index 21e8874273ad..f26a1c4117de 100644 --- a/tests/components/energieleser/conftest.py +++ b/tests/components/energieleser/conftest.py @@ -1,6 +1,7 @@ """Fixtures for energieleser integration tests.""" from collections.abc import Generator +from dataclasses import replace from unittest.mock import AsyncMock, patch from energieleser import ( @@ -78,6 +79,14 @@ def mock_stromleser_device() -> StromleserOneDevice: return StromleserOneDevice.from_payload(STROMLESER_API_RESPONSE) +@pytest.fixture +def mock_locked_stromleser_device( + mock_stromleser_device: StromleserOneDevice, +) -> StromleserOneDevice: + """Return a stromleser device with PIN locked.""" + return replace(mock_stromleser_device, pin_locked=True) + + @pytest.fixture def mock_gasleser_device() -> GasleserDevice: """Return a parsed gasleser device built from the API fixture.""" diff --git a/tests/components/energieleser/snapshots/test_diagnostics.ambr b/tests/components/energieleser/snapshots/test_diagnostics.ambr new file mode 100755 index 000000000000..863d1f582cb8 --- /dev/null +++ b/tests/components/energieleser/snapshots/test_diagnostics.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_entry_diagnostics[gasleser] + dict({ + 'count': 603, + 'current_flow_rate': 0.01, + 'device_id': '**REDACTED**', + 'device_type': 'gasleser', + 'signal_strength_dbm': -51.0, + 'timestamp': 1776179005, + 'total_consumption': 37030.67, + }) +# --- +# name: test_entry_diagnostics[stromleser] + dict({ + 'device_id': '**REDACTED**', + 'device_type': 'stromleser', + 'energy_export': dict({ + 'unit': 'Wh', + 'value': 26561.0, + }), + 'energy_export_tariff_1': None, + 'energy_export_tariff_2': None, + 'energy_export_tariff_3': None, + 'energy_export_tariff_4': None, + 'energy_import': dict({ + 'unit': 'Wh', + 'value': 12345.0, + }), + 'energy_import_tariff_1': None, + 'energy_import_tariff_2': None, + 'energy_import_tariff_3': None, + 'energy_import_tariff_4': None, + 'pin_locked': False, + 'power_absolute': None, + 'power_active': dict({ + 'unit': 'W', + 'value': 8.16, + }), + 'power_export': None, + 'power_import': None, + 'power_l1': dict({ + 'unit': 'W', + 'value': 0.0, + }), + 'power_l2': dict({ + 'unit': 'W', + 'value': 0.0, + }), + 'power_l3': dict({ + 'unit': 'W', + 'value': 8.16, + }), + 'signal_strength_dbm': -51.0, + 'timestamp': 1776178480, + }) +# --- +# name: test_entry_diagnostics[waermeleser] + dict({ + 'device_id': '**REDACTED**', + 'device_type': 'waermeleser', + 'fabrication_number': '**REDACTED**', + 'flow_temperature': dict({ + 'unit': '°C', + 'value': 16.9, + }), + 'power': dict({ + 'unit': 'kW', + 'value': 2.31, + }), + 'return_temperature': dict({ + 'unit': '°C', + 'value': 19.6, + }), + 'signal_strength_dbm': -51.0, + 'temperature_difference': dict({ + 'unit': 'K', + 'value': 2.68, + }), + 'timestamp': 1747285200, + 'total_energy_t1': dict({ + 'unit': 'MWh', + 'value': 34.09, + }), + 'total_energy_t2': dict({ + 'unit': 'MWh', + 'value': 12.45, + }), + 'total_energy_t3': dict({ + 'unit': 'MWh', + 'value': 5.67, + }), + 'total_volume': dict({ + 'unit': 'm³', + 'value': 3561.23, + }), + 'volume_flow': dict({ + 'unit': 'l/h', + 'value': 1.23, + }), + }) +# --- +# name: test_entry_diagnostics[wasserleser] + dict({ + 'current_flow_rate': dict({ + 'unit': 'l/h', + 'value': 0.0, + }), + 'current_flow_rate_m3': dict({ + 'unit': 'm3/h', + 'value': 0.0, + }), + 'device_id': '**REDACTED**', + 'device_type': 'wasserleser', + 'signal_strength_dbm': -49.0, + 'timestamp': 1779276532, + 'today_consumption': dict({ + 'unit': 'm3', + 'value': 0.0, + }), + 'total_consumption': dict({ + 'unit': 'm3', + 'value': 123.755, + }), + }) +# --- diff --git a/tests/components/energieleser/test_config_flow.py b/tests/components/energieleser/test_config_flow.py index e3629dc60a03..7ff0afc85a07 100755 --- a/tests/components/energieleser/test_config_flow.py +++ b/tests/components/energieleser/test_config_flow.py @@ -307,3 +307,98 @@ async def test_zeroconf_flow_errors( assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_reason + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_energieleser_client") +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_stromleser_config_entry: MockConfigEntry, +) -> None: + """Test a successful reconfiguration flow.""" + mock_stromleser_config_entry.add_to_hass(hass) + + result = await mock_stromleser_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.102"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_stromleser_config_entry.data[CONF_HOST] == "192.168.1.102" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow_another_device( + hass: HomeAssistant, + mock_stromleser_config_entry: MockConfigEntry, + mock_energieleser_client: AsyncMock, + mock_gasleser_device: GasleserDevice, +) -> None: + """Test reconfiguration flow with a different device.""" + mock_stromleser_config_entry.add_to_hass(hass) + + mock_energieleser_client.get_device.return_value = mock_gasleser_device + + result = await mock_stromleser_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.105"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_device" + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param( + EnergieleserConnectionError("boom"), "cannot_connect", id="cannot_connect" + ), + pytest.param( + EnergieleserUnknownDeviceError("FOO_0000000001"), + "unknown_device_type", + id="unknown_device_type", + ), + pytest.param( + EnergieleserError("boom"), + "unknown", + id="unknown", + ), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_stromleser_config_entry: MockConfigEntry, + mock_energieleser_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test client errors during reconfiguration flow.""" + mock_stromleser_config_entry.add_to_hass(hass) + mock_energieleser_client.get_device.side_effect = side_effect + + result = await mock_stromleser_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.105"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_energieleser_client.get_device.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.102"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" diff --git a/tests/components/energieleser/test_diagnostics.py b/tests/components/energieleser/test_diagnostics.py new file mode 100755 index 000000000000..61795c4615af --- /dev/null +++ b/tests/components/energieleser/test_diagnostics.py @@ -0,0 +1,55 @@ +"""Test energieleser diagnostics.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("device_fixture", "config_entry_fixture"), + [ + pytest.param( + "mock_stromleser_device", "mock_stromleser_config_entry", id="stromleser" + ), + pytest.param( + "mock_gasleser_device", "mock_gasleser_config_entry", id="gasleser" + ), + pytest.param( + "mock_waermeleser_device", + "mock_waermeleser_config_entry", + id="waermeleser", + ), + pytest.param( + "mock_wasserleser_device", + "mock_wasserleser_config_entry", + id="wasserleser", + ), + ], +) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_energieleser_client: AsyncMock, + device_fixture: str, + config_entry_fixture: str, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + device = request.getfixturevalue(device_fixture) + config_entry = request.getfixturevalue(config_entry_fixture) + + mock_energieleser_client.get_device.return_value = device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot diff --git a/tests/components/energieleser/test_init.py b/tests/components/energieleser/test_init.py old mode 100755 new mode 100644 index b620df729924..ce229a0dfc25 --- a/tests/components/energieleser/test_init.py +++ b/tests/components/energieleser/test_init.py @@ -6,18 +6,21 @@ from energieleser import ( EnergieleserConnectionError, EnergieleserError, EnergieleserUnknownDeviceError, + StromleserOneDevice, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.energieleser.const import CONF_SW_VERSION, DOMAIN +from homeassistant.components.energieleser.coordinator import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from .conftest import STROMLESER_DEVICE_ID, STROMLESER_SW_VERSION -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("mock_energieleser_client") @@ -89,3 +92,62 @@ async def test_device_exposes_discovery_sw_version( ) assert device is not None assert device.sw_version == STROMLESER_SW_VERSION + + +async def test_meter_locked_repair_issue( + hass: HomeAssistant, + mock_energieleser_client: AsyncMock, + mock_stromleser_device: StromleserOneDevice, + mock_locked_stromleser_device: StromleserOneDevice, + mock_stromleser_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test repair issue is created when meter is locked and deleted when unlocked.""" + mock_energieleser_client.get_device.return_value = mock_locked_stromleser_device + mock_stromleser_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + issue_id = f"pin_locked_{mock_stromleser_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == "meter_locked" + assert ( + issue.learn_more_url + == "https://docs.energieleser.de/en/docs/stromleser-one/installation/preparation" + ) + assert issue.translation_placeholders == { + "device_name": mock_stromleser_config_entry.title, + } + + mock_energieleser_client.get_device.return_value = mock_stromleser_device + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_meter_locked_repair_issue_removed_on_unload( + hass: HomeAssistant, + mock_energieleser_client: AsyncMock, + mock_locked_stromleser_device: StromleserOneDevice, + mock_stromleser_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is deleted when entry is unloaded.""" + mock_energieleser_client.get_device.return_value = mock_locked_stromleser_device + mock_stromleser_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + issue_id = f"pin_locked_{mock_stromleser_config_entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + assert await hass.config_entries.async_unload(mock_stromleser_config_entry.entry_id) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None diff --git a/tests/components/enphase_envoy/__init__.py b/tests/components/enphase_envoy/__init__.py index f5381eda2a78..199d950c02a2 100644 --- a/tests/components/enphase_envoy/__init__.py +++ b/tests/components/enphase_envoy/__init__.py @@ -1,7 +1,12 @@ """Tests for the Enphase Envoy integration.""" +from datetime import timedelta + +from jwt import encode + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -17,3 +22,15 @@ async def setup_integration( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is expected_state + + +def envoy_token(days_to_expiry: int = 365) -> str: + """Build envoy token with specified days to expiration.""" + return encode( + payload={ + "name": "envoy", + "exp": (dt_util.utcnow() + timedelta(days=days_to_expiry)).timestamp(), + }, + key="secret", + algorithm="HS256", + ) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index f112c78a7567..94919d6fe860 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -4,7 +4,6 @@ from collections.abc import AsyncGenerator, Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch -import jwt import multidict from pyenphase import ( EnvoyACBPower, @@ -29,9 +28,18 @@ from pyenphase.models.tariff import EnvoyStorageSettings, EnvoyTariff import pytest from homeassistant.components.enphase_envoy import DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.enphase_envoy.const import CONF_MANUAL_TOKEN +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant +from . import envoy_token + from tests.common import MockConfigEntry, load_json_object_fixture @@ -47,15 +55,49 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="config_entry") def config_entry_fixture( - hass: HomeAssistant, config: dict[str, str] + hass: HomeAssistant, + config: dict[str, Any], + request: pytest.FixtureRequest, ) -> MockConfigEntry: """Define a config entry fixture.""" + token_mode = "none" + token_life = 365 + if hasattr(request, "param"): + if isinstance(request.param, list): + token_mode = request.param[0] + if len(request.param) > 1: + token_life = request.param[1] + else: + token_mode = request.param + if token_mode == "none": + data = config + elif token_mode == "auto": + # config contains token from automatic retrieval + data = { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: envoy_token(token_life), + CONF_MANUAL_TOKEN: False, + } + elif token_mode == "manual": + # config contains token from manual entry + data = { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_TOKEN: envoy_token(token_life), + CONF_MANUAL_TOKEN: True, + } + return MockConfigEntry( domain=DOMAIN, entry_id="45a36e55aaddb2007c5f6602e0c38e72", title="Envoy 1234", unique_id="1234", - data=config, + data=data, ) @@ -75,11 +117,7 @@ async def mock_envoy( request: pytest.FixtureRequest, ) -> AsyncGenerator[AsyncMock]: """Define a mocked Envoy fixture.""" - new_token = jwt.encode( - payload={"name": "envoy", "exp": 2007837780}, - key="secret", - algorithm="HS256", - ) + new_token = envoy_token() with ( patch( "homeassistant.components.enphase_envoy.config_flow.Envoy", @@ -96,11 +134,7 @@ async def mock_envoy( ): mock_envoy = mock_client.return_value # Add the fixtures specified - token = jwt.encode( - payload={"name": "envoy", "exp": 1907837780}, - key="secret", - algorithm="HS256", - ) + token = envoy_token(200) mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") mock_envoy.serial_number = "1234" mock = Mock() diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index b27e00a747cd..dee465efea12 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -30,14 +30,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -57,7 +51,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -284,14 +277,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -311,7 +298,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -944,14 +930,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -971,7 +951,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -1198,14 +1177,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -1225,7 +1198,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -1918,14 +1890,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -1945,7 +1911,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -2172,14 +2137,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -2199,7 +2158,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -2921,14 +2879,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -2948,7 +2900,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -3491,14 +3442,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ list([ @@ -3522,7 +3467,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.6.175', }), @@ -3844,14 +3788,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -3871,7 +3809,6 @@ 'model_id': None, 'name': 'Inverter 1', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '1', 'sw_version': None, }), @@ -4414,14 +4351,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4441,7 +4372,6 @@ 'model_id': None, 'name': 'Collar 482520020939', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '482520020939', 'sw_version': '3.0.6-D0', }), @@ -4725,14 +4655,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4752,7 +4676,6 @@ 'model_id': None, 'name': 'C6 Combiner 482523040549', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '482523040549', 'sw_version': '0.1.20-D1', }), @@ -4852,14 +4775,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -4879,7 +4796,6 @@ 'model_id': None, 'name': 'Enpower 654321', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '654321', 'sw_version': '1.2.2064_release/20.34', }), @@ -5273,14 +5189,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -5300,7 +5210,6 @@ 'model_id': None, 'name': 'Envoy <>', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>', 'sw_version': '7.1.2', }), @@ -18167,14 +18076,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18194,7 +18097,6 @@ 'model_id': None, 'name': 'Encharge <>56', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': '<>56', 'sw_version': '2.6.5973_rel/22.11', }), @@ -18543,14 +18445,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18570,7 +18466,6 @@ 'model_id': None, 'name': 'NC1 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), @@ -18956,14 +18851,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -18983,7 +18872,6 @@ 'model_id': None, 'name': 'NC2 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), @@ -19369,14 +19257,8 @@ dict({ 'device': dict({ 'area_id': None, - 'config_entries': list([ - '45a36e55aaddb2007c5f6602e0c38e72', - ]), - 'config_entries_subentries': dict({ - '45a36e55aaddb2007c5f6602e0c38e72': list([ - None, - ]), - }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), @@ -19396,7 +19278,6 @@ 'model_id': None, 'name': 'NC3 Fixture', 'name_by_user': None, - 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', 'serial_number': None, 'sw_version': '1.2.2064_release/20.34', }), diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 52a2e3716d78..fc149036c430 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -4,10 +4,11 @@ from ipaddress import ip_address import logging from unittest.mock import AsyncMock -from pyenphase import EnvoyAuthenticationError, EnvoyError +from pyenphase import EnvoyAuthenticationError, EnvoyError, EnvoyTokenAuth import pytest from homeassistant.components.enphase_envoy.const import ( + CONF_MANUAL_TOKEN, DOMAIN, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, OPTION_DIAGNOSTICS_INCLUDE_FIXTURES_DEFAULT_VALUE, @@ -15,12 +16,18 @@ from homeassistant.components.enphase_envoy.const import ( OPTION_DISABLE_KEEP_ALIVE_DEFAULT_VALUE, ) from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import setup_integration +from . import envoy_token, setup_integration from tests.common import MockConfigEntry @@ -52,6 +59,8 @@ async def test_form(hass: HomeAssistant, mock_envoy: AsyncMock) -> None: CONF_NAME: "Envoy 1234", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + CONF_TOKEN: mock_envoy.auth.token, } @@ -83,6 +92,9 @@ async def test_user_no_serial_number( CONF_NAME: "Envoy", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + # mock always fills token + CONF_TOKEN: mock_envoy.auth.token, } @@ -186,6 +198,8 @@ async def test_zeroconf( CONF_NAME: "Envoy 1234", CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + CONF_TOKEN: mock_envoy.auth.token, } @@ -814,3 +828,373 @@ async def test_reconfigure_change_ip_to_existing( assert other_entry.data[CONF_HOST] == "1.1.1.2" assert other_entry.data[CONF_USERNAME] == "other-username" assert other_entry.data[CONF_PASSWORD] == "other-password" + + +async def test_form_configure_manual_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test user step selecting to use manual token entry.""" + 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"] == {} + + # user opts for manual token entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + # no config update only form mode switch + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # in manual token mode user enters host, token with error and leaves manual_token on + token = envoy_token() + wrong_token = "wrongtoken" + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", token=wrong_token, envoy_serial="1234" + ) + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: True, CONF_TOKEN: wrong_token}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # user input to update manual token + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") + mock_envoy.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_TOKEN: token, CONF_MANUAL_TOKEN: True}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Envoy 1234" + assert result["result"].unique_id == "1234" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_MANUAL_TOKEN: True, + CONF_TOKEN: token, + } + + +async def test_form_switch_between_token_modes( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test user step selecting to use automatic token entry.""" + 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"] == {} + + # user opts for manual token entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + # no config update only form mode switch + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # in manual token mode user opts to switch back to automatic_token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: False}, + ) + await hass.async_block_till_done() + # no config update only form mode switch + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # user enters credentials and leaves manual_token off + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Envoy 1234" + assert result["result"].unique_id == "1234" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_MANUAL_TOKEN: False, + # in auto mode the verification will retrieve token from envoy + CONF_TOKEN: mock_envoy.auth.token, + } + + +async def test_reauth_switch_to_manual_token( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test reauth switch to manual token mode.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # user opts to switch to manual token + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + # no config update only form mode switch + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {} + + # user enters token + token = envoy_token() + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_TOKEN: token, + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data[CONF_TOKEN] == token + assert config_entry.data[CONF_MANUAL_TOKEN] + + +@pytest.mark.parametrize( + ("config_entry"), + [("manual")], + indirect=True, +) +async def test_reauth_manual_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reauth in manual token mode.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # user input to update manual token with token error + next_token = envoy_token(300) + wrong_token = "wrongtoken" + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", token=wrong_token, envoy_serial="1234" + ) + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_MANUAL_TOKEN: True, CONF_TOKEN: wrong_token}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # user input to update manual token + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=next_token, envoy_serial="1234") + mock_envoy.authenticate.side_effect = None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: next_token, + CONF_MANUAL_TOKEN: True, + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_MANUAL_TOKEN] + assert config_entry.data[CONF_TOKEN] == next_token + + +@pytest.mark.parametrize( + ("config_entry"), + [("auto")], + indirect=True, +) +async def test_reconfigure_switch_to_manual_token( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, +) -> None: + """Test reconfigure form switching from automatic to manual token entry.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # user input to switch to manual token entry + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: True}, + ) + await hass.async_block_till_done() + # no config update, only form mode switch + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {} + + # in manual token mode user enters host, token and manual_token option + token = envoy_token() + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_TOKEN: token, CONF_MANUAL_TOKEN: True}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Now we should have token and manual_token mode on + assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data[CONF_USERNAME] == "" + assert config_entry.data[CONF_PASSWORD] == "" + assert config_entry.data[CONF_TOKEN] == token + assert config_entry.data[CONF_MANUAL_TOKEN] + + +@pytest.mark.parametrize( + ("config_entry"), + [("manual")], + indirect=True, +) +async def test_reconfigure_manual_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure in manual token mode.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # user input to update manual token with token error + next_token = envoy_token(300) + wrong_token = "wrongtoken" + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", token=wrong_token, envoy_serial="1234" + ) + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: True, CONF_TOKEN: wrong_token}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "invalid_auth"} + + # user input to update manual token + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=next_token, envoy_serial="1234") + mock_envoy.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: True, CONF_TOKEN: next_token}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data[CONF_USERNAME] == "" + assert config_entry.data[CONF_PASSWORD] == "" + assert config_entry.data[CONF_TOKEN] == next_token + assert config_entry.data[CONF_MANUAL_TOKEN] + + +@pytest.mark.parametrize( + ("config_entry"), + [("manual")], + indirect=True, +) +async def test_reconfigure_switch_from_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure switching back to automatic token mode.""" + await setup_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # user input to switch from manual to automatic token entry + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_MANUAL_TOKEN: False}, + ) + + await hass.async_block_till_done() + # no config update, only switch to other form mode + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {} + + # user updates username & pw + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username1", + CONF_PASSWORD: "test-password2", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + # # token should be automatic again and manual_token false + assert config_entry.data[CONF_HOST] == "1.1.1.1" + assert config_entry.data[CONF_USERNAME] == "test-username1" + assert config_entry.data[CONF_PASSWORD] == "test-password2" + assert config_entry.data[CONF_TOKEN] == mock_envoy.auth.token + assert not config_entry.data[CONF_MANUAL_TOKEN] diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 3609e811e526..bf2cc90f9fb4 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -23,6 +23,7 @@ from homeassistant.components.enphase_envoy.coordinator import ( FIRMWARE_REFRESH_INTERVAL, MAC_VERIFICATION_DELAY, SCAN_INTERVAL, + TOKEN_REPAIR_ID, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -34,12 +35,16 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from . import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -133,6 +138,104 @@ async def test_expired_token_in_config( assert entity_state.state == "116" +@pytest.mark.parametrize( + ("config_entry"), + [("manual")], + indirect=True, +) +@respx.mock +async def test_not_expired_token_with_manual_token( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator with 365 day token in config and manual token option on.""" + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", + token=config_entry.data[CONF_TOKEN], + envoy_serial="1234", + ) + caplog.set_level(logging.DEBUG) + + await setup_integration(hass, config_entry) + + assert ( + "Envoy 1234: 365 days remaining on token, fresh=True, manual token mode=True" + in caplog.text + ) + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == "116" + + +@pytest.mark.parametrize( + ("config_entry"), + [["manual", 25]], # expires in 25 days + indirect=True, +) +@respx.mock +async def test_almost_expired_token_with_manual_token( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator with token within warning time in config and manual token option on.""" + caplog.set_level(logging.DEBUG) + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + + # Make sure to mock pyenphase.auth.EnvoyTokenAuth._obtain_token + # when specifying username and password in EnvoyTokenauth + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", + token=config_entry.data[CONF_TOKEN], + envoy_serial="1234", + ) + await setup_integration( + hass, + config_entry, + ) + assert ( + "Envoy 1234: 25 days remaining on token, fresh=False, manual token mode=True" + in caplog.text + ) + + # verify repair was issued + assert len(events) == 1 + assert events[0].data == { + "action": "create", + "domain": DOMAIN, + "issue_id": f"{TOKEN_REPAIR_ID}_1234", + } + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == "116" + + +@pytest.mark.parametrize( + ("config_entry"), + [["manual", -1]], # expired yesterday + indirect=True, +) +@respx.mock +async def test_expired_token_with_manual_token( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator with expired token in config and manual token option on.""" + + # manual token mode has no option to use username/pw to refresh token + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError( + "fail authentication" + ) + # setup should fail with expired token + await setup_integration( + hass, config_entry, expected_state=ConfigEntryState.SETUP_ERROR + ) + + async def test_coordinator_update_error( hass: HomeAssistant, mock_envoy: AsyncMock, diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index 7a5d88b64bf3..cd23ae7194bb 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -9,12 +9,16 @@ import pytest from homeassistant import config_entries from homeassistant.components.environment_canada.const import ( + CONF_RADAR_DURATION, + CONF_RADAR_FPS, CONF_RADAR_LAYER, CONF_RADAR_LEGEND, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, CONF_RADAR_TIMESTAMP, CONF_STATION, + DEFAULT_RADAR_DURATION, + DEFAULT_RADAR_FPS, DEFAULT_RADAR_LAYER, DEFAULT_RADAR_LEGEND, DEFAULT_RADAR_OPACITY, @@ -246,6 +250,8 @@ async def test_options_flow_form(hass: HomeAssistant, ec_data: dict[str, Any]) - CONF_RADAR_TIMESTAMP, CONF_RADAR_OPACITY, CONF_RADAR_RADIUS, + CONF_RADAR_DURATION, + CONF_RADAR_FPS, } @@ -261,6 +267,8 @@ async def test_options_flow_save(hass: HomeAssistant, ec_data: dict[str, Any]) - CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 30, CONF_RADAR_RADIUS: 100, + CONF_RADAR_DURATION: 30, + CONF_RADAR_FPS: 10, } with patch( "homeassistant.components.environment_canada.async_setup_entry", @@ -286,6 +294,8 @@ async def test_options_flow_prefills_saved_options( CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 50, CONF_RADAR_RADIUS: 300, + CONF_RADAR_DURATION: 60, + CONF_RADAR_FPS: 15, } config_entry = await init_integration(hass, ec_data, options=saved_options) @@ -297,6 +307,8 @@ async def test_options_flow_prefills_saved_options( assert defaults[CONF_RADAR_TIMESTAMP] is False assert defaults[CONF_RADAR_OPACITY] == 50 assert defaults[CONF_RADAR_RADIUS] == 300 + assert defaults[CONF_RADAR_DURATION] == 60 + assert defaults[CONF_RADAR_FPS] == 15 @pytest.mark.parametrize( @@ -310,6 +322,8 @@ async def test_options_flow_prefills_saved_options( "timestamp": DEFAULT_RADAR_TIMESTAMP, "layer_opacity": DEFAULT_RADAR_OPACITY, "radius": DEFAULT_RADAR_RADIUS, + "loop_minutes": DEFAULT_RADAR_DURATION, + "fps": DEFAULT_RADAR_FPS, }, id="defaults", ), @@ -320,6 +334,8 @@ async def test_options_flow_prefills_saved_options( CONF_RADAR_TIMESTAMP: False, CONF_RADAR_OPACITY: 40.0, CONF_RADAR_RADIUS: 150.0, + CONF_RADAR_DURATION: 30.0, + CONF_RADAR_FPS: 10.0, }, { "layer": "snow", @@ -327,6 +343,8 @@ async def test_options_flow_prefills_saved_options( "timestamp": False, "layer_opacity": 40, "radius": 150, + "loop_minutes": 30, + "fps": 10, }, id="custom", ), @@ -351,3 +369,7 @@ async def test_ecmap_built_from_options( assert isinstance(kwargs["layer_opacity"], int) assert kwargs["radius"] == expected["radius"] assert isinstance(kwargs["radius"], int) + assert kwargs["loop_minutes"] == expected["loop_minutes"] + assert isinstance(kwargs["loop_minutes"], int) + assert kwargs["fps"] == expected["fps"] + assert isinstance(kwargs["fps"], int) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index bfb6aa97446f..8060f6aafe31 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -210,7 +210,7 @@ def mock_client(mock_device_info) -> Generator[APIClient]: "homeassistant.components.esphome.manager.ReconnectLogic", BaseMockReconnectLogic, ), - patch("homeassistant.components.esphome.APIClient", mock_client), + patch("homeassistant.components.esphome.manager.APIClient", mock_client), patch("homeassistant.components.esphome.config_flow.APIClient", mock_client), ): yield mock_client diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 62cd3c5c39af..6bd490685735 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -82,6 +82,7 @@ 'minor': 99, }), 'device_info': dict({ + 'api_encryption_provisionable': False, 'api_encryption_supported': False, 'area': dict({ 'area_id': 0, diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 8c495552ac0e..c83fc9d55b3b 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -5,6 +5,7 @@ from dataclasses import replace from http import HTTPStatus import io import socket +import struct from unittest.mock import ANY, AsyncMock, Mock, patch import wave @@ -2428,3 +2429,321 @@ async def test_multichannel_audio_fallback_channel_0( await satellite.handle_audio(b"channel 0", b"channel 1") await satellite.handle_pipeline_stop(abort=False) await pipeline_finished.wait() + + +def _make_wav_header( + riff: bytes = b"RIFF", + wave_fmt: bytes = b"WAVE", + chunk_id: bytes = b"fmt ", + chunk_size: int = 16, + audio_format: int = 1, + num_channels: int = 1, + sample_rate: int = 16000, + bits_per_sample: int = 16, + data_chunk_id: bytes = b"data", + data_chunk_size: int = 0, +) -> bytes: + """Build a WAV header for testing.""" + header = struct.pack("<4sI4s", riff, 36 + data_chunk_size, wave_fmt) + header += struct.pack("<4sI", chunk_id, chunk_size) + if chunk_size >= 16: + block_align = num_channels * (bits_per_sample // 8) + byte_rate = sample_rate * block_align + header += struct.pack( + " 16: + header += b"\x00" * (chunk_size - 16) + header += struct.pack("<4sI", data_chunk_id, data_chunk_size) + return header + + +class _ChunkedMockResultStream(MockResultStream): + """MockResultStream that yields pre-defined chunks.""" + + def __init__( + self, hass: HomeAssistant, extension: str, chunks: list[bytes] + ) -> None: + super().__init__(hass, extension, b"") + self.chunks = chunks + + async def async_stream_result(self): + for chunk in self.chunks: + yield chunk + + +@pytest.mark.parametrize( + "wav_data", + [ + pytest.param( + _make_wav_header(riff=b"RIF_"), + id="invalid_riff_header", + ), + pytest.param( + _make_wav_header(audio_format=2), + id="unsupported_audio_format", + ), + pytest.param( + _make_wav_header(num_channels=2), + id="incorrect_channels", + ), + pytest.param( + _make_wav_header(sample_rate=22050), + id="incorrect_sample_rate", + ), + pytest.param( + _make_wav_header(bits_per_sample=8), + id="incorrect_bits_per_sample", + ), + pytest.param( + struct.pack("<4sI4s", b"RIFF", 20, b"WAVE") + + struct.pack("<4sI", b"data", 0), + id="missing_fmt_chunk", + ), + pytest.param( + struct.pack("<4sI4s", b"RIFF", 100, b"WAVE") + + struct.pack("<4sI", b"fmt ", 8) + + b"\x00" * 8 + + struct.pack("<4sI", b"data", 4) + + b"\x01\x02\x03\x04", + id="fmt_chunk_too_small", + ), + ], +) +async def test_stream_tts_audio_invalid_wav( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + wav_data: bytes, +) -> None: + """Test that invalid WAV headers are rejected without sending audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + stream = _ChunkedMockResultStream(hass, "wav", [wav_data]) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_not_called() + + +async def test_stream_tts_audio_junk_chunk_skipping( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test skipping unknown chunks in _stream_tts_audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Skipping unknown chunks + junk_chunk = struct.pack("<4sI", b"JUNK", 4) + b"junk" + header_with_junk = ( + struct.pack("<4sI4s", b"RIFF", 100, b"WAVE") + + junk_chunk + + struct.pack("<4sI", b"fmt ", 16) + + struct.pack(" None: + """Test fragmentation of incoming stream bytes.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + full_wav = _make_wav_header(data_chunk_size=4) + b"\x01\x02\x03\x04" + byte_chunks = [bytes([b]) for b in full_wav] + stream = _ChunkedMockResultStream(hass, "wav", byte_chunks) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_called_once_with(b"\x01\x02\x03\x04") + + +async def test_stream_tts_audio_multi_chunk_pacing( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test multi-chunk streaming with sleep/wait logic.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + wav_with_16000bytes = _make_wav_header(data_chunk_size=16000) + b"\x00" * 16000 + stream = _ChunkedMockResultStream(hass, "wav", [wav_with_16000bytes]) + await satellite._stream_tts_audio(stream, samples_per_chunk=512) + assert mock_client.send_voice_assistant_audio.call_count == 16 + for i in range(15): + assert mock_client.send_voice_assistant_audio.call_args_list[i].args == ( + b"\x00" * 1024, + ) + assert mock_client.send_voice_assistant_audio.call_args_list[15].args == ( + b"\x00" * 640, + ) + + +async def test_stream_tts_audio_cancel_between_chunks( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test cancel/abort when self._is_running becomes False (between chunks).""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + header_chunk = _make_wav_header(data_chunk_size=8) + + async def async_stream_cancel(): + yield header_chunk + satellite._is_running = False + yield b"\x00" * 8 + + stream = _ChunkedMockResultStream(hass, "wav", []) + stream.async_stream_result = async_stream_cancel + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_not_called() + + +async def test_stream_tts_audio_cancel_inner_loop( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test cancel/abort when self._is_running becomes False (inner chunk loop).""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + audio_data = b"\x00" * 2048 + full_wav = _make_wav_header(data_chunk_size=len(audio_data)) + audio_data + + original_send = satellite._send_tts_audio + + def send_then_stop(payload: bytes) -> None: + original_send(payload) + satellite._is_running = False + + stream = _ChunkedMockResultStream(hass, "wav", [full_wav]) + with patch.object(satellite, "_send_tts_audio", side_effect=send_then_stop): + await satellite._stream_tts_audio(stream, samples_per_chunk=512) + assert mock_client.send_voice_assistant_audio.call_count == 1 + + +async def test_stream_tts_audio_odd_junk_padding( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test odd-sized JUNK chunk with RIFF word-alignment padding.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + odd_junk_chunk = ( + struct.pack("<4sI", b"JUNK", 5) + b"junk!" + b"\x00" + ) # 5 bytes + 1 pad + header_with_odd_junk = ( + struct.pack("<4sI4s", b"RIFF", 200, b"WAVE") + + odd_junk_chunk + + struct.pack("<4sI", b"fmt ", 16) + + struct.pack(" None: + """Test trailing metadata after data chunk is not forwarded as audio.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + audio_payload = b"\x11\x22" * 4 # 8 bytes of audio + trailing_list = struct.pack("<4sI", b"LIST", 4) + b"INFO" + wav_with_trailing = ( + _make_wav_header(data_chunk_size=len(audio_payload)) + + audio_payload + + trailing_list + ) + stream = _ChunkedMockResultStream(hass, "wav", [wav_with_trailing]) + await satellite._stream_tts_audio(stream) + mock_client.send_voice_assistant_audio.assert_called_once_with(audio_payload) diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 158954ca374b..7ecc3dc88721 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -129,6 +129,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "api_encryption_provisionable": False, "api_encryption_supported": False, "area": {"area_id": 0, "name": ""}, "areas": [], diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 36bf86366220..c64d8955631f 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -607,7 +607,7 @@ async def test_entity_id_preserved_on_upgrade_when_in_storage( ent_reg_entry = entity_registry.async_get_or_create( Platform.BINARY_SENSOR, DOMAIN, - "11:22:33:44:55:AA-binary_sensor-my", + "11:22:33:44:55:AA/0/binary_sensor/my", ) entity_registry.async_update_entity( ent_reg_entry.entity_id, @@ -1161,6 +1161,58 @@ async def test_entity_id_with_empty_sub_device_name( assert hass.states.get("binary_sensor.main_device_sensor") is not None +async def test_legacy_unique_id_migrated_to_v3_sub_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test a legacy sub-device unique id is migrated to the version 3 format.""" + sub_devices = [ + SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0), + ] + device_info = {"name": "test", "devices": sub_devices} + entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + device_id=22222222, + ), + ] + states = [BinarySensorState(key=1, state=True, missing_state=False)] + + # Seed a registry entry in the legacy format with the @device_id suffix + legacy_entry = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-temperature@22222222", + suggested_object_id="kitchen_controller_temperature", + ) + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + entity_entry = entity_registry.async_get(legacy_entry.entity_id) + assert entity_entry is not None + # The legacy id is renamed to version 3, keeping the same entity + assert ( + entity_entry.unique_id == "11:22:33:44:55:AA/22222222/binary_sensor/Temperature" + ) + assert ( + entity_registry.async_get_entity_id( + Platform.BINARY_SENSOR, + DOMAIN, + "11:22:33:44:55:AA-binary_sensor-temperature@22222222", + ) + is None + ) + + async def test_unique_id_migration_when_entity_moves_between_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -1204,8 +1256,8 @@ async def test_unique_id_migration_when_entity_moves_between_devices( entity_entry = entity_registry.async_get("binary_sensor.test_temperature") assert entity_entry is not None initial_unique_id = entity_entry.unique_id - # Initial unique_id should not have @device_id suffix since it's on main device - assert "@" not in initial_unique_id + # Main device entities use device_id 0 in the unique id + assert "/0/" in initial_unique_id # Add sub-device to device info sub_devices = [ @@ -1260,8 +1312,8 @@ async def test_unique_id_migration_when_entity_moves_between_devices( # Wait for entity to be updated await hass.async_block_till_done() - # The entity_id doesn't change when moving between devices - # Only the unique_id gets updated with @device_id suffix + # The entity_id doesn't change when moving between devices, + # only the device segment of the unique_id changes state = hass.states.get("binary_sensor.test_temperature") assert state is not None @@ -1269,9 +1321,8 @@ async def test_unique_id_migration_when_entity_moves_between_devices( entity_entry = entity_registry.async_get("binary_sensor.test_temperature") assert entity_entry is not None - # Unique ID should have been migrated to include @device_id - # This is done by our build_device_unique_id wrapper - expected_unique_id = f"{initial_unique_id}@22222222" + # Unique ID device segment should now be the sub-device id + expected_unique_id = initial_unique_id.replace("/0/", "/22222222/") assert entity_entry.unique_id == expected_unique_id # Entity should now be associated with the sub-device @@ -1331,8 +1382,8 @@ async def test_unique_id_migration_sub_device_to_main_device( ) assert entity_entry is not None initial_unique_id = entity_entry.unique_id - # Initial unique_id should have @device_id suffix since it's on sub-device - assert "@22222222" in initial_unique_id + # Sub-device entities carry the sub-device id in the unique id + assert "/22222222/" in initial_unique_id # Update entity info - move to main device new_entity_info = [ @@ -1365,8 +1416,8 @@ async def test_unique_id_migration_sub_device_to_main_device( ) assert entity_entry is not None - # Unique ID should have been migrated to remove @device_id suffix - expected_unique_id = initial_unique_id.replace("@22222222", "") + # Unique ID device segment should now be the main device id 0 + expected_unique_id = initial_unique_id.replace("/22222222/", "/0/") assert entity_entry.unique_id == expected_unique_id # Entity should now be associated with the main device @@ -1427,8 +1478,8 @@ async def test_unique_id_migration_between_sub_devices( ) assert entity_entry is not None initial_unique_id = entity_entry.unique_id - # Initial unique_id should have @22222222 suffix - assert "@22222222" in initial_unique_id + # Sub-device entities carry the sub-device id in the unique id + assert "/22222222/" in initial_unique_id # Update entity info - move to second sub-device new_entity_info = [ @@ -1461,8 +1512,8 @@ async def test_unique_id_migration_between_sub_devices( ) assert entity_entry is not None - # Unique ID should have been migrated from @22222222 to @33333333 - expected_unique_id = initial_unique_id.replace("@22222222", "@33333333") + # Unique ID device segment should have moved from 22222222 to 33333333 + expected_unique_id = initial_unique_id.replace("/22222222/", "/33333333/") assert entity_entry.unique_id == expected_unique_id # Entity should now be associated with the second sub-device @@ -1524,8 +1575,8 @@ async def test_entity_device_id_rename_in_yaml( entity_entry = entity_registry.async_get("binary_sensor.old_device_sensor") assert entity_entry is not None initial_unique_id = entity_entry.unique_id - # Should have @11111111 suffix - assert "@11111111" in initial_unique_id + # Sub-device entities carry the sub-device id in the unique id + assert "/11111111/" in initial_unique_id # Simulate user renaming device_id in YAML config # The device_id hash changes from 11111111 to 99999999 @@ -1587,9 +1638,8 @@ async def test_entity_device_id_rename_in_yaml( entity_entry = entity_registry.async_get("binary_sensor.renamed_device_sensor") assert entity_entry is not None - # Unique ID should have the new device_id - base_unique_id = initial_unique_id.replace("@11111111", "") - expected_unique_id = f"{base_unique_id}@99999999" + # Unique ID device segment should have the new device_id + expected_unique_id = initial_unique_id.replace("/11111111/", "/99999999/") assert entity_entry.unique_id == expected_unique_id # Entity should be associated with the new device diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index e06c77a2824d..eb7d83057b9b 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( import pytest from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.const import CONF_NOISE_PSK from homeassistant.components.esphome.entry_data import RuntimeEntryData from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -21,6 +22,53 @@ from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo from .conftest import MockGenericDeviceEntryType +async def test_migrate_entity_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test a legacy unique id is migrated to the version 3 format.""" + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "11:22:33:44:55:AA-sensor-mysensor", + suggested_object_id="my_sensor", + disabled_by=None, + ) + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + entity_category=ESPHomeEntityCategory.DIAGNOSTIC, + icon="mdi:leaf", + ) + ] + states = [SensorState(key=1, state=50)] + 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("sensor.my_sensor") + assert state is not None + assert state.state == "50" + entry = entity_registry.async_get("sensor.my_sensor") + assert entry is not None + # The legacy unique id should have been renamed to the version 3 format, + # keeping the entity (and its entity_id) instead of creating a new one + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "11:22:33:44:55:AA-sensor-mysensor" + ) + is None + ) + + async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -28,18 +76,20 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( mock_generic_device_entry: MockGenericDeviceEntryType, ) -> None: """Test unique id migration prefers the original entity on downgrade upgrade.""" + # The original entity, already in the version 3 format entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, - "my_sensor", - suggested_object_id="old_sensor", + "11:22:33:44:55:AA/0/sensor/my sensor", + suggested_object_id="new_sensor", disabled_by=None, ) + # A duplicate left behind in the legacy format by a downgrade entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, "11:22:33:44:55:AA-sensor-mysensor", - suggested_object_id="new_sensor", + suggested_object_id="old_sensor", disabled_by=None, ) entity_info = [ @@ -64,22 +114,28 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( assert state.state == "50" entry = entity_registry.async_get("sensor.new_sensor") assert entry is not None - # Confirm we did not touch the entity that was created + # Confirm we did not touch the legacy entity that was created # on downgrade so when they upgrade again they can delete the # entity that was only created on downgrade and they keep # the original one. assert ( - entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, "my_sensor") + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "11:22:33:44:55:AA-sensor-mysensor" + ) is not None ) - # 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" + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" async def test_discover_zwave() -> None: """Test ESPHome discovery of Z-Wave JS.""" hass = Mock() + # The noise PSK is read from the config entry, not the live client, so that + # a dynamically provisioned key that could not be applied to the already + # connected client is still passed on to the add-on. + hass.config_entries.async_get_entry.return_value = Mock( + data={CONF_NOISE_PSK: "mock-noise-psk"} + ) entry_data = RuntimeEntryData( "mock-id", "mock-title", @@ -105,6 +161,7 @@ async def test_discover_zwave() -> None: device_info, None, ) + hass.config_entries.async_get_entry.assert_called_once_with("mock-id") mock_create_flow.assert_called_once_with( hass, "zwave_js", @@ -114,7 +171,7 @@ async def test_discover_zwave() -> None: zwave_home_id=1234, ip_address="mock-client-address", port=1234, - noise_psk=None, + noise_psk="mock-noise-psk", ), discovery_key=discovery_flow.DiscoveryKey( domain="esphome", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 96ffcb52d3b7..70cdd63c5fa6 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,11 +2,13 @@ import asyncio import base64 +from collections.abc import Generator import logging from typing import Any from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIConnectionError, APIVersion, @@ -32,6 +34,7 @@ import pytest import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.esphome.config_flow import PROBE_NOISE_PSK from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_BLUETOOTH_MAC_ADDRESS, @@ -1933,8 +1936,8 @@ async def test_assist_in_progress_issue_deleted( Remove this cleanup after 2026.4 """ entry = entity_registry.async_get_or_create( - domain=DOMAIN, - platform="binary_sensor", + domain="binary_sensor", + platform=DOMAIN, unique_id="11:22:33:44:55:AA-assist_in_progress", ) ir.async_create_issue( @@ -1956,7 +1959,7 @@ async def test_assist_in_progress_issue_deleted( ) assert ( entity_registry.async_get_entity_id( - DOMAIN, "binary_sensor", "11:22:33:44:55:AA-assist_in_progress" + "binary_sensor", DOMAIN, "11:22:33:44:55:AA-assist_in_progress" ) is None ) @@ -2724,12 +2727,227 @@ async def test_manager_handle_dynamic_encryption_key_connection_error( assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] +@pytest.fixture +def mock_provisioning_client(mock_client: APIClient) -> Generator[Mock]: + """Mock the APIClient built for the zero PSK provisioning connection.""" + client = Mock(spec=APIClient) + client.connect = AsyncMock() + client.disconnect = AsyncMock() + client.noise_encryption_set_key = AsyncMock(return_value=True) + + def _api_client(*args: Any, **kwargs: Any) -> Mock: + if kwargs.get("noise_psk") == ZERO_NOISE_PSK: + return client + return mock_client(*args, **kwargs) + + with patch( + "homeassistant.components.esphome.manager.APIClient", side_effect=_api_client + ): + yield client + + +def _make_provisionable_entry(hass: HomeAssistant, mac_address: str) -> MockConfigEntry: + """Create a config entry without a noise PSK.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-device", + }, + unique_id=mac_address, + ) + entry.add_to_hass(hass) + return entry + + +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_provisioned_over_zero_psk( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test provisionable firmware gets the key over a zero PSK connection.""" + mac_address = "11:22:33:44:55:aa" + test_key_bytes = b"test_key_32_bytes_long_exactly!" + mock_token_bytes.return_value = test_key_bytes + expected_key = base64.b64encode(test_key_bytes).decode() + + entry = _make_provisionable_entry(hass, mac_address) + + # The main (plaintext) client must never be used to push the key + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + # The key went over the zero PSK client (the fixture only hands it out + # for constructions using ZERO_NOISE_PSK), not the plaintext connection + mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( + base64.b64encode(test_key_bytes) + ) + mock_client.noise_encryption_set_key.assert_not_called() + mock_provisioning_client.disconnect.assert_called_with(force=True) + + # Entry and storage were updated + assert entry.data[CONF_NOISE_PSK] == expected_key + assert ( + hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address] + == expected_key + ) + + +async def test_dynamic_encryption_key_provisioned_over_zero_psk_from_storage( + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], +) -> None: + """Test a stored key is re-provisioned over the zero PSK connection.""" + mac_address = "11:22:33:44:55:aa" + test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode() + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {mac_address: test_key}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + mock_provisioning_client.noise_encryption_set_key.assert_called_once_with( + test_key.encode() + ) + mock_client.noise_encryption_set_key.assert_not_called() + assert entry.data[CONF_NOISE_PSK] == test_key + + +@pytest.mark.parametrize( + ("connect_error", "set_key_result"), + [ + # Device already has a key (distinct log branch) + (InvalidEncryptionKeyAPIError("already keyed"), True), + # Old firmware answering plaintext to the noise hello (generic branch; + # all connection errors are APIConnectionError subclasses) + (EncryptionPlaintextAPIError("plaintext"), True), + # Device accepted the connection but rejected the key + (None, False), + ], +) +@patch("homeassistant.components.esphome.manager.secrets.token_bytes") +async def test_dynamic_encryption_key_zero_psk_failures_never_use_plaintext( + mock_token_bytes: Mock, + hass: HomeAssistant, + mock_client: APIClient, + mock_provisioning_client: Mock, + mock_esphome_device: MockESPHomeDeviceType, + hass_storage: dict[str, Any], + connect_error: Exception | None, + set_key_result: bool, +) -> None: + """Test zero PSK provisioning failures do not fall back to plaintext.""" + mac_address = "11:22:33:44:55:aa" + mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!" + + hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": ENCRYPTION_KEY_STORAGE_KEY, + "data": {"keys": {}}, + } + + entry = _make_provisionable_entry(hass, mac_address) + mock_client.noise_encryption_set_key = AsyncMock(return_value=True) + + # A None side_effect leaves connect behaving normally + mock_provisioning_client.connect.side_effect = connect_error + mock_provisioning_client.noise_encryption_set_key.return_value = set_key_result + + device = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + device_info={ + "uses_password": False, + "name": "test-device", + "mac_address": mac_address, + "esphome_version": "2026.8.0", + "api_encryption_supported": True, + "api_encryption_provisionable": True, + }, + ) + + await device.mock_disconnect(True) + await device.mock_connect() + + # The plaintext connection was never used to push the key, the entry was + # not updated, and no generated key was stored + mock_client.noise_encryption_set_key.assert_not_called() + assert CONF_NOISE_PSK not in entry.data + assert mac_address not in hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"] + mock_provisioning_client.disconnect.assert_called_with(force=True) + + +def test_zero_noise_psk_is_not_the_probe_key() -> None: + """Test the provisioning PSK is 32 zero bytes and differs from the probe.""" + assert base64.b64decode(ZERO_NOISE_PSK) == bytes(32) + assert ZERO_NOISE_PSK != PROBE_NOISE_PSK + + async def test_zwave_proxy_request_home_id_change( hass: HomeAssistant, mock_client: APIClient, mock_esphome_device: MockESPHomeDeviceType, ) -> None: """Test Z-Wave proxy request handler with HOME_ID_CHANGE request.""" + noise_psk = "cD3vRGhSJTMgc2VjdXJlIG5vaXNlIHBzayBoZXJlIQ==" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test-zwave-proxy", + CONF_NOISE_PSK: noise_psk, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) device_info = { "name": "test-zwave-proxy", @@ -2739,6 +2957,7 @@ async def test_zwave_proxy_request_home_id_change( await mock_esphome_device( mock_client=mock_client, + entry=entry, device_info=device_info, ) await hass.async_block_till_done() @@ -2771,6 +2990,10 @@ async def test_zwave_proxy_request_home_id_change( # Verify no flow was created for non-HOME_ID_CHANGE requests mock_create_flow.assert_not_called() + # A dynamically provisioned key is written to the config entry but cannot + # be applied to the already connected client, so the client reports no PSK. + mock_client.noise_psk = None + # Create a mock request with HOME_ID_CHANGE type and zwave_home_id as bytes zwave_home_id = 1234567890 request = ZWaveProxyRequest( @@ -2792,6 +3015,8 @@ async def test_zwave_proxy_request_home_id_change( call_args = mock_create_flow.call_args assert call_args[0][0] == hass assert call_args[0][1] == "zwave_js" + # The noise PSK is taken from the config entry, not the live client + assert call_args[0][3].noise_psk == noise_psk async def test_no_zwave_proxy_subscribe_without_feature_flags( diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index 5a6090234a48..975f0fb31eff 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -139,13 +139,15 @@ async def test_device_conflict_migration( ent_reg_entry = entity_registry.async_get("binary_sensor.test_my_binary_sensor") assert ent_reg_entry - assert ent_reg_entry.unique_id == "11:22:33:44:55:AA-binary_sensor-mybinary_sensor" + assert ( + ent_reg_entry.unique_id == "11:22:33:44:55:AA/0/binary_sensor/my binary_sensor" + ) entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) assert entries is not None for entry in entries: - assert entry.unique_id.startswith("11:22:33:44:55:AA-") + assert entry.unique_id.startswith("11:22:33:44:55:AA/") disconnect_done = hass.loop.create_future() async def async_disconnect(*args, **kwargs) -> None: @@ -201,14 +203,16 @@ async def test_device_conflict_migration( assert mock_config_entry.unique_id == "11:22:33:44:55:ab" ent_reg_entry = entity_registry.async_get("binary_sensor.test_my_binary_sensor") assert ent_reg_entry - assert ent_reg_entry.unique_id == "11:22:33:44:55:AB-binary_sensor-mybinary_sensor" + assert ( + ent_reg_entry.unique_id == "11:22:33:44:55:AB/0/binary_sensor/my binary_sensor" + ) entries = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) assert entries is not None for entry in entries: - assert entry.unique_id.startswith("11:22:33:44:55:AB-") + assert entry.unique_id.startswith("11:22:33:44:55:AB/") dev_entry = device_registry.async_get_device( identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:44:55:ab")} diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 926d24a7869b..adea7007c2c8 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -129,7 +129,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( assert entry is not None # 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" + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -168,7 +168,7 @@ async def test_generic_numeric_sensor_state_class_measurement( assert entry is not None # 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" + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" assert entry.entity_category is None @@ -205,7 +205,7 @@ async def test_generic_numeric_sensor_state_class_measurement_angle( assert entry is not None # 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" + assert entry.unique_id == "11:22:33:44:55:AA/0/sensor/my sensor" assert entry.entity_category is None diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 1bdb4b4ac637..c29606fdff1c 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -10,6 +10,7 @@ from awesomeversion.exceptions import AwesomeVersionCompareException import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard +from homeassistant.components.esphome.update import KEY_UPDATE_LOCK from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -811,6 +812,98 @@ async def test_attempt_to_update_twice( await update_task +async def test_update_dashboard_with_build_queue_skips_global_lock( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test the global compile lock is skipped when the dashboard has a build queue.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2026.6.0", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device(mock_client=mock_client) + await hass.async_block_till_done() + + # Hold the global compile lock; the install must not need it + lock = hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()) + await lock.acquire() + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ) as mock_compile, + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + lock.release() + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + +async def test_update_dashboard_without_build_queue_waits_for_global_lock( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_dashboard: dict[str, Any], +) -> None: + """Test the global compile lock still serializes installs on older dashboards.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2026.5.0", + "configuration": "test.yaml", + } + ] + await async_get_dashboard(hass).async_refresh() + await mock_esphome_device(mock_client=mock_client) + await hass.async_block_till_done() + + lock = hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()) + await lock.acquire() + with ( + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.compile", + return_value=True, + ) as mock_compile, + patch( + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.upload", + return_value=True, + ), + ): + update_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_firmware"}, + blocking=True, + ) + ) + for _ in range(5): + await asyncio.sleep(0) + # The compile must be blocked on the global lock + assert len(mock_compile.mock_calls) == 0 + lock.release() + await update_task + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + async def test_update_deep_sleep_already_online( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/esphome/test_wav_parser.py b/tests/components/esphome/test_wav_parser.py new file mode 100644 index 000000000000..c53ccaf35884 --- /dev/null +++ b/tests/components/esphome/test_wav_parser.py @@ -0,0 +1,270 @@ +"""Test the ESPHome WAV parser helper.""" + +from collections.abc import AsyncIterable +import io +import struct +import wave + +import pytest + +from homeassistant.components.esphome.wav_parser import stream_wav + + +def _create_wav( + channels: int = 1, + sample_width: int = 2, + sample_rate: int = 16000, + data: bytes = b"\x00" * 1024, +) -> bytes: + """Create a valid WAV file in bytes.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(sample_rate) + wav_file.setsampwidth(sample_width) + wav_file.setnchannels(channels) + wav_file.writeframes(data) + return wav_io.getvalue() + + +async def _async_generator(data: bytes, chunk_size: int = 128) -> AsyncIterable[bytes]: + """Yield bytes in chunks.""" + for i in range(0, len(data), chunk_size): + yield data[i : i + chunk_size] + + +async def test_stream_wav_valid() -> None: + """Test streaming a valid WAV file.""" + audio_data = b"\x01\x02\x03\x04" * 256 # 1024 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=100), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, + ): + chunks.append((chunk, is_last)) + + # samples_per_chunk = 256, expected_width = 2, expected_channels = 1 + # bytes_per_chunk = 256 * 2 * 1 = 512 bytes + # total audio data = 1024 bytes -> exactly 2 chunks of 512 bytes + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_unsupported_format() -> None: + """Test streaming with an unsupported format.""" + wav_bytes = _create_wav() + with pytest.raises(ValueError, match="Unsupported expected format"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_format="mp3", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + +async def test_stream_wav_invalid_header() -> None: + """Test streaming with an invalid WAV header.""" + invalid_wav = b"RIFFinvalidheader" + with pytest.raises( + ValueError, match="Invalid WAV format: missing RIFF/WAVE header" + ): + async for _, _ in stream_wav( + _async_generator(invalid_wav), + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + +async def test_stream_wav_missing_data_chunk() -> None: + """Test streaming a WAV that is missing data chunk.""" + # Write only a fmt chunk + header = b"RIFF" + struct.pack(" None: + """Test parameter validation against fmt chunk.""" + wav_bytes = _create_wav(channels=1, sample_width=2, sample_rate=16000) + + # Wrong channels + with pytest.raises(ValueError, match="Expected 2 channels, got 1"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=2, + expected_width=2, + expected_sample_rate=16000, + ): + pass + + # Wrong width + with pytest.raises(ValueError, match="Expected 4 bytes per sample, got 2"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=1, + expected_width=4, + expected_sample_rate=16000, + ): + pass + + # Wrong sample rate + with pytest.raises(ValueError, match="Expected 8000 Hz, got 16000 Hz"): + async for _, _ in stream_wav( + _async_generator(wav_bytes), + expected_channels=1, + expected_width=2, + expected_sample_rate=8000, + ): + pass + + +async def test_stream_wav_non_pcm() -> None: + """Test non-PCM WAV format.""" + header = b"RIFF" + struct.pack(" None: + """Test streaming data chunk without fmt chunk.""" + header = b"RIFF" + struct.pack(" None: + """Test when fmt chunk size is less than 16.""" + header = b"RIFF" + struct.pack(" None: + """Test streaming a WAV file where audio data size is not a multiple of chunk size.""" + audio_data = b"\x01\x02\x03\x04" * 150 # 600 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=100), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, # 512 bytes per chunk + ): + chunks.append((chunk, is_last)) + + # First chunk: 512 bytes, not last + # Second chunk: 88 bytes, last + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_small_chunks() -> None: + """Test streaming a WAV file in very small chunks to test partial header parsing.""" + audio_data = b"\x01\x02\x03\x04" * 256 # 1024 bytes + wav_bytes = _create_wav(data=audio_data) + + chunks = [] + async for chunk, is_last in stream_wav( + _async_generator(wav_bytes, chunk_size=5), + expected_format="pcm", + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=256, + ): + chunks.append((chunk, is_last)) + + assert len(chunks) == 2 + assert chunks[0] == (audio_data[:512], False) + assert chunks[1] == (audio_data[512:], True) + + +async def test_stream_wav_odd_fmt_chunk() -> None: + """Test streaming a WAV file with an odd-sized fmt chunk where the pad byte is in a separate chunk.""" + header = b"RIFF" + struct.pack(" AsyncIterable[bytes]: + yield part1 + yield part2 + + chunks = [] + async for chunk, is_last in stream_wav( + stream_generator(), + expected_channels=1, + expected_width=2, + expected_sample_rate=16000, + samples_per_chunk=2, + ): + chunks.append((chunk, is_last)) + + assert len(chunks) == 1 + assert chunks[0] == (b"\x01\x02\x03\x04", True) diff --git a/tests/components/essent/snapshots/test_init.ambr b/tests/components/essent/snapshots/test_init.ambr index de1cc10d4f1f..c0bef2d727ae 100644 --- a/tests/components/essent/snapshots/test_init.ambr +++ b/tests/components/essent/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Essent', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/eurotronic_cometblue/snapshots/test_init.ambr b/tests/components/eurotronic_cometblue/snapshots/test_init.ambr index e7a6d8a3ebdc..79c9b95cd142 100644 --- a/tests/components/eurotronic_cometblue/snapshots/test_init.ambr +++ b/tests/components/eurotronic_cometblue/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Comet Blue aa:bb:cc:dd:ee:ff', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0.10', 'via_device_id': None, diff --git a/tests/components/evohome/snapshots/test_button.ambr b/tests/components/evohome/snapshots/test_button.ambr index 82cf48fa2418..02ec2f0a3cee 100644 --- a/tests/components/evohome/snapshots/test_button.ambr +++ b/tests/components/evohome/snapshots/test_button.ambr @@ -25,19 +25,6 @@ 'state': 'unknown', }) # --- -# name: test_setup_platform[botched][button.reset_domestic_hot_water-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Reset Domestic Hot Water', - }), - 'context': , - 'entity_id': 'button.reset_domestic_hot_water', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_setup_platform[botched][button.reset_front_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -116,6 +103,19 @@ 'state': 'unknown', }) # --- +# name: test_setup_platform[botched][button.reset_my_home_dhw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Reset My Home DHW', + }), + 'context': , + 'entity_id': 'button.reset_my_home_dhw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_platform[default][button.reset_bathroom_dn-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -142,19 +142,6 @@ 'state': 'unknown', }) # --- -# name: test_setup_platform[default][button.reset_domestic_hot_water-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Reset Domestic Hot Water', - }), - 'context': , - 'entity_id': 'button.reset_domestic_hot_water', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_setup_platform[default][button.reset_front_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -233,6 +220,19 @@ 'state': 'unknown', }) # --- +# name: test_setup_platform[default][button.reset_my_home_dhw-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Reset My Home DHW', + }), + 'context': , + 'entity_id': 'button.reset_my_home_dhw', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_setup_platform[default][button.reset_spare_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/evohome/test_services.py b/tests/components/evohome/test_services.py index 91a911e1de49..eab9cbf81515 100644 --- a/tests/components/evohome/test_services.py +++ b/tests/components/evohome/test_services.py @@ -4,15 +4,13 @@ from datetime import UTC, datetime from typing import Any from unittest.mock import patch +from evohomeasync2.const import SZ_DURATION, SZ_MODE, SZ_PERIOD, SZ_SETPOINT, SZ_STATE from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.evohome.climate import EvoZone from homeassistant.components.evohome.const import ( - ATTR_DURATION, - ATTR_PERIOD, - ATTR_SETPOINT, DOMAIN, REFRESH_BREAKS_IN_HA_VERSION, RESET_BREAKS_IN_HA_VERSION, @@ -21,7 +19,7 @@ from homeassistant.components.evohome.const import ( ) from homeassistant.components.evohome.water_heater import EvoDHW from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, ATTR_STATE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir @@ -133,7 +131,7 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", }, blocking=True, ) @@ -156,8 +154,8 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "AutoWithEco", - ATTR_DURATION: {"hours": 12}, + SZ_MODE: "AutoWithEco", + SZ_DURATION: {"hours": 12}, }, blocking=True, ) @@ -172,8 +170,8 @@ async def test_set_system_mode_deprecated( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, blocking=True, ) @@ -199,8 +197,8 @@ async def test_set_system_mode( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, target={ATTR_ENTITY_ID: ctl_id}, blocking=True, @@ -217,8 +215,8 @@ async def test_set_system_mode( EvoService.SET_SYSTEM_MODE, { ATTR_ENTITY_ID: ctl_id, - ATTR_MODE: "Away", - ATTR_PERIOD: {"days": 7}, + SZ_MODE: "Away", + SZ_PERIOD: {"days": 7}, }, blocking=True, ) @@ -308,7 +306,7 @@ async def test_set_zone_override( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, + SZ_SETPOINT: 19.5, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -322,8 +320,8 @@ async def test_set_zone_override( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 135}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 135}, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -363,8 +361,8 @@ async def test_set_zone_override_advance( DOMAIN, EvoService.SET_ZONE_OVERRIDE, { - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 0}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 0}, }, target={ATTR_ENTITY_ID: zone_id}, blocking=True, @@ -392,7 +390,7 @@ async def test_set_zone_override_legacy( EvoService.SET_ZONE_OVERRIDE, { ATTR_ENTITY_ID: zone_id, - ATTR_SETPOINT: 19.5, + SZ_SETPOINT: 19.5, }, blocking=True, ) @@ -406,8 +404,8 @@ async def test_set_zone_override_legacy( EvoService.SET_ZONE_OVERRIDE, { ATTR_ENTITY_ID: zone_id, - ATTR_SETPOINT: 19.5, - ATTR_DURATION: {"minutes": 135}, + SZ_SETPOINT: 19.5, + SZ_DURATION: {"minutes": 135}, }, blocking=True, ) @@ -422,7 +420,7 @@ async def test_set_zone_override_legacy( ("service", "service_data"), [ (EvoService.CLEAR_ZONE_OVERRIDE, {}), - (EvoService.SET_ZONE_OVERRIDE, {ATTR_SETPOINT: 19.5}), + (EvoService.SET_ZONE_OVERRIDE, {SZ_SETPOINT: 19.5}), ], ) async def test_zone_services_with_ctl_id( @@ -458,7 +456,7 @@ async def test_controller_services_with_zone_id( DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", ATTR_ENTITY_ID: zone_id, }, blocking=True, @@ -482,7 +480,7 @@ async def test_set_system_mode_entity_not_found(hass: HomeAssistant) -> None: DOMAIN, EvoService.SET_SYSTEM_MODE, { - ATTR_MODE: "Auto", + SZ_MODE: "Auto", ATTR_ENTITY_ID: non_existent_entity_id, }, blocking=True, @@ -496,19 +494,19 @@ async def test_set_system_mode_entity_not_found(hass: HomeAssistant) -> None: _SET_SYSTEM_MODE_VALIDATOR_PARAMS = [ ( - {ATTR_MODE: "NotARealMode"}, + {SZ_MODE: "NotARealMode"}, "mode_not_supported", ), ( - {ATTR_MODE: "Auto", ATTR_DURATION: {"hours": 1}}, + {SZ_MODE: "Auto", SZ_DURATION: {"hours": 1}}, "mode_cant_be_temporary", ), ( - {ATTR_MODE: "AutoWithEco", ATTR_PERIOD: {"days": 1}}, + {SZ_MODE: "AutoWithEco", SZ_PERIOD: {"days": 1}}, "mode_cant_have_period", ), ( - {ATTR_MODE: "DayOff", ATTR_DURATION: {"hours": 1}}, + {SZ_MODE: "DayOff", SZ_DURATION: {"hours": 1}}, "mode_cant_have_duration", ), ] @@ -537,9 +535,7 @@ async def test_set_system_mode_validator( ) assert exc_info.value.translation_key == expected_translation_key - assert exc_info.value.translation_placeholders == { - ATTR_MODE: service_data[ATTR_MODE] - } + assert exc_info.value.translation_placeholders == {SZ_MODE: service_data[SZ_MODE]} @pytest.mark.parametrize("install", ["default"]) @@ -558,7 +554,7 @@ async def test_set_dhw_override( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: False, + SZ_STATE: False, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, @@ -572,8 +568,8 @@ async def test_set_dhw_override( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: True, - ATTR_DURATION: {"minutes": 135}, + SZ_STATE: True, + SZ_DURATION: {"minutes": 135}, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, @@ -613,8 +609,8 @@ async def test_set_dhw_override_advance( DOMAIN, EvoService.SET_DHW_OVERRIDE, { - ATTR_STATE: True, - ATTR_DURATION: {"minutes": 0}, + SZ_STATE: True, + SZ_DURATION: {"minutes": 0}, }, target={ATTR_ENTITY_ID: dhw_id}, blocking=True, diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 49910ea1b251..83563014b4d5 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -1,11 +1,19 @@ """The tests for evohome storage load & save.""" from datetime import datetime, timedelta -from typing import Any, Final, NotRequired, TypedDict +from typing import Any, Final, TypedDict +from evohomeasync.auth import SZ_SESSION_ID, SZ_SESSION_ID_EXPIRES +from evohomeasync2.auth import ( + SZ_ACCESS_TOKEN, + SZ_ACCESS_TOKEN_EXPIRES, + SZ_REFRESH_TOKEN, +) import pytest from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER +from homeassistant.components.evohome.storage import _TokenStoreT +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -13,30 +21,10 @@ from .conftest import setup_evohome from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME -class _SessionDataT(TypedDict): - session_id: str - session_id_expires: NotRequired[str] # 2024-07-27T23:57:30+01:00 - - -class _TokenStoreT(TypedDict): - username: str - refresh_token: str - access_token: str - access_token_expires: str # 2024-07-27T23:57:30+01:00 - user_data: NotRequired[_SessionDataT] - - class _EmptyStoreT(TypedDict): pass -SZ_USERNAME: Final = "username" -SZ_REFRESH_TOKEN: Final = "refresh_token" -SZ_ACCESS_TOKEN: Final = "access_token" -SZ_ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" -SZ_USER_DATA: Final = "user_data" - - def dt_pair(dt_dtm: datetime) -> tuple[datetime, str]: """Return a datetime without milliseconds and its string representation.""" dt_str = dt_dtm.isoformat(timespec="seconds") # e.g. 2024-07-28T00:57:29+01:00 @@ -44,12 +32,10 @@ def dt_pair(dt_dtm: datetime) -> tuple[datetime, str]: ACCESS_TOKEN_EXP_DTM, ACCESS_TOKEN_EXP_STR = dt_pair(dt_util.now() + timedelta(hours=1)) - -USERNAME_DIFF: Final = f"not_{USERNAME}" -USERNAME_SAME: Final = USERNAME +_, SESSION_ID_EXP_STR = dt_pair(dt_util.now() + timedelta(minutes=15)) _TEST_STORAGE_BASE: Final[_TokenStoreT] = { - SZ_USERNAME: USERNAME_SAME, + CONF_USERNAME: USERNAME, SZ_REFRESH_TOKEN: REFRESH_TOKEN, SZ_ACCESS_TOKEN: ACCESS_TOKEN, SZ_ACCESS_TOKEN_EXPIRES: ACCESS_TOKEN_EXP_STR, @@ -57,8 +43,11 @@ _TEST_STORAGE_BASE: Final[_TokenStoreT] = { TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = { "sans_session_id": _TEST_STORAGE_BASE, - "null_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: None}, # type: ignore[dict-item] - "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"session_id": SESSION_ID}}, + "with_session_id": _TEST_STORAGE_BASE + | { + SZ_SESSION_ID: SESSION_ID, + SZ_SESSION_ID_EXPIRES: SESSION_ID_EXP_STR, + }, # pyright: ignore[reportAssignmentType] } TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { @@ -66,7 +55,7 @@ TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { "store_was_reset": {}, } -DOMAIN_STORAGE_BASE: Final = { +DOMAIN_STORAGE_ROOT: Final = { "version": STORAGE_VER, "minor_version": 1, "key": STORAGE_KEY, @@ -84,21 +73,20 @@ async def test_auth_tokens_null( ) -> None: """Test credentials manager when cache is empty.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]} + hass_storage[DOMAIN] = DOMAIN_STORAGE_ROOT | {"data": TEST_STORAGE_NULL[idx]} async for _ in setup_evohome(hass, config, install=install): pass - # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + # Confirm the expected tokens were cached to storage... + assert data[CONF_USERNAME] == USERNAME assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" - assert ( - dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) - > dt_util.now() - ) + + assert (expires := data.get(SZ_ACCESS_TOKEN_EXPIRES)) is not None + assert dt_util.parse_datetime(expires, raise_on_error=True) > dt_util.now() @pytest.mark.parametrize("install", ["minimal"]) @@ -112,18 +100,20 @@ async def test_auth_tokens_same( ) -> None: """Test credentials manager when cache contains valid data for this user.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} + hass_storage[DOMAIN] = DOMAIN_STORAGE_ROOT | {"data": TEST_STORAGE_DATA[idx]} async for _ in setup_evohome(hass, config, install=install): pass - # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + # Confirm the expected tokens were cached to storage... + assert data[CONF_USERNAME] == USERNAME assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN assert data[SZ_ACCESS_TOKEN] == ACCESS_TOKEN - assert dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES]) == ACCESS_TOKEN_EXP_DTM + + assert (expires := data[SZ_ACCESS_TOKEN_EXPIRES]) is not None + assert dt_util.parse_datetime(expires, raise_on_error=True) == ACCESS_TOKEN_EXP_DTM @pytest.mark.parametrize("install", ["minimal"]) @@ -137,27 +127,26 @@ 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)) + # Make this access token have expired in the past... + _, 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 test_data[SZ_ACCESS_TOKEN_EXPIRES] = dt_str - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} + hass_storage[DOMAIN] = DOMAIN_STORAGE_ROOT | {"data": test_data} async for _ in setup_evohome(hass, config, install=install): pass - # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_SAME + # Confirm the expected tokens were cached to storage... + assert data[CONF_USERNAME] == USERNAME assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" - assert ( - dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) - > dt_util.now() - ) + + assert (expires := data[SZ_ACCESS_TOKEN_EXPIRES]) is not None + assert dt_util.parse_datetime(expires, raise_on_error=True) > dt_util.now() @pytest.mark.parametrize("install", ["minimal"]) @@ -171,19 +160,19 @@ async def test_auth_tokens_diff( ) -> None: """Test credentials manager when cache contains data for a different user.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} - config["username"] = USERNAME_DIFF + # Make this access token be for a different user... + hass_storage[DOMAIN] = DOMAIN_STORAGE_ROOT | {"data": TEST_STORAGE_DATA[idx]} + config[CONF_USERNAME] = f"new_{USERNAME}" async for _ in setup_evohome(hass, config, install=install): pass - # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] - assert data[SZ_USERNAME] == USERNAME_DIFF + # Confirm the expected tokens were cached to storage... + assert data[CONF_USERNAME] == f"new_{USERNAME}" assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" - assert ( - dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) - > dt_util.now() - ) + + assert (expires := data[SZ_ACCESS_TOKEN_EXPIRES]) is not None + assert dt_util.parse_datetime(expires, raise_on_error=True) > dt_util.now() diff --git a/tests/components/ezviz/test_init.py b/tests/components/ezviz/test_init.py new file mode 100644 index 000000000000..d24ce16af627 --- /dev/null +++ b/tests/components/ezviz/test_init.py @@ -0,0 +1,339 @@ +"""Tests for EZVIZ entities.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, Mock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components import image +from homeassistant.components.ezviz.const import ATTR_TYPE_CLOUD +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + +SCAN_INTERVAL = timedelta(seconds=30) + + +def _mock_camera_data(**kwargs: object) -> dict[str, object]: + """Return a valid mocked EZVIZ camera payload for integration setup.""" + data: dict[str, object] = { + "name": "Camera 1", + "device_sub_category": "CAMERA", + "mac_address": "AA:BB:CC:DD:EE:FF", + "version": "1.0.0", + "status": 1, + "encrypted": False, + "supportExt": {}, + "switches": {}, + "local_ip": "192.168.1.100", + "local_rtsp_port": 554, + "alarm_notify": False, + "upgrade_available": False, + "upgrade_in_progress": False, + "upgrade_percent": 0, + "latest_firmware_info": None, + } + data.update(kwargs) + return data + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain="ezviz", + unique_id="test-username", + title="test-username", + data={ + "session_id": "test-username", + "rf_session_id": "test-password", + "url": "apiieu.ezvizlife.com", + "type": ATTR_TYPE_CLOUD, + }, + ) + + +async def test_image_entity_does_not_expose_last_alarm_pic_attribute( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, +) -> None: + """Test that image entity does not include last_alarm_pic as an attribute.""" + # Mock coordinator data with last_alarm_pic + mock_coordinator_data = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image.jpg", + ) + } + + # Mock the load_cameras method to return our test data + mock_ezviz_client.load_cameras.return_value = mock_coordinator_data + + await setup_integration(hass, mock_config_entry) + + # Check that image entity was created without exposing the image URL. + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert "last_alarm_pic" not in state.attributes + + +async def test_last_alarm_pic_sensor_not_created( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, +) -> None: + """Test that last_alarm_pic sensor is not created.""" + # Mock coordinator data with all sensor fields + mock_coordinator_data = { + "C123456789": _mock_camera_data( + battery_level=85, + alarm_sound_mod="soft", + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image.jpg", + supported_channels=1, + local_ip="192.168.1.100", + wan_ip="203.0.113.1", + PIR_Status="active", + last_alarm_type_code="motion", + last_alarm_type_name="Motion Detected", + ) + } + + # Mock the load_cameras method to return our test data + mock_ezviz_client.load_cameras.return_value = mock_coordinator_data + + await setup_integration(hass, mock_config_entry) + + # Check that last_alarm_pic sensor was NOT created + last_alarm_pic_state = hass.states.get("sensor.camera_1_last_alarm_pic") + assert last_alarm_pic_state is None + + # But other sensors should be created + registry = er.async_get(hass) + battery_entity = registry.async_get("sensor.camera_1_battery") + assert battery_entity is not None + + +async def test_migrated_last_alarm_pic_sensor_is_removed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrated last_alarm_pic sensor entities are removed on setup.""" + mock_config_entry.add_to_hass(hass) + + migrated_entry = entity_registry.async_get_or_create( + "sensor", + "ezviz", + "C123456789_Camera 1.last_alarm_pic", + config_entry=mock_config_entry, + ) + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image.jpg", + ) + } + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get(migrated_entry.entity_id) is None + + +async def test_sensor_cleanup_ignores_entries_without_unique_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrated sensor cleanup ignores entries with no unique_id.""" + mock_config_entry.add_to_hass(hass) + + entity_without_unique_id = entity_registry.async_get_or_create( + "sensor", + "ezviz", + "C123456789_Camera 1.battery_level", + config_entry=mock_config_entry, + ) + entity_registry.async_update_entity( + entity_without_unique_id.entity_id, + new_unique_id=None, + ) + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image.jpg", + ) + } + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get(entity_without_unique_id.entity_id) is not None + + +async def test_image_entity_created_without_alarm_pic( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, +) -> None: + """Test that image entity is created even when last_alarm_pic is missing.""" + # Mock coordinator data without last_alarm_pic + mock_coordinator_data = { + "C123456789": _mock_camera_data( + battery_level=85, + ) + } + + # Mock the load_cameras method to return our test data + mock_ezviz_client.load_cameras.return_value = mock_coordinator_data + + await setup_integration(hass, mock_config_entry) + + # Check that image entity was created without exposing the image URL. + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert "last_alarm_pic" not in state.attributes + + +@pytest.mark.parametrize( + ("last_alarm_time", "expected_state"), + [ + pytest.param( + "2023-01-01T12:00:00Z", + "2023-01-01T12:00:00+00:00", + id="valid timestamp", + ), + pytest.param(None, STATE_UNKNOWN, id="missing timestamp"), + pytest.param(123, STATE_UNKNOWN, id="non-string timestamp"), + ], +) +async def test_image_entity_last_alarm_time_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + last_alarm_time: object, + expected_state: str, +) -> None: + """Test image entity state from last_alarm_time data.""" + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time=last_alarm_time, + last_alarm_pic="https://example.com/image.jpg", + ) + } + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert state.state == expected_state + + +async def test_image_entity_updates_last_alarm_pic_on_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the image entity updates its alarm picture on refresh.""" + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image-1.jpg", + ) + } + + await setup_integration(hass, mock_config_entry) + with patch( + "homeassistant.components.ezviz.image.EzvizLastMotion._fetch_url", + new_callable=AsyncMock, + return_value=Mock(content=b"image-1"), + ): + image_data = await image.async_get_image( + hass, "image.camera_1_last_motion_image" + ) + assert image_data.content == b"image-1" + + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:05:00Z", + last_alarm_pic="https://example.com/image-2.jpg", + ) + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.ezviz.image.EzvizLastMotion._fetch_url", + new_callable=AsyncMock, + return_value=Mock(content=b"image-2"), + ): + image_data = await image.async_get_image( + hass, "image.camera_1_last_motion_image" + ) + assert image_data.content == b"image-2" + + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert "last_alarm_pic" not in state.attributes + + +async def test_image_entity_keeps_last_alarm_pic_when_refresh_omits_it( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ezviz_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the image entity keeps the previous alarm picture when omitted.""" + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:00:00Z", + last_alarm_pic="https://example.com/image-1.jpg", + ) + } + + await setup_integration(hass, mock_config_entry) + with patch( + "homeassistant.components.ezviz.image.EzvizLastMotion._fetch_url", + new_callable=AsyncMock, + return_value=Mock(content=b"image-1"), + ): + image_data = await image.async_get_image( + hass, "image.camera_1_last_motion_image" + ) + assert image_data.content == b"image-1" + + mock_ezviz_client.load_cameras.return_value = { + "C123456789": _mock_camera_data( + last_alarm_time="2023-01-01T12:05:00Z", + ) + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.ezviz.image.EzvizLastMotion._fetch_url", + new_callable=AsyncMock, + ) as mock_fetch_url: + image_data = await image.async_get_image( + hass, "image.camera_1_last_motion_image" + ) + assert image_data.content == b"image-1" + mock_fetch_url.assert_not_called() + + state = hass.states.get("image.camera_1_last_motion_image") + assert state is not None + assert "last_alarm_pic" not in state.attributes diff --git a/tests/components/fan/test_llm.py b/tests/components/fan/test_llm.py new file mode 100644 index 000000000000..92f95aad4651 --- /dev/null +++ b/tests/components/fan/test_llm.py @@ -0,0 +1,58 @@ +"""Tests for the fan LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.fan import llm as fan_llm +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "fan.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a fan entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "fan", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test fan"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the fan platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed fan entity.""" + assert "HassFanSetSpeed" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no fan entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassFanSetSpeed" not in await _tool_names(hass) + assert fan_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert fan_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/file_upload/test_init.py b/tests/components/file_upload/test_init.py index 2bf68f00ea0f..49daf1b3b63f 100644 --- a/tests/components/file_upload/test_init.py +++ b/tests/components/file_upload/test_init.py @@ -17,8 +17,8 @@ from tests.components.image_upload import TEST_IMAGE from tests.typing import ClientSessionGenerator -@pytest.fixture -async def uploaded_file_dir( +@pytest.fixture(name="uploaded_file_dir") +async def upload_file_dir( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> Path: """Test uploading and using a file.""" @@ -51,7 +51,6 @@ async def test_using_file(hass: HomeAssistant, uploaded_file_dir) -> None: assert file_path.parent == uploaded_file_dir assert file_path.read_bytes() == TEST_IMAGE.read_bytes() - # Test it's removed assert not uploaded_file_dir.exists() diff --git a/tests/components/fints/__init__.py b/tests/components/fints/__init__.py index 6a2b1d96d206..b733febd899e 100644 --- a/tests/components/fints/__init__.py +++ b/tests/components/fints/__init__.py @@ -1 +1 @@ -"""Tests for FinTS component.""" +"""Tests for FinTS integration.""" diff --git a/tests/components/flo/snapshots/test_init.ambr b/tests/components/flo/snapshots/test_init.ambr index 6a242c4d2cec..7e4dc3e64f6b 100644 --- a/tests/components/flo/snapshots/test_init.ambr +++ b/tests/components/flo/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Smart water shutoff', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '6.1.1', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Kitchen sink', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111112', 'sw_version': '1.1.15', 'via_device_id': None, diff --git a/tests/components/foobot/__init__.py b/tests/components/foobot/__init__.py index 88d916d997f1..a39cf589a88f 100644 --- a/tests/components/foobot/__init__.py +++ b/tests/components/foobot/__init__.py @@ -1 +1 @@ -"""Tests for the foobot component.""" +"""Tests for the Foobot integration.""" diff --git a/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr b/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr index fc5568f903de..01dc327459ef 100644 --- a/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr +++ b/tests/components/fressnapf_tracker/snapshots/test_device_tracker.ambr @@ -46,8 +46,8 @@ : 10.0, : list([ ]), - : 52.520008, - : 13.404954, + : 52.520008, + : 13.404954, : , : , }), diff --git a/tests/components/fressnapf_tracker/snapshots/test_init.ambr b/tests/components/fressnapf_tracker/snapshots/test_init.ambr index 59d50fa4ce13..721deab34c45 100644 --- a/tests/components/fressnapf_tracker/snapshots/test_init.ambr +++ b/tests/components/fressnapf_tracker/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_state_entity_device_snapshots[Fluffy-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Fluffy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ABC123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/fronius/snapshots/test_binary_sensor.ambr b/tests/components/fronius/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..b87ef268a49f --- /dev/null +++ b/tests/components/fronius/snapshots/test_binary_sensor.ambr @@ -0,0 +1,201 @@ +# serializer version: 1 +# name: test_binary_sensors[gen24][binary_sensor.solarnet_backup_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.solarnet_backup_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backup mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backup mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backup_mode', + 'unique_id': 'solar_net_123.4567890-power_flow-backup_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[gen24][binary_sensor.solarnet_backup_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarNet Backup mode', + }), + 'context': , + 'entity_id': 'binary_sensor.solarnet_backup_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[gen24][binary_sensor.solarnet_battery_standby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.solarnet_battery_standby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery standby', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery standby', + 'platform': 'fronius', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_standby', + 'unique_id': 'solar_net_123.4567890-power_flow-battery_standby', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[gen24][binary_sensor.solarnet_battery_standby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarNet Battery standby', + }), + 'context': , + 'entity_id': 'binary_sensor.solarnet_battery_standby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[gen24_storage][binary_sensor.solarnet_backup_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.solarnet_backup_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backup mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backup mode', + 'platform': 'fronius', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backup_mode', + 'unique_id': 'solar_net_12345678-power_flow-backup_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[gen24_storage][binary_sensor.solarnet_backup_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarNet Backup mode', + }), + 'context': , + 'entity_id': 'binary_sensor.solarnet_backup_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[gen24_storage][binary_sensor.solarnet_battery_standby-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.solarnet_battery_standby', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery standby', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery standby', + 'platform': 'fronius', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_standby', + 'unique_id': 'solar_net_12345678-power_flow-battery_standby', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[gen24_storage][binary_sensor.solarnet_battery_standby-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarNet Battery standby', + }), + 'context': , + 'entity_id': 'binary_sensor.solarnet_battery_standby', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fronius/test_binary_sensor.py b/tests/components/fronius/test_binary_sensor.py new file mode 100644 index 000000000000..902e9b3308c2 --- /dev/null +++ b/tests/components/fronius/test_binary_sensor.py @@ -0,0 +1,54 @@ +"""Tests for the Fronius binary sensor platform.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MOCK_UID, mock_responses, setup_fronius_integration + +from tests.common import snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("fixture_set", "unique_id"), + [ + pytest.param("gen24", MOCK_UID, id="gen24"), + pytest.param("gen24_storage", "12345678", id="gen24_storage"), + ], +) +async def test_binary_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + fixture_set: str, + unique_id: str, +) -> None: + """Test Fronius power flow binary sensors for Gen24 devices.""" + mock_responses(aioclient_mock, fixture_set=fixture_set) + with patch("homeassistant.components.fronius.PLATFORMS", [Platform.BINARY_SENSOR]): + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id=unique_id + ) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_no_binary_sensors_without_backup_keys( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that no binary sensors are created when the API omits the keys.""" + # The Symo power flow response has neither BackupMode nor BatteryStandby. + mock_responses(aioclient_mock, fixture_set="symo") + await setup_fronius_integration(hass, is_logger=True) + + assert not hass.states.async_all(domain_filter=BINARY_SENSOR_DOMAIN) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index be8cd43cf2bb..0d226de76c97 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -1,5 +1,7 @@ """Tests for the Fronius sensor platform.""" +from unittest.mock import patch + from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -10,6 +12,7 @@ from homeassistant.components.fronius.coordinator import ( FroniusPowerFlowUpdateCoordinator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -269,7 +272,8 @@ async def test_gen24( assert state.state == str(expected_state) mock_responses(aioclient_mock, fixture_set="gen24") - config_entry = await setup_fronius_integration(hass, is_logger=False) + with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]): + config_entry = await setup_fronius_integration(hass, is_logger=False) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @@ -309,9 +313,10 @@ async def test_gen24_storage( assert state.state == str(expected_state) mock_responses(aioclient_mock, fixture_set="gen24_storage") - config_entry = await setup_fronius_integration( - hass, is_logger=False, unique_id="12345678" - ) + with patch("homeassistant.components.fronius.PLATFORMS", [Platform.SENSOR]): + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id="12345678" + ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 73 await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/fuelprices_dk/__init__.py b/tests/components/fuelprices_dk/__init__.py new file mode 100644 index 000000000000..acdada59323d --- /dev/null +++ b/tests/components/fuelprices_dk/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Danish Fuelprices integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration from a mock config entry.""" + 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/fuelprices_dk/conftest.py b/tests/components/fuelprices_dk/conftest.py new file mode 100644 index 000000000000..cf7bd2034dd9 --- /dev/null +++ b/tests/components/fuelprices_dk/conftest.py @@ -0,0 +1,81 @@ +"""Common fixtures for Fuelprices.dk tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pybraendstofpriser import Flist +import pytest + +from homeassistant.components.fuelprices_dk.const import ( + CONF_COMPANY, + CONF_STATION, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + +TEST_API_KEY = "test-api-key" +TEST_COMPANY = "Circle K" +TEST_STATION = {"id": 1234, "name": "Aarhus C"} +TEST_PRICES = {"Blyfri95": 14.29, "Diesel": 12.99, "Blyfri98": 14.99} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry for config flow tests.""" + with patch( + "homeassistant.components.fuelprices_dk.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a standard mock config entry with one station subentry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Fuelprices.dk", + version=1, + data={CONF_API_KEY: TEST_API_KEY}, + subentries_data=[ + ConfigSubentryData( + subentry_type="station", + title=f"{TEST_COMPANY} - {TEST_STATION['name']}", + unique_id=f"{TEST_COMPANY}_{TEST_STATION['id']}", + data={ + CONF_COMPANY: TEST_COMPANY, + CONF_STATION: TEST_STATION, + }, + ) + ], + ) + + +@pytest.fixture +def mock_braendstofpriser() -> Generator[AsyncMock]: + """Mock the pybraendstofpriser client used by the integration.""" + with ( + patch( + "homeassistant.components.fuelprices_dk.config_flow.Braendstofpriser", + autospec=True, + ) as mock_config_flow_client, + patch( + "homeassistant.components.fuelprices_dk.coordinator.Braendstofpriser", + new=mock_config_flow_client, + ), + ): + client = mock_config_flow_client.return_value + client.list_companies.return_value = [{"company": TEST_COMPANY}] + client.list_stations.return_value = Flist([TEST_STATION]) + client.get_prices.return_value = { + "station": { + "id": TEST_STATION["id"], + "name": TEST_STATION["name"], + "last_update": "2024-01-01T12:00:00", + }, + "prices": TEST_PRICES, + } + yield client diff --git a/tests/components/fuelprices_dk/snapshots/test_sensor.ambr b/tests/components/fuelprices_dk/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..119b9355bca0 --- /dev/null +++ b/tests/components/fuelprices_dk/snapshots/test_sensor.ambr @@ -0,0 +1,166 @@ +# serializer version: 1 +# name: test_sensors[sensor.aarhus_c_blyfri95-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aarhus_c_blyfri95', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Blyfri95', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:gas-station', + 'original_name': 'Blyfri95', + 'platform': 'fuelprices_dk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_price_blyfri95', + 'unit_of_measurement': 'DKK/L', + }) +# --- +# name: test_sensors[sensor.aarhus_c_blyfri95-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aarhus C Blyfri95', + : 'mdi:gas-station', + : , + : 'DKK/L', + }), + 'context': , + 'entity_id': 'sensor.aarhus_c_blyfri95', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.29', + }) +# --- +# name: test_sensors[sensor.aarhus_c_blyfri98-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aarhus_c_blyfri98', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Blyfri98', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:gas-station', + 'original_name': 'Blyfri98', + 'platform': 'fuelprices_dk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_price_blyfri98', + 'unit_of_measurement': 'DKK/L', + }) +# --- +# name: test_sensors[sensor.aarhus_c_blyfri98-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aarhus C Blyfri98', + : 'mdi:gas-station', + : , + : 'DKK/L', + }), + 'context': , + 'entity_id': 'sensor.aarhus_c_blyfri98', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.99', + }) +# --- +# name: test_sensors[sensor.aarhus_c_diesel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aarhus_c_diesel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Diesel', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:gas-station', + 'original_name': 'Diesel', + 'platform': 'fuelprices_dk', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_price_diesel', + 'unit_of_measurement': 'DKK/L', + }) +# --- +# name: test_sensors[sensor.aarhus_c_diesel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Aarhus C Diesel', + : 'mdi:gas-station', + : , + : 'DKK/L', + }), + 'context': , + 'entity_id': 'sensor.aarhus_c_diesel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.99', + }) +# --- diff --git a/tests/components/fuelprices_dk/test_config_flow.py b/tests/components/fuelprices_dk/test_config_flow.py new file mode 100644 index 000000000000..6c2407ce1345 --- /dev/null +++ b/tests/components/fuelprices_dk/test_config_flow.py @@ -0,0 +1,447 @@ +"""Test the Fuelprices.dk config flow.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientResponseError +from pybraendstofpriser import Flist +import pytest + +from homeassistant.components.fuelprices_dk.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .conftest import TEST_API_KEY, TEST_COMPANY, TEST_STATION + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +def _client_error(status: int) -> ClientResponseError: + """Create an aiohttp client response error with a specific status code.""" + return ClientResponseError( + request_info=Mock(), + history=(), + status=status, + message="error", + headers=None, + ) + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test a full successful 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_API_KEY: TEST_API_KEY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "station_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Fuelprices.dk" + assert result["data"] == {CONF_API_KEY: TEST_API_KEY} + assert len(result["subentries"]) == 1 + subentry = result["subentries"][0] + assert subentry["subentry_type"] == "station" + assert subentry["title"] == f"{TEST_COMPANY} - {TEST_STATION['name']}" + assert subentry["unique_id"] == f"{TEST_COMPANY}_{TEST_STATION['id']}" + assert subentry["data"] == {"company": TEST_COMPANY, "station": TEST_STATION} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("status", "error"), + [ + (401, "invalid_api_key"), + (429, "rate_limit_exceeded"), + (500, "cannot_connect"), + ], +) +async def test_user_flow_recovers_from_api_errors( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + status: int, + error: str, +) -> None: + """Test the user flow shows an error and then recovers.""" + mock_braendstofpriser.list_companies.side_effect = _client_error(status) + + 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_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_braendstofpriser.list_companies.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_recovers_without_companies( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, +) -> None: + """Test the user flow recovers when the API returns no companies.""" + mock_braendstofpriser.list_companies.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"], {CONF_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + mock_braendstofpriser.list_companies.return_value = [{"company": TEST_COMPANY}] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: TEST_API_KEY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_duplicate_api_key_aborts( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test flow aborts when the same API key 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_API_KEY: TEST_API_KEY} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "configure_stations", + [ + lambda mock: setattr(mock.list_stations, "side_effect", _client_error(500)), + lambda mock: setattr(mock.list_stations, "return_value", Flist([])), + ], + ids=["error", "empty"], +) +async def test_user_flow_station_error_returns_to_company_selection( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + configure_stations: Callable[[AsyncMock], None], +) -> None: + """Test station loading errors return the user to company selection.""" + configure_stations(mock_braendstofpriser) + + 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_API_KEY: TEST_API_KEY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + assert result["errors"] == {"base": "cannot_connect"} + + mock_braendstofpriser.list_stations.side_effect = None + mock_braendstofpriser.list_stations.return_value = Flist([TEST_STATION]) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_allows_different_api_key( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_braendstofpriser: AsyncMock, +) -> None: + """Test flow allows a different API key.""" + 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_API_KEY: "other-api-key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "other-api-key"} + + +async def test_reauth_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_braendstofpriser: AsyncMock, +) -> None: + """Test reauthentication updates the API key.""" + 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"], {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( + ("status", "error"), + [ + (401, "invalid_api_key"), + (429, "rate_limit_exceeded"), + (500, "cannot_connect"), + ], +) +async def test_reauth_recovers_from_api_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_braendstofpriser: AsyncMock, + status: int, + error: str, +) -> None: + """Test reauth shows an error and then recovers.""" + mock_config_entry.add_to_hass(hass) + mock_braendstofpriser.list_companies.side_effect = _client_error(status) + + result = await mock_config_entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bad-key"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_braendstofpriser.list_companies.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "new-api-key"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_subentry_flow_create( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a station subentry.""" + await setup_integration(hass, mock_config_entry) + + new_station = {"id": 4321, "name": "Aarhus N"} + mock_braendstofpriser.list_stations.return_value = Flist( + [TEST_STATION, new_station] + ) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "station_selection" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"station": new_station["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"{TEST_COMPANY} - {new_station['name']}" + assert result["unique_id"] == f"{TEST_COMPANY}_{new_station['id']}" + + +async def test_subentry_flow_duplicate_station( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry flow aborts for an already configured station.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"station": TEST_STATION["name"]} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "station_already_configured" + + +@pytest.mark.parametrize( + ("status", "reason"), + [ + (401, "invalid_api_key"), + (429, "rate_limit_exceeded"), + (500, "cannot_connect"), + ], +) +async def test_subentry_flow_api_init_error( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + status: int, + reason: str, +) -> None: + """Test subentry flow aborts for API init errors.""" + await setup_integration(hass, mock_config_entry) + mock_braendstofpriser.list_companies.side_effect = _client_error(status) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_subentry_flow_no_companies_aborts( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry flow aborts when no companies are returned.""" + await setup_integration(hass, mock_config_entry) + mock_braendstofpriser.list_companies.return_value = [] + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + "configure_stations", + [ + lambda mock: setattr(mock.list_stations, "side_effect", _client_error(500)), + lambda mock: setattr(mock.list_stations, "return_value", Flist([])), + ], + ids=["error", "empty"], +) +async def test_subentry_flow_station_error_returns_to_company_selection( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + configure_stations: Callable[[AsyncMock], None], +) -> None: + """Test station loading errors return the user to company selection.""" + await setup_integration(hass, mock_config_entry) + + configure_stations(mock_braendstofpriser) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "station"), + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "company_selection" + assert result["errors"] == {"base": "cannot_connect"} + + new_station = {"id": 4321, "name": "Aarhus N"} + mock_braendstofpriser.list_stations.side_effect = None + mock_braendstofpriser.list_stations.return_value = Flist( + [TEST_STATION, new_station] + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"company": TEST_COMPANY} + ) + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], {"station": new_station["name"]} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/fuelprices_dk/test_init.py b/tests/components/fuelprices_dk/test_init.py new file mode 100644 index 000000000000..70098a5fb720 --- /dev/null +++ b/tests/components/fuelprices_dk/test_init.py @@ -0,0 +1,147 @@ +"""Test initialization for Fuelprices.dk.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientResponseError +from pybraendstofpriser import Flist +from pybraendstofpriser.exceptions import ProductNotFoundError +import pytest + +from homeassistant.config_entries import ( + ConfigEntryState, + ConfigSubentry, + ConfigSubentryData, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import TEST_API_KEY, TEST_COMPANY, TEST_PRICES, TEST_STATION + +from tests.common import MockConfigEntry + + +def _client_error(status: int) -> ClientResponseError: + """Create an aiohttp client response error with a specific status code.""" + return ClientResponseError( + request_info=Mock(), + history=(), + status=status, + message="error", + headers=None, + ) + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the config entry is set up and unloaded correctly.""" + await setup_integration(hass, mock_config_entry) + + 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 + + +async def test_reload_on_subentry_added( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the entry reloads and adds entities when a subentry is added.""" + await setup_integration(hass, mock_config_entry) + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == len(TEST_PRICES) + + mock_braendstofpriser.get_prices.reset_mock() + + new_station = {"id": 4321, "name": "Aarhus N"} + hass.config_entries.async_add_subentry( + mock_config_entry, + ConfigSubentry( + subentry_type="station", + title=f"{TEST_COMPANY} - {new_station['name']}", + unique_id=f"{TEST_COMPANY}_{new_station['id']}", + data={"company": TEST_COMPANY, "station": new_station}, + ), + ) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == len(TEST_PRICES) * 2 + assert mock_braendstofpriser.get_prices.await_count == 2 + + +async def test_skips_non_station_subentries( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup skips unsupported subentry types.""" + config_entry = MockConfigEntry( + domain="fuelprices_dk", + version=1, + data={CONF_API_KEY: TEST_API_KEY}, + subentries_data=[ + ConfigSubentryData( + subentry_type="other", + title="Other", + unique_id="other_1", + data={"company": TEST_COMPANY, "station": TEST_STATION}, + ) + ], + ) + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.LOADED + assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + mock_braendstofpriser.get_prices.assert_not_called() + + +async def test_stations_use_flist( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup completes when the API returns an Flist of stations.""" + mock_braendstofpriser.list_stations.return_value = Flist([TEST_STATION]) + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (_client_error(401), ConfigEntryState.SETUP_ERROR), + (_client_error(500), ConfigEntryState.SETUP_ERROR), + (ProductNotFoundError("missing"), ConfigEntryState.SETUP_ERROR), + ], + ids=["auth_failed", "cannot_connect", "product_not_found"], +) +async def test_setup_error_handling( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup handles API errors during the first refresh.""" + mock_braendstofpriser.get_prices.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state diff --git a/tests/components/fuelprices_dk/test_sensor.py b/tests/components/fuelprices_dk/test_sensor.py new file mode 100644 index 000000000000..fb84bf14a797 --- /dev/null +++ b/tests/components/fuelprices_dk/test_sensor.py @@ -0,0 +1,97 @@ +"""Test sensor platform for Fuelprices.dk.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import TEST_PRICES + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the sensor entities.""" + with patch("homeassistant.components.fuelprices_dk.PLATFORMS", ["sensor"]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_updates( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor updates when the coordinator refreshes.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == "14.29" + + mock_braendstofpriser.get_prices.return_value = { + "station": {"id": 1234, "name": "Aarhus C", "last_update": None}, + "prices": {**TEST_PRICES, "Blyfri95": 15.99}, + } + + freezer.tick(3600) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == "15.99" + + +async def test_sensor_becomes_unavailable_when_product_missing( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a sensor becomes unavailable when its product is not returned.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == "14.29" + + remaining = {k: v for k, v in TEST_PRICES.items() if k != "Blyfri95"} + mock_braendstofpriser.get_prices.return_value = { + "station": {"id": 1234, "name": "Aarhus C", "last_update": None}, + "prices": remaining, + } + + freezer.tick(3600) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == STATE_UNAVAILABLE + + +async def test_sensor_ignores_non_numeric_price( + hass: HomeAssistant, + mock_braendstofpriser: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a sensor reports unknown when the API returns a non-numeric price.""" + await setup_integration(hass, mock_config_entry) + + mock_braendstofpriser.get_prices.return_value = { + "station": {"id": 1234, "name": "Aarhus C", "last_update": None}, + "prices": {**TEST_PRICES, "Blyfri95": "n/a"}, + } + + freezer.tick(3600) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.aarhus_c_blyfri95").state == STATE_UNKNOWN diff --git a/tests/components/fumis/snapshots/test_climate.ambr b/tests/components/fumis/snapshots/test_climate.ambr index fb25e5fe4c48..7b60c8b3f06d 100644 --- a/tests/components/fumis/snapshots/test_climate.ambr +++ b/tests/components/fumis/snapshots/test_climate.ambr @@ -71,8 +71,8 @@ # name: test_climate_entity.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -97,7 +97,6 @@ 'model_id': None, 'name': 'Clou Duo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.6.0', 'via_device_id': None, diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index 20b246609c70..02414fc0750d 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup[Aqua Contour] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'My contour', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.0', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_setup[Timer] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'My timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.2.3', 'via_device_id': None, diff --git a/tests/components/gatus/__init__.py b/tests/components/gatus/__init__.py new file mode 100644 index 000000000000..26e3d9d4d9b1 --- /dev/null +++ b/tests/components/gatus/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Gatus integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Gatus integration.""" + 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/gatus/conftest.py b/tests/components/gatus/conftest.py new file mode 100644 index 000000000000..1e557575e259 --- /dev/null +++ b/tests/components/gatus/conftest.py @@ -0,0 +1,58 @@ +"""Common fixtures for the Gatus tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from gatus_api import EndpointStatus, Result +import pytest + +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gatus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_gatus_client() -> Generator[AsyncMock]: + """Mock the third-party Gatus API client wrapper globally across coordinator and config flow.""" + with ( + patch( + "homeassistant.components.gatus.coordinator.GatusClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.gatus.config_flow.GatusClient", + new=mock_client, + ), + ): + client_instance = mock_client.return_value + client_instance.get_endpoints_statuses = AsyncMock( + return_value=[ + EndpointStatus( + key="backend_service", + name="Backend Service", + group="Core", + results=[Result(success=True, status=200)], + ) + ] + ) + yield client_instance + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Fixture to cleanly create a Gatus configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example.com:8080"}, + entry_id="1234567890abcdef1234567890abcdef", + ) diff --git a/tests/components/gatus/fixtures/group.json b/tests/components/gatus/fixtures/group.json new file mode 100644 index 000000000000..8c7c032441e2 --- /dev/null +++ b/tests/components/gatus/fixtures/group.json @@ -0,0 +1,8 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "group": "Core", + "results": [{ "success": false, "status": 500 }] + } +] diff --git a/tests/components/gatus/fixtures/no_group.json b/tests/components/gatus/fixtures/no_group.json new file mode 100644 index 000000000000..c582a4eb7535 --- /dev/null +++ b/tests/components/gatus/fixtures/no_group.json @@ -0,0 +1,7 @@ +[ + { + "key": "backend_service", + "name": "Backend Service", + "results": [{ "success": true, "status": 200 }] + } +] diff --git a/tests/components/gatus/snapshots/test_binary_sensor.ambr b/tests/components/gatus/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..56d2fc37d30b --- /dev/null +++ b/tests/components/gatus/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.core_backend_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'gatus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890abcdef1234567890abcdef_backend_service', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_setup_and_states[binary_sensor.core_backend_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'Core Backend Service', + }), + 'context': , + 'entity_id': 'binary_sensor.core_backend_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/gatus/test_binary_sensor.py b/tests/components/gatus/test_binary_sensor.py new file mode 100644 index 000000000000..761a2c757a0f --- /dev/null +++ b/tests/components/gatus/test_binary_sensor.py @@ -0,0 +1,164 @@ +"""Tests for the Gatus binary sensor platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from gatus_api import EndpointStatus, GatusClientError, Result +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, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) + + +async def test_binary_sensor_setup_and_states( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and entity snapshots using snapshot_platform.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +def _to_endpoint_statuses(raw_data: list[dict[str, Any]]) -> list[EndpointStatus]: + return [ + EndpointStatus( + key=ep["key"], + name=ep["name"], + group=ep.get("group"), + results=[ + Result(success=r["success"], status=r["status"]) + for r in ep.get("results", []) + ], + ) + for ep in raw_data + ] + + +async def test_binary_sensor_dynamic_update( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the binary sensor entity updates when the mock client returns new data.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_data = await async_load_json_array_fixture(hass, "gatus/group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + freezer.tick(300) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "off" + + +async def test_binary_sensor_no_group( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the binary sensor entity is created correctly when an endpoint has no group.""" + mock_data = await async_load_json_array_fixture(hass, "gatus/no_group.json") + + mock_gatus_client.get_endpoints_statuses.return_value = _to_endpoint_statuses( + mock_data + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "on" + + +async def test_binary_sensor_client_error( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a client exception cleanly marks entities as unavailable.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("binary_sensor.core_backend_service") + assert state is not None + assert state.state == "on" + + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError + + freezer.tick(30) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.core_backend_service") + assert state.state == "unavailable" + + +async def test_binary_sensor_empty_results( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with empty results is treated as unavailable.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "unavailable" + + # Verify underlying properties return None directly on empty results + entity = hass.data["binary_sensor"].get_entity("binary_sensor.backend_service") + assert entity is not None + assert entity.latest_result is None + assert entity.is_on is None + + +async def test_binary_sensor_missing_status( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an endpoint with a result missing a status code is handled correctly.""" + mock_gatus_client.get_endpoints_statuses.return_value = [ + EndpointStatus( + key="backend_service", + name="Backend Service", + group=None, + results=[Result(success=False, status=None)], + ) + ] + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.backend_service") + assert state is not None + assert state.state == "off" diff --git a/tests/components/gatus/test_config_flow.py b/tests/components/gatus/test_config_flow.py new file mode 100644 index 000000000000..45d9bf8c1241 --- /dev/null +++ b/tests/components/gatus/test_config_flow.py @@ -0,0 +1,217 @@ +"""Test the Gatus Config flow.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant import config_entries +from homeassistant.components.gatus.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form, validate the client, and create a successful entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_form_success_with_path( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form, validate the client, and create a successful entry with a sub-path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080/gatus-instance/"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gatus" + assert result["data"] == { + CONF_URL: "http://gatus.example.com:8080/gatus-instance", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_form_failures_and_recovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test handling validation failures and ensuring the flow can completely recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that duplicate configurations for the same base URL abort early.""" + mock_config_entry.add_to_hass(hass) + 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_URL: "http://gatus.example.com:8080"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_flow_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test 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 result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080/"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_URL: "http://gatus.example2.com:8080", + } + + +@pytest.mark.parametrize( + ("side_effect", "error_key"), + [ + (GatusClientError("Cannot connect"), "cannot_connect"), + (Exception("Unexpected backend explosion"), "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gatus_client: AsyncMock, + side_effect: Exception, + error_key: str, +) -> None: + """Test reconfigure flow errors and recover.""" + 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"] == "reconfigure" + + mock_gatus_client.get_endpoints_statuses.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_key} + + mock_gatus_client.get_endpoints_statuses.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example2.com:8080"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_URL: "http://gatus.example2.com:8080", + } + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow aborts if the new URL is already configured.""" + mock_config_entry.add_to_hass(hass) + + other_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://gatus.example3.com:8080"}, + entry_id="other_id", + ) + other_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "http://gatus.example3.com:8080"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/gatus/test_init.py b/tests/components/gatus/test_init.py new file mode 100644 index 000000000000..ebb0f2d772c3 --- /dev/null +++ b/tests/components/gatus/test_init.py @@ -0,0 +1,46 @@ +"""Tests for the Gatus integration setup and unload lifecycle.""" + +from unittest.mock import AsyncMock + +from gatus_api import GatusClientError +import pytest + +from homeassistant.components.gatus.coordinator import GatusDataUpdateCoordinator +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_setup_and_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test standard successful setup and unload cycle of the integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + assert isinstance(mock_config_entry.runtime_data, GatusDataUpdateCoordinator) + + 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 + + +async def test_setup_failure_retry( + hass: HomeAssistant, + mock_gatus_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that an API connection failure during initial setup places the entry in retry state.""" + mock_gatus_client.get_endpoints_statuses.side_effect = GatusClientError( + "Cannot connect to Gatus API during initial setup" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 21c1561484aa..d89232e9365f 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -242,13 +242,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d """Test config entry is removed when the source entity is removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup( generic_hygrostat_config_entry.entry_id ) @@ -266,28 +259,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d hass, generic_hygrostat_entity_entry.entity_id ) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity with patch( "homeassistant.components.generic_hygrostat.async_unload_entry", wraps=generic_hygrostat.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - switch_entity_entry = entity_registry.async_get("switch.test_unique") generic_hygrostat_entity_entry = entity_registry.async_get( "humidifier.my_generic_hygrostat" ) assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id - # Check if the generic_hygrostat config entry is not in the device + # Check that the source device is not removed and the generic_hygrostat config + # entry is not in the device source_device = device_registry.async_get(source_device.id) + assert source_device is not None assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_hygrostat config entry is not removed @@ -541,7 +532,7 @@ async def test_migration_1_1( switch_device: dr.DeviceEntry, switch_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes generic_hygrostat config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" generic_hygrostat_config_entry = MockConfigEntry( data={}, @@ -560,21 +551,12 @@ async def test_migration_1_1( ) generic_hygrostat_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id - ) - - # Check preconditions - switch_device = device_registry.async_get(switch_device.id) - assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id) await hass.async_block_till_done() assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device switch_device = device_registry.async_get(switch_device.id) assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 51e996c22c7c..5ed1c5a1d524 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -247,13 +247,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d """Test config entry is removed when the source entity is removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup( generic_thermostat_config_entry.entry_id ) @@ -271,28 +264,26 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d hass, generic_thermostat_entity_entry.entity_id ) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity with patch( "homeassistant.components.generic_thermostat.async_unload_entry", wraps=generic_thermostat.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() # Check that the helper entity is linked to the expected source device - switch_entity_entry = entity_registry.async_get("switch.test_unique") generic_thermostat_entity_entry = entity_registry.async_get( "climate.my_generic_thermostat" ) assert generic_thermostat_entity_entry.device_id == expected_helper_device_id - # Check if the generic_thermostat config entry is not in the device + # Check that the source device is not removed and the generic_thermostat config + # entry is not in the device source_device = device_registry.async_get(source_device.id) + assert source_device is not None assert generic_thermostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_thermostat config entry is not removed @@ -554,7 +545,7 @@ async def test_migration_1_1( switch_device: dr.DeviceEntry, switch_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes generic_thermostat config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" generic_thermostat_config_entry = MockConfigEntry( data={}, @@ -573,21 +564,12 @@ async def test_migration_1_1( ) generic_thermostat_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id - ) - - # Check preconditions - switch_device = device_registry.async_get(switch_device.id) - assert generic_thermostat_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id) await hass.async_block_till_done() assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device switch_device = device_registry.async_get(switch_device.id) assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries diff --git a/tests/components/gentex_homelink/snapshots/test_init.ambr b/tests/components/gentex_homelink/snapshots/test_init.ambr index d9d52e290bcc..7644cfae437a 100644 --- a/tests/components/gentex_homelink/snapshots/test_init.ambr +++ b/tests/components/gentex_homelink/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'TestDevice', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/google_drive/snapshots/test_sensor.ambr b/tests/components/google_drive/snapshots/test_sensor.ambr index 06df145129b9..870b016d6322 100644 --- a/tests/components/google_drive/snapshots/test_sensor.ambr +++ b/tests/components/google_drive/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensor.10 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://drive.google.com/drive/folders/HA folder ID', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'testuser@domain.com', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 63355f58df32..ff9dc38be179 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'Google AI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': None, 'name': 'Google AI STT', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': None, 'name': 'Google AI TTS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -112,7 +109,6 @@ 'model_id': None, 'name': 'Google AI Task', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 97861c9782ad..1aab55c30e42 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -755,7 +755,7 @@ async def test_migration_from_v1_with_same_keys( ( {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, [], - {"mock_entry_id": {None, "mock_id_1"}}, + {"mock_entry_id": {"mock_id_1"}}, ), # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1: # Wrong device registry, TTS subentry created @@ -770,7 +770,7 @@ async def test_migration_from_v1_with_same_keys( unique_id=None, ) ], - {"mock_entry_id": {None, "mock_id_1"}}, + {"mock_entry_id": {"mock_id_1"}}, ), # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2 # or later: Correct device registry, TTS subentry created diff --git a/tests/components/google_health/__init__.py b/tests/components/google_health/__init__.py new file mode 100644 index 000000000000..8ab7166c02a0 --- /dev/null +++ b/tests/components/google_health/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Health integration.""" diff --git a/tests/components/google_health/conftest.py b/tests/components/google_health/conftest.py new file mode 100644 index 000000000000..783ad31df148 --- /dev/null +++ b/tests/components/google_health/conftest.py @@ -0,0 +1,195 @@ +"""Test fixtures for Google Health.""" + +from collections.abc import Awaitable, Callable, Generator +import time +from typing import Any +from unittest.mock import AsyncMock, patch + +from google_health_api.model import ( + BODY_FAT, + DAILY_RESTING_HEART_RATE, + WEIGHT, + ActiveEnergyBurnedRollupValue, + DailyRollupDataPoint, + DataPoint, + DataType, + DistanceRollupValue, + FloorsRollupValue, + Identity, + ListDataPointResult, + StepsRollupValue, + TotalCaloriesRollupValue, + UserInfo, + _ListDataPointsModel, +) +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_health.const import DOMAIN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_json_object_fixture + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" + + +def _rollup_fixture( + filename: str, rollup_cls: type, field_name: str +) -> DailyRollupDataPoint | None: + """Build the most recent daily rollup data point from a fixture.""" + points = load_json_object_fixture(filename, DOMAIN)["rollupDataPoints"] + if not points: + return None + return DailyRollupDataPoint.from_api_dict(rollup_cls, field_name, points[0]) + + +def _list_fixture(filename: str, data_type: DataType) -> ListDataPointResult: + """Build a list data point result from a fixture.""" + data_points = [ + DataPoint.from_api_dict(data_type, item) + for item in load_json_object_fixture(filename, DOMAIN)["dataPoints"] + ] + return ListDataPointResult(_ListDataPointsModel(data_points=data_points)) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return int(time.time() + 86400) + + +@pytest.fixture +def scopes() -> list[str]: + """Fixture with scopes to set up.""" + return OAUTH_SCOPES + + +@pytest.fixture(name="token_entry") +def mock_token_entry(expires_at: int, scopes: list[str]) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(scopes), + "token_type": "Bearer", + "expires_at": expires_at, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Google Health", + unique_id="mock-health-user-id", + entry_id="01J0BC4QM2YBRP6H5G933CETT7", + data={ + "auth_implementation": DOMAIN, + "token": token_entry, + }, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.google_health.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_google_health_client() -> Generator[AsyncMock]: + """Mock a Google Health client.""" + with ( + patch( + "homeassistant.components.google_health.GoogleHealthApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.google_health.config_flow.GoogleHealthApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.steps = AsyncMock() + client.steps.today.return_value = _rollup_fixture( + "steps.json", StepsRollupValue, "steps" + ) + client.steps.required_read_scopes = [ + "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly" + ] + client.distance = AsyncMock() + client.distance.today.return_value = _rollup_fixture( + "distance.json", DistanceRollupValue, "distance" + ) + client.active_energy_burned = AsyncMock() + client.active_energy_burned.today.return_value = _rollup_fixture( + "active_energy_burned.json", + ActiveEnergyBurnedRollupValue, + "activeEnergyBurned", + ) + client.total_calories = AsyncMock() + client.total_calories.today.return_value = _rollup_fixture( + "total_calories.json", TotalCaloriesRollupValue, "totalCalories" + ) + client.floors = AsyncMock() + client.floors.today.return_value = _rollup_fixture( + "floors.json", FloorsRollupValue, "floors" + ) + client.weight = AsyncMock() + client.weight.list.return_value = _list_fixture("weight.json", WEIGHT) + client.weight.required_read_scopes = [ + "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly" + ] + client.daily_resting_heart_rate = AsyncMock() + client.daily_resting_heart_rate.list.return_value = _list_fixture( + "resting_heart_rate.json", DAILY_RESTING_HEART_RATE + ) + client.body_fat = AsyncMock() + client.body_fat.list.return_value = _list_fixture("body_fat.json", BODY_FAT) + client.get_identity.return_value = Identity.from_dict( + load_json_object_fixture("identity.json", DOMAIN) + ) + client.get_user_info.return_value = UserInfo.from_dict( + load_json_object_fixture("userinfo.json", DOMAIN) + ) + yield client + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_credentials: None, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run diff --git a/tests/components/google_health/fixtures/active_energy_burned.json b/tests/components/google_health/fixtures/active_energy_burned.json new file mode 100644 index 000000000000..f8365250bfe5 --- /dev/null +++ b/tests/components/google_health/fixtures/active_energy_burned.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "activeEnergyBurned": { + "kcalSum": 350.5 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/body_fat.json b/tests/components/google_health/fixtures/body_fat.json new file mode 100644 index 000000000000..76b35c2e197d --- /dev/null +++ b/tests/components/google_health/fixtures/body_fat.json @@ -0,0 +1,12 @@ +{ + "dataPoints": [ + { + "bodyFat": { + "percentage": 18.5, + "sampleTime": { + "physicalTime": "2026-06-29T00:00:00Z" + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/distance.json b/tests/components/google_health/fixtures/distance.json new file mode 100644 index 000000000000..b8f1d4b3b163 --- /dev/null +++ b/tests/components/google_health/fixtures/distance.json @@ -0,0 +1,11 @@ +{ + "rollupDataPoints": [ + { + "distance": { + "millimetersSum": 5000000 + }, + "civilStartTime": { "date": { "year": 2026, "month": 6, "day": 28 } }, + "civilEndTime": { "date": { "year": 2026, "month": 6, "day": 29 } } + } + ] +} diff --git a/tests/components/google_health/fixtures/floors.json b/tests/components/google_health/fixtures/floors.json new file mode 100644 index 000000000000..c507b4c97b10 --- /dev/null +++ b/tests/components/google_health/fixtures/floors.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "floors": { + "countSum": 5 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/identity.json b/tests/components/google_health/fixtures/identity.json new file mode 100644 index 000000000000..f4de595d72ef --- /dev/null +++ b/tests/components/google_health/fixtures/identity.json @@ -0,0 +1,5 @@ +{ + "name": "users/me/identity", + "healthUserId": "mock-health-user-id", + "legacyUserId": "mock-legacy-user-id" +} diff --git a/tests/components/google_health/fixtures/resting_heart_rate.json b/tests/components/google_health/fixtures/resting_heart_rate.json new file mode 100644 index 000000000000..37f7345f4bed --- /dev/null +++ b/tests/components/google_health/fixtures/resting_heart_rate.json @@ -0,0 +1,10 @@ +{ + "dataPoints": [ + { + "dailyRestingHeartRate": { + "beatsPerMinute": 65, + "date": { "year": 2026, "month": 6, "day": 29 } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/steps.json b/tests/components/google_health/fixtures/steps.json new file mode 100644 index 000000000000..9a0a1e44ee2e --- /dev/null +++ b/tests/components/google_health/fixtures/steps.json @@ -0,0 +1,11 @@ +{ + "rollupDataPoints": [ + { + "steps": { + "countSum": 10500 + }, + "civilStartTime": { "date": { "year": 2026, "month": 6, "day": 28 } }, + "civilEndTime": { "date": { "year": 2026, "month": 6, "day": 29 } } + } + ] +} diff --git a/tests/components/google_health/fixtures/total_calories.json b/tests/components/google_health/fixtures/total_calories.json new file mode 100644 index 000000000000..f78675d39388 --- /dev/null +++ b/tests/components/google_health/fixtures/total_calories.json @@ -0,0 +1,23 @@ +{ + "rollupDataPoints": [ + { + "totalCalories": { + "kcalSum": 2100.2 + }, + "civilStartTime": { + "date": { + "year": 2026, + "month": 6, + "day": 28 + } + }, + "civilEndTime": { + "date": { + "year": 2026, + "month": 6, + "day": 29 + } + } + } + ] +} diff --git a/tests/components/google_health/fixtures/userinfo.json b/tests/components/google_health/fixtures/userinfo.json new file mode 100644 index 000000000000..20eab08c05de --- /dev/null +++ b/tests/components/google_health/fixtures/userinfo.json @@ -0,0 +1,5 @@ +{ + "sub": "mock-sub", + "given_name": "Allen", + "name": "Allen Porter" +} diff --git a/tests/components/google_health/fixtures/weight.json b/tests/components/google_health/fixtures/weight.json new file mode 100644 index 000000000000..a651ae872b04 --- /dev/null +++ b/tests/components/google_health/fixtures/weight.json @@ -0,0 +1,12 @@ +{ + "dataPoints": [ + { + "weight": { + "weightGrams": 80000.0, + "sampleTime": { + "physicalTime": "2026-06-29T00:00:00Z" + } + } + } + ] +} diff --git a/tests/components/google_health/snapshots/test_sensor.ambr b/tests/components/google_health/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..6dbd4ffb0cdb --- /dev/null +++ b/tests/components/google_health/snapshots/test_sensor.ambr @@ -0,0 +1,440 @@ +# serializer version: 1 +# name: test_all_entities[sensor.google_health_active_calories-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_active_calories', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active calories', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active calories', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_calories', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_active_calories', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_active_calories-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Active calories', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_active_calories', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '350.5', + }) +# --- +# name: test_all_entities[sensor.google_health_body_fat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_body_fat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Body fat', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Body fat', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'body_fat', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_body_fat', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.google_health_body_fat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Body fat', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.google_health_body_fat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_all_entities[sensor.google_health_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Distance', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'distance', + : 'Google Health Distance', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000.0', + }) +# --- +# name: test_all_entities[sensor.google_health_floors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_floors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floors', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floors', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_floors', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.google_health_floors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Floors', + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_floors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[sensor.google_health_resting_heart_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_resting_heart_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Resting heart rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Resting heart rate', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'resting_heart_rate', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_resting_heart_rate', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_all_entities[sensor.google_health_resting_heart_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Resting heart rate', + : , + : 'bpm', + }), + 'context': , + 'entity_id': 'sensor.google_health_resting_heart_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_all_entities[sensor.google_health_steps-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_steps', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Steps', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steps', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'steps', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_steps', + 'unit_of_measurement': 'steps', + }) +# --- +# name: test_all_entities[sensor.google_health_steps-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Steps', + : , + : 'steps', + }), + 'context': , + 'entity_id': 'sensor.google_health_steps', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10500', + }) +# --- +# name: test_all_entities[sensor.google_health_total_calories-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_total_calories', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total calories', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total calories', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_calories', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_total_calories', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_total_calories-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Google Health Total calories', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_total_calories', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2100.2', + }) +# --- +# name: test_all_entities[sensor.google_health_weight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.google_health_weight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Weight', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weight', + 'platform': 'google_health', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_weight', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.google_health_weight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'weight', + : 'Google Health Weight', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.google_health_weight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- diff --git a/tests/components/google_health/test_config_flow.py b/tests/components/google_health/test_config_flow.py new file mode 100644 index 000000000000..2ac6eaf3cde7 --- /dev/null +++ b/tests/components/google_health/test_config_flow.py @@ -0,0 +1,401 @@ +"""Test the Google Health config flow.""" + +from unittest.mock import AsyncMock, patch + +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, +) +from google_health_api.model import Identity +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_health.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + OAUTH_SCOPES, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +API_BASE_URL = "https://health.googleapis.com/v4/users/me" +IDENTITY_URL = f"{API_BASE_URL}/identity" +USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.mark.usefixtures( + "current_request_with_host", + "mock_setup_entry", + "setup_credentials", + "mock_google_health_client", +) +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": 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}" + "&scope=https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly" + "+https://www.googleapis.com/auth/googlehealth.profile.readonly" + "+https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline" + "&prompt=consent" + ) + + 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, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Allen" + assert result["result"].unique_id == "mock-health-user-id" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_missing_profile_scope( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test config flow aborts if profile read scope is missing from token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + + 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 + + # Return a token containing only the activity scope (missing profile scope) + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_profile_scope" + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_get_identity_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test config flow aborts if get_identity raises an API error.""" + mock_google_health_client.get_identity.side_effect = GoogleHealthApiError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_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() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test config flow aborts if the Google Health API is not enabled.""" + mock_google_health_client.get_identity.side_effect = HealthApiForbiddenException + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_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() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "api_not_enabled" + assert result["description_placeholders"] == { + "url": "https://console.developers.google.com/apis/api/health.googleapis.com/overview" + } + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_missing_health_user_id( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test config flow aborts if identity does not contain healthUserId.""" + mock_google_health_client.get_identity.return_value = Identity( + name="users/me/identity", health_user_id="" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_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() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures( + "current_request_with_host", "mock_setup_entry", "setup_credentials" +) +async def test_config_flow_profile_name_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_google_health_client: AsyncMock, +) -> None: + """Test flow completes with default title if fetching profile userinfo fails.""" + mock_google_health_client.get_user_info.side_effect = GoogleHealthApiError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_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() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Google Health" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test reauth flow completes successfully.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": "google", + "token": { + "access_token": "old-access-token", + "refresh_token": "old-refresh-token", + "scope": " ".join(OAUTH_SCOPES), + }, + }, + unique_id="mock-health-user-id", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + + 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={} + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + + 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() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), + }, + ) + + aioclient_mock.get( + IDENTITY_URL, + json={ + "name": "users/me/identity", + "healthUserId": "mock-health-user-id", + }, + ) + + aioclient_mock.get( + USERINFO_URL, + json={ + "givenName": "Allen", + "name": "Allen Porter", + }, + ) + + with patch( + "homeassistant.components.google_health.async_setup_entry", return_value=True + ) as mock_setup: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + assert config_entry.data["token"]["access_token"] == "new-access-token" + assert config_entry.data["token"]["refresh_token"] == "new-refresh-token" + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/google_health/test_init.py b/tests/components/google_health/test_init.py new file mode 100644 index 000000000000..766d26d1c209 --- /dev/null +++ b/tests/components/google_health/test_init.py @@ -0,0 +1,201 @@ +"""Tests for Google Health integration lifecycle (init/unloading).""" + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from google_health_api.exceptions import ( + GoogleHealthApiError, + HealthApiForbiddenException, + HealthAuthException, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_health.coordinator import POLLING_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("mock_google_health_client") +async def test_setup_and_unload( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test standard setup and unloading of the config entry.""" + assert await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + assert hass.states.get("sensor.google_health_steps") is not None + assert hass.states.get("sensor.google_health_distance") is not None + assert hass.states.get("sensor.google_health_weight") is not None + assert hass.states.get("sensor.google_health_resting_heart_rate") is not None + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_setup_api_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_google_health_client: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup error retry handling when API fails.""" + mock_google_health_client.steps.today.side_effect = GoogleHealthApiError + + assert not await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_setup_auth_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_google_health_client: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup error when API returns auth or forbidden errors.""" + mock_google_health_client.steps.today.side_effect = HealthApiForbiddenException + + assert not await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.usefixtures("mock_google_health_client") +@pytest.mark.parametrize( + "scopes", ["https://www.googleapis.com/auth/health.activity_and_fitness.readonly"] +) +async def test_setup_missing_scopes( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup fails if token has missing profile scope.""" + assert not await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +@pytest.mark.usefixtures("mock_google_health_client") +@pytest.mark.parametrize( + "scopes", + [ + [ + "https://www.googleapis.com/auth/googlehealth.profile.readonly", + "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", + ] + ], +) +async def test_setup_missing_activity_scope( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup succeeds but steps sensor is not added if activity scope is missing.""" + assert await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + assert hass.states.get("sensor.google_health_steps") is None + assert hass.states.get("sensor.google_health_distance") is None + assert hass.states.get("sensor.google_health_active_calories") is None + assert hass.states.get("sensor.google_health_total_calories") is None + assert hass.states.get("sensor.google_health_floors") is None + + assert hass.states.get("sensor.google_health_weight") is not None + assert hass.states.get("sensor.google_health_resting_heart_rate") is not None + assert hass.states.get("sensor.google_health_body_fat") is not None + + +@pytest.mark.usefixtures("mock_google_health_client") +@pytest.mark.parametrize( + "scopes", + [ + [ + "https://www.googleapis.com/auth/googlehealth.profile.readonly", + "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", + ] + ], +) +async def test_setup_missing_measurements_scope( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test setup succeeds but weight sensor is not added if measurements scope is missing.""" + assert await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + assert hass.states.get("sensor.google_health_weight") is None + assert hass.states.get("sensor.google_health_resting_heart_rate") is None + assert hass.states.get("sensor.google_health_body_fat") is None + + assert hass.states.get("sensor.google_health_steps") is not None + assert hass.states.get("sensor.google_health_distance") is not None + assert hass.states.get("sensor.google_health_active_calories") is not None + assert hass.states.get("sensor.google_health_total_calories") is not None + assert hass.states.get("sensor.google_health_floors") is not None + + +async def test_setup_oauth_implementation_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.google_health.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_google_health_client") +async def test_runtime_auth_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + mock_google_health_client: AsyncMock, +) -> None: + """Test runtime auth failure triggers a reauth flow.""" + # Setup the integration + assert await integration_setup() + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + # Mock an authorization error on subsequent update refresh + mock_google_health_client.steps.today.side_effect = HealthAuthException( + "Token expired" + ) + + # Trigger update by advancing time + async_fire_time_changed( + hass, + dt_util.utcnow() + POLLING_INTERVAL + timedelta(seconds=1), + ) + await hass.async_block_till_done() + # Yield to let untracked asyncio.gather tasks run + await asyncio.sleep(0) + await hass.async_block_till_done() + + # Verify that the flow was initiated + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/google_health/test_sensor.py b/tests/components/google_health/test_sensor.py new file mode 100644 index 000000000000..f14a8017c907 --- /dev/null +++ b/tests/components/google_health/test_sensor.py @@ -0,0 +1,63 @@ +"""Tests for Google Health sensor platform.""" + +from collections.abc import Awaitable, Callable +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("mock_google_health_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test all sensor entities.""" + with patch("homeassistant.components.google_health._PLATFORMS", [Platform.SENSOR]): + assert await integration_setup() + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_sensor_empty_rollup( + hass: HomeAssistant, + mock_google_health_client: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test rollup sensors when the rollup endpoints return no data.""" + mock_google_health_client.steps.today.return_value = None + mock_google_health_client.distance.today.return_value = None + mock_google_health_client.active_energy_burned.today.return_value = None + mock_google_health_client.total_calories.today.return_value = None + mock_google_health_client.floors.today.return_value = None + + assert await integration_setup() + + steps_state = hass.states.get("sensor.google_health_steps") + assert steps_state is not None + assert steps_state.state == "0" + + distance_state = hass.states.get("sensor.google_health_distance") + assert distance_state is not None + assert distance_state.state == "0.0" + + active_calories_state = hass.states.get("sensor.google_health_active_calories") + assert active_calories_state is not None + assert active_calories_state.state == "0.0" + + total_calories_state = hass.states.get("sensor.google_health_total_calories") + assert total_calories_state is not None + assert total_calories_state.state == "0.0" + + floors_state = hass.states.get("sensor.google_health_floors") + assert floors_state is not None + assert floors_state.state == "0" diff --git a/tests/components/gpslogger/__init__.py b/tests/components/gpslogger/__init__.py index 636a9a767f95..822253c30a11 100644 --- a/tests/components/gpslogger/__init__.py +++ b/tests/components/gpslogger/__init__.py @@ -1 +1 @@ -"""Tests for the GPSLogger component.""" +"""Tests for the GPSLogger integration.""" diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 4755c9031239..a172debf065b 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -64,7 +64,7 @@ async def setup_zones(hass: HomeAssistant) -> None: @pytest.fixture async def webhook_id(hass: HomeAssistant, gpslogger_client: TestClient) -> str: - """Initialize the GPSLogger component and get the webhook_id.""" + """Initialize the GPSLogger integration and get the webhook_id.""" await async_process_ha_core_config( hass, {"internal_url": "http://example.local:8123"}, diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 2d7d974e5cdd..c5fd7f80825c 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -32,13 +32,31 @@ 'none', 'sleep', ]), - : , - : 'off', + : , + : 'default', + : list([ + 'default', + 'full_swing', + 'left', + 'left_center', + 'center', + 'right_center', + 'right', + ]), + : 'default', : list([ - 'off', - 'vertical', - 'horizontal', - 'both', + 'default', + 'full_swing', + 'fixed_upper', + 'fixed_upper_middle', + 'fixed_middle', + 'fixed_lower_middle', + 'fixed_lower', + 'swing_upper', + 'swing_upper_middle', + 'swing_middle', + 'swing_lower_middle', + 'swing_lower', ]), : 1, : 25, @@ -85,11 +103,28 @@ 'none', 'sleep', ]), + : list([ + 'default', + 'full_swing', + 'left', + 'left_center', + 'center', + 'right_center', + 'right', + ]), : list([ - 'off', - 'vertical', - 'horizontal', - 'both', + 'default', + 'full_swing', + 'fixed_upper', + 'fixed_upper_middle', + 'fixed_middle', + 'fixed_lower_middle', + 'fixed_lower', + 'swing_upper', + 'swing_upper_middle', + 'swing_middle', + 'swing_lower_middle', + 'swing_lower', ]), : 1, }), @@ -117,8 +152,8 @@ 'platform': 'gree', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'climate', 'unique_id': 'aabbcc112233', 'unit_of_measurement': None, }), diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index d38044e9bdf2..539bb6b923d3 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -20,6 +20,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, @@ -34,18 +35,17 @@ from homeassistant.components.climate import ( SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - SWING_BOTH, - SWING_HORIZONTAL, - SWING_OFF, - SWING_VERTICAL, HVACMode, ) from homeassistant.components.gree.climate import ( - FAN_MODES_REVERSE, + FAN_MODES_INVERSE, + HORIZONTAL_SWING_MODES_INVERSE, HVAC_MODES, - HVAC_MODES_REVERSE, + HVAC_MODES_INVERSE, + VERTICAL_SWING_MODES_INVERSE, GreeClimateEntity, ) from homeassistant.components.gree.const import ( @@ -425,7 +425,7 @@ async def test_send_target_temperature( hass.config.units = units device().power = True - device().mode = HVAC_MODES_REVERSE.get(HVACMode.AUTO) + device().mode = HVAC_MODES_INVERSE.get(HVACMode.AUTO) fake_device = device() if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: @@ -700,7 +700,7 @@ async def test_update_hvac_mode( ) -> None: """Test for updating hvac mode from the device.""" device().power = hvac_mode != HVACMode.OFF - device().mode = HVAC_MODES_REVERSE.get(hvac_mode) + device().mode = HVAC_MODES_INVERSE.get(hvac_mode) await async_setup_gree(hass) @@ -778,7 +778,7 @@ async def test_update_fan_mode( hass: HomeAssistant, discovery, device, fan_mode ) -> None: """Test for updating fan mode from the device.""" - device().fan_speed = FAN_MODES_REVERSE.get(fan_mode) + device().fan_speed = FAN_MODES_INVERSE.get(fan_mode) await async_setup_gree(hass) @@ -788,10 +788,11 @@ async def test_update_fan_mode( @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "swing_mode", + ["default", "full_swing", "fixed_upper", "fixed_lower"], ) async def test_send_swing_mode( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, swing_mode: str ) -> None: """Test for sending swing mode command to the device.""" await async_setup_gree(hass) @@ -826,10 +827,11 @@ async def test_send_invalid_swing_mode(hass: HomeAssistant, discovery, device) - @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "swing_mode", + ["default", "full_swing", "fixed_upper", "fixed_lower"], ) async def test_send_swing_mode_device_timeout( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, swing_mode: str ) -> None: """Test for sending swing mode command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -849,28 +851,134 @@ async def test_send_swing_mode_device_timeout( @pytest.mark.parametrize( - "swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL] + "vertical_swing", + [VerticalSwing.Default, VerticalSwing.FullSwing, VerticalSwing.FixedUpper], ) async def test_update_swing_mode( - hass: HomeAssistant, discovery, device, swing_mode + hass: HomeAssistant, discovery, device, vertical_swing: VerticalSwing ) -> None: """Test for updating swing mode from the device.""" - device().horizontal_swing = ( - HorizontalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_HORIZONTAL) - else HorizontalSwing.Default - ) - device().vertical_swing = ( - VerticalSwing.FullSwing - if swing_mode in (SWING_BOTH, SWING_VERTICAL) - else VerticalSwing.Default - ) + device().vertical_swing = vertical_swing await async_setup_gree(hass) state = hass.states.get(ENTITY_ID) assert state is not None - assert state.attributes.get(ATTR_SWING_MODE) == swing_mode + assert ( + state.attributes.get(ATTR_SWING_MODE) + == VERTICAL_SWING_MODES_INVERSE[vertical_swing] + ) + + +@pytest.mark.parametrize( + "swing_horizontal_mode", + ["default", "full_swing", "left", "right"], +) +async def test_send_swing_horizontal_mode( + hass: HomeAssistant, discovery, device, swing_horizontal_mode: str +) -> None: + """Test for sending horizontal swing mode command to the device.""" + await async_setup_gree(hass) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == swing_horizontal_mode + + +async def test_send_invalid_swing_horizontal_mode( + hass: HomeAssistant, discovery, device +) -> None: + """Test for sending an invalid horizontal swing mode command to the device.""" + await async_setup_gree(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: "invalid"}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) != "invalid" + + +@pytest.mark.parametrize( + "swing_horizontal_mode", + ["default", "full_swing", "left", "right"], +) +async def test_send_swing_horizontal_mode_device_timeout( + hass: HomeAssistant, discovery, device, swing_horizontal_mode: str +) -> None: + """Test for sending horizontal swing mode command to the device with a device timeout.""" + device().push_state_update.side_effect = DeviceTimeoutError + + await async_setup_gree(hass) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == swing_horizontal_mode + + +@pytest.mark.parametrize( + "horizontal_swing", + [HorizontalSwing.Default, HorizontalSwing.FullSwing, HorizontalSwing.Left], +) +async def test_update_swing_horizontal_mode( + hass: HomeAssistant, discovery, device, horizontal_swing: HorizontalSwing +) -> None: + """Test for updating horizontal swing mode from the device.""" + device().horizontal_swing = horizontal_swing + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert ( + state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) + == HORIZONTAL_SWING_MODES_INVERSE[horizontal_swing] + ) + + +async def test_swing_mode_unknown_device_value( + hass: HomeAssistant, discovery, device +) -> None: + """Test that an out-of-range vertical swing value from the device returns None.""" + device().vertical_swing = 99 + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_MODE) is None + + +async def test_swing_horizontal_mode_unknown_device_value( + hass: HomeAssistant, discovery, device +) -> None: + """Test that an out-of-range horizontal swing value from the device returns None.""" + device().horizontal_swing = 99 + + await async_setup_gree(hass) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) is None async def test_coordinator_update_handler( diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 8381079c728a..66d43dd74948 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -475,7 +475,6 @@ EVENT_ATTRS = [{"event_types": []}, {"event_type": None}] FAN_ATTRS = [{"supported_features": 0}, {}] LIGHT_ATTRS = [ { - "icon": "mdi:lightbulb-group", "supported_color_modes": ["onoff"], "supported_features": 0, }, diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 50fcdeb1f5e4..a04e2ddc55f6 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1999,6 +1999,59 @@ async def test_setup_and_remove_config_entry( assert entity_registry.async_get(f"{group_type}.bed_room") is None +async def test_groups_with_entity(hass: HomeAssistant) -> None: + """Test groups_with_entity finds legacy groups.""" + assert await async_setup_component( + hass, + "group", + {"group": {"living_room": {"entities": ["light.one", "light.two"]}}}, + ) + await hass.async_block_till_done() + + assert group.groups_with_entity(hass, "light.one") == ["group.living_room"] + assert group.groups_with_entity(hass, "light.three") == [] + + +@pytest.mark.parametrize( + ("group_type", "member_state", "extra_options"), + [ + pytest.param("light", "on", {"all": False}, id="light"), + pytest.param("lock", "locked", {}, id="lock"), + ], +) +async def test_groups_with_entity_config_entry( + hass: HomeAssistant, + group_type: str, + member_state: str, + extra_options: dict[str, Any], +) -> None: + """Test groups_with_entity finds config entry groups.""" + members = [f"{group_type}.one", f"{group_type}.two"] + + for member in members: + hass.states.async_set(member, member_state, {}) + + group_config_entry = MockConfigEntry( + data={}, + domain=group.DOMAIN, + options={ + "entities": members, + "group_type": group_type, + "name": "Bed Room", + **extra_options, + }, + title="Bed Room", + ) + group_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(group_config_entry.entry_id) + await hass.async_block_till_done() + + assert group.groups_with_entity(hass, f"{group_type}.one") == [ + f"{group_type}.bed_room" + ] + assert group.groups_with_entity(hass, f"{group_type}.three") == [] + + @pytest.mark.parametrize( ("hide_members", "hidden_by_initial", "hidden_by"), [ diff --git a/tests/components/growatt_server/snapshots/test_init.ambr b/tests/components/growatt_server/snapshots/test_init.ambr index b1e65ceefadf..4aa7050670b8 100644 --- a/tests/components/growatt_server/snapshots/test_init.ambr +++ b/tests/components/growatt_server/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_classic_api_setup DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'TLX123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'TLX123456', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'MIN123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'MIN123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/harbor/__init__.py b/tests/components/harbor/__init__.py new file mode 100644 index 000000000000..592e9ed1a213 --- /dev/null +++ b/tests/components/harbor/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Harbor integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the Harbor integration in Home Assistant.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/harbor/conftest.py b/tests/components/harbor/conftest.py new file mode 100644 index 000000000000..09b59dc3d250 --- /dev/null +++ b/tests/components/harbor/conftest.py @@ -0,0 +1,118 @@ +"""Common fixtures for the Harbor tests.""" + +from collections.abc import Awaitable, Callable, Generator +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.harbor.const import ( + CONF_CERT_PEM, + CONF_KEY_PEM, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.const import CONF_IP_ADDRESS + +from tests.common import MockConfigEntry + +SERIAL = "1234567890" +CERT_PEM = "-----BEGIN CERTIFICATE-----\nMIIBdummy\n-----END CERTIFICATE-----" +KEY_PEM = "-----BEGIN PRIVATE KEY-----\nMIIBdummy\n-----END PRIVATE KEY-----" + +HEARTBEAT_TOPIC = f"cameras/{SERIAL}/events/heartbeat" +LIVEKIT_TOPIC = f"cameras/{SERIAL}/events/local_livekit_heartbeat" + +HEARTBEAT_PAYLOAD: dict[str, Any] = { + "temperature": 98.6, + "os_version": "1.2.3", + "settings": {"preference_display_name": "Nursery"}, +} +LIVEKIT_PAYLOAD: dict[str, Any] = { + "bitrate": 1234.5, + "network_bars": 3, + "stream_quality": "GOOD", + "viewers_by_identity_full": { + "viewer-1": {"identity": "alice"}, + "viewer-2": {"identity": "bob"}, + }, + "os_version": "1.2.3", + "app_version": "4.5.6", +} + + +def connection_callback( + mock_mqtt_client: AsyncMock, +) -> Callable[[bool], Awaitable[None]]: + """Return the on_connection_change callback the integration registered.""" + return mock_mqtt_client.call_args.kwargs["on_connection_change"] + + +async def emit_message( + mock_mqtt_client: AsyncMock, topic: str, payload: dict[str, Any] +) -> None: + """Deliver an MQTT message through the handler the integration registered.""" + await mock_mqtt_client.call_args.kwargs["message_handler"](topic, payload) + + +async def set_connected(mock_mqtt_client: AsyncMock, connected: bool) -> None: + """Drive the MQTT connection state the integration observes.""" + await connection_callback(mock_mqtt_client)(connected) + + +@pytest.fixture(autouse=True) +def mock_connect_timeout() -> Generator[None]: + """Patch the connect timeout so unreachable-camera tests run quickly.""" + with patch("homeassistant.components.harbor.coordinator.CONNECT_TIMEOUT", 0): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.harbor.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mqtt_client() -> Generator[AsyncMock]: + """Mock the Harbor MQTT client, reporting a successful connection on start.""" + with patch( + "homeassistant.components.harbor.coordinator.HarborMQTTClient", + autospec=True, + ) as mock_client: + + async def _start() -> None: + await set_connected(mock_client, True) + # Setup waits for the first device message too; simulate the + # initial-commands response landing right after connect, the + # same way a real camera answers before any explicit test + # message. Empty so it doesn't set values tests don't expect. + await mock_client.call_args.kwargs["message_handler"](HEARTBEAT_TOPIC, {}) + + mock_client.return_value.start.side_effect = _start + # The config flow probes get-settings for the camera's friendly name; + # default to an unnamed camera so the title falls back to the serial. + mock_client.return_value.get_settings.return_value = SimpleNamespace( + settings=SimpleNamespace(preference_display_name=None) + ) + yield mock_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock Harbor config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=SERIAL, + title=f"Camera {SERIAL}", + data={ + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) diff --git a/tests/components/harbor/snapshots/test_init.ambr b/tests/components/harbor/snapshots/test_init.ambr new file mode 100644 index 000000000000..101e73248cb6 --- /dev/null +++ b/tests/components/harbor/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device_registry + 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( + 'harbor', + '1234567890', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Harbor', + 'model': 'Harbor Camera', + 'model_id': None, + 'name': 'Nursery', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'sw_version': '1.2.3', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/harbor/snapshots/test_sensor.ambr b/tests/components/harbor/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..b24e5b1e26de --- /dev/null +++ b/tests/components/harbor/snapshots/test_sensor.ambr @@ -0,0 +1,289 @@ +# serializer version: 1 +# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_bitrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bitrate', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bitrate', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bitrate', + 'unique_id': '1234567890_bitrate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'data_rate', + : 'Harbor Camera 1234567890 Bitrate', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_bitrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.5', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'excellent', + 'fair', + 'good', + 'poor', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_stream_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stream quality', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Stream quality', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stream_quality', + 'unique_id': '1234567890_stream_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Harbor Camera 1234567890 Stream quality', + : list([ + 'excellent', + 'fair', + 'good', + 'poor', + ]), + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_stream_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.harbor_camera_1234567890_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Harbor Camera 1234567890 Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.0', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_viewers-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.harbor_camera_1234567890_viewers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Viewers', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Viewers', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'num_viewers', + 'unique_id': '1234567890_num_viewers', + 'unit_of_measurement': 'viewers', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_viewers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Viewers', + : , + : 'viewers', + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_viewers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'harbor', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': '1234567890_wifi_strength', + 'unit_of_measurement': 'bars', + }) +# --- +# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Harbor Camera 1234567890 Wi-Fi strength', + : , + : 'bars', + }), + 'context': , + 'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/harbor/test_config_flow.py b/tests/components/harbor/test_config_flow.py new file mode 100644 index 000000000000..238d2a662749 --- /dev/null +++ b/tests/components/harbor/test_config_flow.py @@ -0,0 +1,231 @@ +"""Test the Harbor config flow.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.harbor.const import ( + CONF_CERT_PEM, + CONF_KEY_PEM, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import CERT_PEM, KEY_PEM, SERIAL, set_connected + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_mqtt_client") +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mqtt_client: AsyncMock, +) -> None: + """Test the full user flow creates an entry.""" + 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_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Camera {SERIAL}" + assert result["result"].unique_id == SERIAL + assert result["data"] == { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + } + client_id = mock_mqtt_client.call_args.kwargs["client_id"] + assert client_id.startswith(f"{DOMAIN}-{SERIAL}-probe-") + assert client_id != f"{DOMAIN}-{SERIAL}-probe" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_uses_friendly_name( + hass: HomeAssistant, mock_mqtt_client: AsyncMock +) -> None: + """Test the entry is titled with the camera's friendly name when set.""" + mock_mqtt_client.return_value.get_settings.return_value = SimpleNamespace( + settings=SimpleNamespace(preference_display_name="Nursery") + ) + + 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_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nursery" + + +@pytest.mark.parametrize( + ("user_input", "error_field", "error"), + [ + ( + { + CONF_SERIAL: "123", + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_SERIAL, + "invalid_serial", + ), + ( + { + CONF_SERIAL: "abcdefghij", + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_SERIAL, + "invalid_serial", + ), + ( + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: "not a cert", + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_CERT_PEM, + "invalid_cert", + ), + ( + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: "not a key", + CONF_IP_ADDRESS: "192.168.1.10", + }, + CONF_KEY_PEM, + "invalid_key", + ), + ], + ids=["short_serial", "non_digit_serial", "bad_cert", "bad_key"], +) +@pytest.mark.usefixtures("mock_mqtt_client", "mock_setup_entry") +async def test_user_flow_validation_errors( + hass: HomeAssistant, + user_input: dict[str, str], + error_field: str, + error: str, +) -> None: + """Test validation errors are surfaced and recoverable.""" + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {error_field: error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the flow aborts when the serial 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_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_mqtt_client: AsyncMock, +) -> None: + """Test the flow shows an error and recovers when the camera is unreachable.""" + # Start the probe client without ever reporting a successful connection. + mock_mqtt_client.return_value.start.side_effect = None + + 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_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # A subsequent connection succeeds and the entry is created. + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + + mock_mqtt_client.return_value.start.side_effect = _start + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SERIAL: SERIAL, + CONF_CERT_PEM: CERT_PEM, + CONF_KEY_PEM: KEY_PEM, + CONF_IP_ADDRESS: "192.168.1.10", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/harbor/test_init.py b/tests/components/harbor/test_init.py new file mode 100644 index 000000000000..ef0d69148f2a --- /dev/null +++ b/tests/components/harbor/test_init.py @@ -0,0 +1,144 @@ +"""Test the Harbor integration setup and coordinator.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.harbor.const import DOMAIN +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 . import setup_integration +from .conftest import ( + HEARTBEAT_PAYLOAD, + HEARTBEAT_TOPIC, + SERIAL, + emit_message, + set_connected, +) + +from tests.common import MockConfigEntry + +# The default test fixture reports no device data on connect, so the device +# keeps its placeholder name and the entity id derives from that. +_SENSOR = "sensor.harbor_camera_1234567890_temperature" + + +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test a config entry loads, starts the client, and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_mqtt_client.return_value.start.called + + 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 + assert mock_mqtt_client.return_value.stop.called + + +async def test_setup_uses_instance_scoped_mqtt_client_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup uses an MQTT client id unique to this HA instance.""" + await setup_integration(hass, mock_config_entry) + + client_id = mock_mqtt_client.call_args.kwargs["client_id"] + + assert client_id.startswith(f"{DOMAIN}-") + assert client_id.endswith(f"-{SERIAL}") + assert client_id != f"{DOMAIN}-{SERIAL}" + + +async def test_setup_retry_when_unreachable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup is retried when the camera never connects.""" + # Start the client without ever reporting a successful connection. + mock_mqtt_client.return_value.start.side_effect = None + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_mqtt_client.return_value.stop.called + + +async def test_setup_retry_when_no_data_arrives( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test setup is retried when the camera connects but never sends data.""" + + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + + mock_mqtt_client.return_value.start.side_effect = _start + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_mqtt_client.return_value.stop.called + + +async def test_availability_follows_connection( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test entity availability tracks the MQTT connection.""" + await setup_integration(hass, mock_config_entry) + + # Setup waits for the first device message, so entities start available. + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + # A repeated connected signal is a no-op and keeps entities available. + await set_connected(mock_mqtt_client, True) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + # Losing the connection flips entities back to unavailable. + await set_connected(mock_mqtt_client, False) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state == STATE_UNAVAILABLE + + # Reconnecting restores availability without needing fresh device data. + await set_connected(mock_mqtt_client, True) + await hass.async_block_till_done() + assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the device adopts the name and firmware from the first message. + + Setup waits for that first message before registering entities, so the + device is correct from the start instead of needing a later reload. + """ + + async def _start() -> None: + await set_connected(mock_mqtt_client, True) + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + + mock_mqtt_client.return_value.start.side_effect = _start + + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)}) + assert device == snapshot diff --git a/tests/components/harbor/test_sensor.py b/tests/components/harbor/test_sensor.py new file mode 100644 index 000000000000..79502050034a --- /dev/null +++ b/tests/components/harbor/test_sensor.py @@ -0,0 +1,93 @@ +"""Test the Harbor sensors.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import ( + HEARTBEAT_PAYLOAD, + HEARTBEAT_TOPIC, + LIVEKIT_PAYLOAD, + LIVEKIT_TOPIC, + emit_message, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Harbor sensors report their values.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_missing_values_are_unknown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test sensors without a value in the payload report unknown.""" + await setup_integration(hass, mock_config_entry) + + # Only the heartbeat arrives; sensors fed by the LiveKit message stay unknown. + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.harbor_camera_1234567890_temperature").state == "37.0" + ) + assert ( + hass.states.get("sensor.harbor_camera_1234567890_bitrate").state + == STATE_UNKNOWN + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_unexpected_enum_value_stays_valid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mqtt_client: AsyncMock, +) -> None: + """Test a stream quality outside the declared options surfaces as unknown. + + The library maps unrecognized enum values onto its own "unknown" member; + the sensor treats that as no value rather than exposing "unknown" as a + literal enum option. + """ + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.harbor_camera_1234567890_stream_quality" + + await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD) + await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == "good" + + # The camera reports a stream quality outside the known set. + await emit_message( + mock_mqtt_client, + LIVEKIT_TOPIC, + {**LIVEKIT_PAYLOAD, "stream_quality": "DEGRADED"}, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/components/harman_luxury/__init__.py b/tests/components/harman_luxury/__init__.py new file mode 100644 index 000000000000..13b41935398c --- /dev/null +++ b/tests/components/harman_luxury/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Harman Luxury integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Harman Luxury integration in Home Assistant.""" + 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/harman_luxury/conftest.py b/tests/components/harman_luxury/conftest.py new file mode 100644 index 000000000000..f680515edf47 --- /dev/null +++ b/tests/components/harman_luxury/conftest.py @@ -0,0 +1,86 @@ +"""Common fixtures for the Harman Luxury tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aioharmanluxury import DeviceInfo, HarmanLuxuryState +import pytest + +from homeassistant.components.harman_luxury.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MANUFACTURER, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_SERIAL = "48b782c2-60ac-40c1-a169-8d7ccb81dcc1" +TEST_NAME = "Dining Room" + +DEVICE_INFO = DeviceInfo( + serial=TEST_SERIAL, + model="ARCAM ST5", + name=TEST_NAME, + mac="02:FE:6C:B7:EB:59", +) + +PLAYER_STATE = HarmanLuxuryState( + online=True, + volume=45, + muted=False, + play_state="playing", + title="Necessary Evil", + artist="Motionless In White", + album="Graveyard Shift", + art_url="http://1.2.3.4/art.jpg", + duration=228, + position=42, + can_play=True, + can_pause=True, + can_stop=True, + can_next=True, + can_previous=True, +) + +SSDP_DISCOVERY = SsdpServiceInfo( + ssdp_usn=f"uuid:{TEST_SERIAL}", + ssdp_st="urn:schemas-upnp-org:device:MediaRenderer:1", + ssdp_location=f"http://{TEST_HOST}:16500/desc.xml", + upnp={ + ATTR_UPNP_SERIAL: TEST_SERIAL, + ATTR_UPNP_MANUFACTURER: "Harman Luxury Audio", + }, +) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=TEST_NAME, + data={CONF_HOST: TEST_HOST}, + unique_id=TEST_SERIAL, + ) + + +@pytest.fixture +def mock_client() -> Generator[AsyncMock]: + """Mock the Harman Luxury client.""" + with ( + patch( + "homeassistant.components.harman_luxury.HarmanLuxuryClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.harman_luxury.config_flow.HarmanLuxuryClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.async_get_info.return_value = DEVICE_INFO + client.async_get_state.return_value = PLAYER_STATE + yield client diff --git a/tests/components/harman_luxury/snapshots/test_media_player.ambr b/tests/components/harman_luxury/snapshots/test_media_player.ambr new file mode 100644 index 000000000000..918c0857700e --- /dev/null +++ b/tests/components/harman_luxury/snapshots/test_media_player.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_entities[media_player.dining_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.dining_room', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'harman_luxury', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '48b782c2-60ac-40c1-a169-8d7ccb81dcc1', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.dining_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'speaker', + : '/api/media_player_proxy/media_player.dining_room?token=deterministic&cache=1f1d8cd564c70097', + : 'Dining Room', + : False, + : 'Graveyard Shift', + : 'Motionless In White', + : 228, + : 42, + : HAFakeDatetime(2024, 1, 1, 12, 0, tzinfo=datetime.timezone.utc), + : 'Necessary Evil', + : , + : 0.45454545454545453, + }), + 'context': , + 'entity_id': 'media_player.dining_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/harman_luxury/test_config_flow.py b/tests/components/harman_luxury/test_config_flow.py new file mode 100644 index 000000000000..aee36205f34f --- /dev/null +++ b/tests/components/harman_luxury/test_config_flow.py @@ -0,0 +1,168 @@ +"""Test the Harman Luxury config flow.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from aioharmanluxury import HarmanLuxuryError +import pytest + +from homeassistant.components.harman_luxury.const import DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import DEVICE_INFO, SSDP_DISCOVERY, TEST_HOST, TEST_NAME, TEST_SERIAL + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_client") +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration 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: TEST_HOST} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NAME + assert result["data"] == {CONF_HOST: TEST_HOST} + assert result["result"].unique_id == TEST_SERIAL + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, mock_client: AsyncMock +) -> None: + """Test the user flow recovers from a connection error.""" + mock_client.async_get_info.side_effect = HarmanLuxuryError + + 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: TEST_HOST} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_client.async_get_info.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_blank_serial( + hass: HomeAssistant, mock_client: AsyncMock +) -> None: + """Test the user flow recovers from a device that reports no serial.""" + mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="") + + 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: TEST_HOST} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_client.async_get_info.return_value = DEVICE_INFO + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_client") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the user flow 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} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_client") +async def test_ssdp_flow(hass: HomeAssistant) -> None: + """Test the SSDP discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + 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: TEST_HOST} + assert result["result"].unique_id == TEST_SERIAL + + +@pytest.mark.usefixtures("mock_client") +async def test_ssdp_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test SSDP discovery aborts and updates the host when already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery = replace(SSDP_DISCOVERY, ssdp_location="http://5.5.5.5:16500/desc.xml") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + # The stale host is updated to the newly discovered one. + assert mock_config_entry.data[CONF_HOST] == "5.5.5.5" + + +async def test_ssdp_flow_cannot_connect( + hass: HomeAssistant, mock_client: AsyncMock +) -> None: + """Test SSDP discovery aborts when the device cannot be reached.""" + mock_client.async_get_info.side_effect = HarmanLuxuryError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("mock_client") +async def test_ssdp_flow_missing_serial(hass: HomeAssistant) -> None: + """Test SSDP discovery aborts when the advertisement lacks a serial.""" + discovery = replace(SSDP_DISCOVERY, upnp={}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_ssdp_flow_serial_mismatch( + hass: HomeAssistant, mock_client: AsyncMock +) -> None: + """Test SSDP discovery aborts when the API serial differs from the advertised one.""" + mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="different") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DISCOVERY + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/harman_luxury/test_init.py b/tests/components/harman_luxury/test_init.py new file mode 100644 index 000000000000..f08bf8cb7ea8 --- /dev/null +++ b/tests/components/harman_luxury/test_init.py @@ -0,0 +1,48 @@ +"""Test the Harman Luxury integration setup.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from aioharmanluxury import HarmanLuxuryError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import DEVICE_INFO + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_client") +async def test_setup_and_unload( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test a config entry loads and unloads cleanly.""" + await setup_integration(hass, mock_config_entry) + 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 + + +async def test_setup_cannot_connect( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry retries setup when the device is unreachable.""" + mock_client.async_get_info.side_effect = HarmanLuxuryError + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_unexpected_device( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test setup fails when the host answers as a different device.""" + mock_client.async_get_info.return_value = replace(DEVICE_INFO, serial="different") + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/harman_luxury/test_media_player.py b/tests/components/harman_luxury/test_media_player.py new file mode 100644 index 000000000000..e25fe28babc2 --- /dev/null +++ b/tests/components/harman_luxury/test_media_player.py @@ -0,0 +1,183 @@ +"""Test the Harman Luxury media player.""" + +from dataclasses import replace +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from aioharmanluxury import HarmanLuxuryError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + MediaPlayerEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import PLAYER_STATE + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "media_player.dining_room" + + +@pytest.mark.freeze_time("2024-01-01 12:00:00+00:00") +@pytest.mark.usefixtures("mock_client") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the media player entity state and attributes.""" + # Freeze the media-proxy token so the entity_picture URL is deterministic. + with patch("secrets.token_hex", return_value="deterministic"): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_volume_set( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test setting the volume level.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + mock_client.async_set_volume.assert_awaited_once_with(50) + + +async def test_volume_mute( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test muting the output.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + mock_client.async_set_mute.assert_awaited_once_with(True) + + +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_MEDIA_PAUSE, "pause"), + (SERVICE_MEDIA_STOP, "stop"), + (SERVICE_MEDIA_NEXT_TRACK, "next"), + (SERVICE_MEDIA_PREVIOUS_TRACK, "previous"), + ], +) +async def test_transport_commands( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: str, +) -> None: + """Test transport control services forward the right command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_client.async_control.assert_awaited_once_with(command) + + +async def test_command_error( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test a failing device command raises a HomeAssistantError.""" + await setup_integration(hass, mock_config_entry) + mock_client.async_control.side_effect = HarmanLuxuryError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + +async def test_off_state( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the player reports off when the device is not online.""" + mock_client.async_get_state.return_value = replace(PLAYER_STATE, online=False) + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + +async def test_media_play_when_paused( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that play is available and forwarded when the source is paused.""" + mock_client.async_get_state.return_value = replace( + PLAYER_STATE, play_state="paused" + ) + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + mock_client.async_control.assert_awaited_once_with("play") + + +async def test_transport_features_are_independent( + hass: HomeAssistant, mock_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test a source that only allows pause does not advertise play or stop.""" + mock_client.async_get_state.return_value = replace( + PLAYER_STATE, can_play=False, can_pause=True, can_stop=False + ) + await setup_integration(hass, mock_config_entry) + features = hass.states.get(ENTITY_ID).attributes[ATTR_SUPPORTED_FEATURES] + assert features & MediaPlayerEntityFeature.PAUSE + assert not features & MediaPlayerEntityFeature.PLAY + assert not features & MediaPlayerEntityFeature.STOP + + +async def test_becomes_unavailable_on_error( + hass: HomeAssistant, + mock_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the entity goes unavailable when polling fails.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + mock_client.async_get_state.side_effect = HarmanLuxuryError + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 193addcfcc29..d65096090a08 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -961,7 +961,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, - filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), + filename=PurePath("test_2025-01-30_05.42_12345678.tar"), folders={supervisor_backups.Folder("ssl")}, homeassistant_exclude_database=False, homeassistant=True, @@ -1015,6 +1015,16 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( homeassistant_exclude_database=True, ), ), + ( + {"name": "Nabu Casa / Webhook Proxy for HA MCP"}, + replace( + DEFAULT_BACKUP_OPTIONS, + name="Nabu Casa / Webhook Proxy for HA MCP", + filename=PurePath( + "nabu_casa_webhook_proxy_for_ha_mcp_2025-01-30_05.42_12345678.tar" + ), + ), + ), ], ) async def test_reader_writer_create( @@ -1701,7 +1711,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: - assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar") + assert call.args[1].filename == PurePath("test_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index c4a0d6c0e859..b848532943c5 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -110,7 +110,7 @@ async def test_binary_sensor( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -141,7 +141,7 @@ async def test_mount_binary_sensor( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -237,7 +237,7 @@ async def test_mount_refresh_after_issue( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 0c1b1d2399dc..440ceaa3263a 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -98,7 +98,7 @@ async def test_diagnostics( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 2da8b47eb394..de63f13775c0 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -1,7 +1,6 @@ """Test issues from supervisor issues.""" from collections.abc import Generator -from datetime import timedelta import os from typing import Any from unittest.mock import ANY, AsyncMock, patch @@ -24,17 +23,22 @@ from aiohasupervisor.models import ( UnhealthyReason, UnsupportedReason, ) -from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.hassio.const import DOMAIN -from homeassistant.components.hassio.coordinator import get_issues_info +from homeassistant.components.hassio.const import DOMAIN, HASSIO_ISSUES_UPDATE_INTERVAL +from homeassistant.components.hassio.coordinator import ( + IssueSubscription, + IssueSubscriptionEvent, + get_issues_info, +) from homeassistant.components.repairs import DOMAIN as REPAIRS_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 from .test_init import MOCK_ENVIRON +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -53,8 +57,8 @@ def fixture_supervisor_environ() -> Generator[None]: def mock_resolution_info( supervisor_client: AsyncMock, - unsupported: list[UnsupportedReason] | None = None, - unhealthy: list[UnhealthyReason] | None = None, + unsupported: list[UnsupportedReason | str] | None = None, + unhealthy: list[UnhealthyReason | str] | None = None, issues: list[Issue] | None = None, suggestions_by_issue: dict[UUID, list[Suggestion]] | None = None, suggestion_result: SupervisorError | None = None, @@ -683,9 +687,8 @@ async def test_supervisor_issues_initial_failure( supervisor_client: AsyncMock, resolution_info: AsyncMock, hass_ws_client: WebSocketGenerator, - freezer: FrozenDateTimeFactory, ) -> None: - """Test issues manager retries after initial update failure.""" + """Test initial issues refresh failure does not block hassio setup.""" mock_resolution_info( supervisor_client, unsupported=[], @@ -715,24 +718,14 @@ async def test_supervisor_issues_initial_failure( resolution_info.return_value, ] - with patch("homeassistant.components.hassio.issues.REQUEST_REFRESH_DELAY", new=0.1): - result = await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert result + result = await async_setup_component(hass, DOMAIN, {}) + 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"]) == 0 - - freezer.tick(timedelta(milliseconds=200)) - 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 + 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"]) == 0 @pytest.mark.usefixtures("all_setup_requests") @@ -884,15 +877,16 @@ async def test_supervisor_remove_missing_issue_without_error( async def test_system_is_not_ready( hass: HomeAssistant, resolution_info: AsyncMock, - caplog: pytest.LogCaptureFixture, ) -> None: - """Ensure hassio starts despite error.""" + """Ensure hassio starts despite issues refresh errors.""" resolution_info.side_effect = SupervisorBadRequestError( "System is not ready with state: setup" ) assert await async_setup_component(hass, DOMAIN, {}) - assert "Failed to update supervisor issues" in caplog.text + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + assert not issues_coordinator.issues @pytest.mark.parametrize( @@ -1157,6 +1151,271 @@ async def test_supervisor_issues_addon_pwned( ) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_subscription_events( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_supervisor_ws_client: WebSocketGenerator, +) -> None: + """Test subscription callbacks for issue add/update/remove by key.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + + events: list[str] = [] + + @callback + def _handle_subscription_event(event: IssueSubscriptionEvent) -> None: + events.append(event.event) + + unsubscribe = issues_coordinator.subscribe( + IssueSubscription( + event_callback=_handle_subscription_event, + key="issue_system_should_not_be_repair", + ) + ) + + client = await hass_supervisor_ws_client() + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": (issue_uuid := uuid4().hex), + "type": "should_not_be_repair", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + assert events == ["changed"] + + await client.send_json( + { + "id": 2, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": issue_uuid, + "type": "should_not_be_repair", + "context": "system", + "reference": "updated", + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + assert events == ["changed", "changed"] + + await client.send_json( + { + "id": 3, + "type": "supervisor/event", + "data": { + "event": "issue_removed", + "data": { + "uuid": issue_uuid, + "type": "should_not_be_repair", + "context": "system", + "reference": "updated", + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + assert events == ["changed", "changed", "removed"] + + unsubscribe() + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_periodic_refresh_backstop( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test issues coordinator polls periodically without requiring subscribers.""" + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type="should_not_be_repair", + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + ) + ], + ) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + + supervisor_client.resolution.info.reset_mock() + + async_fire_time_changed(hass, dt_util.utcnow() + HASSIO_ISSUES_UPDATE_INTERVAL) + await hass.async_block_till_done() + + supervisor_client.resolution.info.assert_called_once() + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_suggestions_change_updates_fixable_state( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_supervisor_ws_client: WebSocketGenerator, +) -> None: + """Test suggestion-only issue changes are not treated as unchanged.""" + mock_resolution_info(supervisor_client) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + supervisor_client.resolution.info.reset_mock() + issue_uuid = uuid4() + + supervisor_client.resolution.info.return_value = ResolutionInfo( + unsupported=[], + unhealthy=[], + issues=[ + Issue( + type="should_not_be_repair", + context=ContextType.SYSTEM, + reference=None, + uuid=issue_uuid, + ) + ], + suggestions=[ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ) + ], + checks=[ + Check(enabled=True, slug=CheckType.DOCKER_CONFIG), + Check(enabled=True, slug=CheckType.FREE_SPACE), + ], + ) + supervisor_client.resolution.suggestions_for_issue.return_value = [ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ) + ] + + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + events: list[str] = [] + + @callback + def _subscription_event(event: IssueSubscriptionEvent) -> None: + events.append(event.event) + + unsubscribe = issues_coordinator.subscribe( + IssueSubscription( + event_callback=_subscription_event, + key="issue_system_should_not_be_repair", + ) + ) + + supervisor_client_ws = await hass_supervisor_ws_client() + await supervisor_client_ws.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": issue_uuid.hex, + "type": "should_not_be_repair", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await supervisor_client_ws.receive_json() + assert msg["success"] + await hass.async_block_till_done() + assert events == ["changed"] + + await issues_coordinator.async_refresh() + await hass.async_block_till_done() + assert events == ["changed", "changed"] + + unsubscribe() + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issues_periodic_refresh_recovers_after_initial_failure( + hass: HomeAssistant, + supervisor_client: AsyncMock, + resolution_info: AsyncMock, +) -> None: + """Test a later refresh recovers issue state after initial refresh failure.""" + issue_uuid = uuid4() + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type="should_not_be_repair", + context=ContextType.SYSTEM, + reference=None, + uuid=issue_uuid, + ) + ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ) + ] + }, + ) + resolution_info.side_effect = [ + SupervisorBadRequestError("System is not ready with state: setup"), + resolution_info.return_value, + ] + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + + issues_coordinator = get_issues_info(hass) + assert issues_coordinator is not None + assert len(issues_coordinator.issues) == 0 + + await issues_coordinator.async_refresh() + await hass.async_block_till_done() + assert len(issues_coordinator.issues) == 1 + + @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_unload_disconnects_listener( hass: HomeAssistant, diff --git a/tests/components/hassio/test_jobs.py b/tests/components/hassio/test_jobs.py index fca6691020d0..11bbd227da5e 100644 --- a/tests/components/hassio/test_jobs.py +++ b/tests/components/hassio/test_jobs.py @@ -9,14 +9,25 @@ from uuid import uuid4 from aiohasupervisor.models import Job, JobsInfo import pytest -from homeassistant.components.hassio.const import DOMAIN, MAIN_COORDINATOR -from homeassistant.components.hassio.coordinator import HassioMainDataUpdateCoordinator -from homeassistant.components.hassio.jobs import JobSubscription +from homeassistant.components.hassio.const import ( + DOMAIN, + JOBS_COORDINATOR, + MAIN_COORDINATOR, + REQUEST_REFRESH_DELAY, + SUPERVISOR_JOBS_UPDATE_INTERVAL, +) +from homeassistant.components.hassio.coordinator import ( + HassioMainDataUpdateCoordinator, + JobSubscription, + SupervisorJobsCoordinator, +) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from .test_init import MOCK_ENVIRON +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -65,10 +76,10 @@ async def test_job_manager_setup(hass: HomeAssistant, jobs_info: AsyncMock) -> N assert result jobs_info.assert_called_once() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert len(data_coordinator.jobs.current_jobs) == 2 - assert data_coordinator.jobs.current_jobs[0].name == "test_job" - assert data_coordinator.jobs.current_jobs[1].name == "test_inner_job" + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 2 + assert jobs_coordinator.current_jobs[0].name == "test_job" + assert jobs_coordinator.current_jobs[1].name == "test_inner_job" @pytest.mark.usefixtures("all_setup_requests") @@ -100,8 +111,8 @@ async def test_job_manager_ws_updates( jobs_info.reset_mock() client = await hass_supervisor_ws_client() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert not data_coordinator.jobs.current_jobs + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert not jobs_coordinator.current_jobs # Make an example listener job_data: Job | None = None @@ -114,7 +125,7 @@ async def test_job_manager_ws_updates( subscription = JobSubscription( mock_subscription_callback, name="test_job", reference="test" ) - unsubscribe = data_coordinator.jobs.subscribe(subscription) + unsubscribe = jobs_coordinator.subscribe(subscription) # Send start of job update await client.send_json( @@ -146,7 +157,7 @@ async def test_job_manager_ws_updates( assert job_data.progress == 0 assert job_data.done is False # One job in the cache - assert len(data_coordinator.jobs.current_jobs) == 1 + assert len(jobs_coordinator.current_jobs) == 1 # Example progress update await client.send_json( @@ -178,7 +189,7 @@ async def test_job_manager_ws_updates( assert job_data.progress == 50 assert job_data.done is False # Same job, same number of jobs in cache - assert len(data_coordinator.jobs.current_jobs) == 1 + assert len(jobs_coordinator.current_jobs) == 1 # Unrelated job update - name change, subscriber should not receive await client.send_json( @@ -208,7 +219,7 @@ async def test_job_manager_ws_updates( assert job_data.name == "test_job" assert job_data.reference == "test" # New job, cache increases - assert len(data_coordinator.jobs.current_jobs) == 2 + assert len(jobs_coordinator.current_jobs) == 2 # Unrelated job update - reference change, subscriber should not receive await client.send_json( @@ -238,7 +249,7 @@ async def test_job_manager_ws_updates( assert job_data.name == "test_job" assert job_data.reference == "test" # New job, cache increases - assert len(data_coordinator.jobs.current_jobs) == 3 + assert len(jobs_coordinator.current_jobs) == 3 # Unsubscribe mock listener, should not receive final update unsubscribe() @@ -271,7 +282,7 @@ async def test_job_manager_ws_updates( assert job_data.progress == 50 assert job_data.done is False # Job ended, cache decreases - assert len(data_coordinator.jobs.current_jobs) == 2 + assert len(jobs_coordinator.current_jobs) == 2 # REST API should not be used during this sequence jobs_info.assert_not_called() @@ -306,9 +317,9 @@ async def test_job_manager_reload_on_supervisor_restart( assert result jobs_info.assert_called_once() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] - assert len(data_coordinator.jobs.current_jobs) == 1 - assert data_coordinator.jobs.current_jobs[0].name == "test_job" + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 1 + assert jobs_coordinator.current_jobs[0].name == "test_job" jobs_info.reset_mock() jobs_info.return_value = JobsInfo(ignore_conditions=[], jobs=[]) @@ -323,7 +334,7 @@ async def test_job_manager_reload_on_supervisor_restart( job_data = job subscription = JobSubscription(mock_subscription_callback, name="test_job") - data_coordinator.jobs.subscribe(subscription) + jobs_coordinator.subscribe(subscription) # Send supervisor restart signal await client.send_json( @@ -341,12 +352,18 @@ async def test_job_manager_reload_on_supervisor_restart( assert msg["success"] await hass.async_block_till_done() + # Advance time past the debouncer cooldown for the refresh to complete + async_fire_time_changed( + hass, dt_util.utcnow() + dt_util.dt.timedelta(seconds=REQUEST_REFRESH_DELAY + 1) + ) + await hass.async_block_till_done() + # Listener should be told job is done and cache cleared out jobs_info.assert_called_once() assert job_data.name == "test_job" assert job_data.reference == "test" assert job_data.done is True - assert not data_coordinator.jobs.current_jobs + assert not jobs_coordinator.current_jobs @pytest.mark.usefixtures("all_setup_requests") @@ -378,7 +395,7 @@ async def test_subscribe_returns_unsubscribe_when_job_already_matches( assert result client = await hass_supervisor_ws_client() - data_coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] received: list[Job] = [] @@ -387,7 +404,7 @@ async def test_subscribe_returns_unsubscribe_when_job_already_matches( received.append(job) subscription = JobSubscription(mock_subscription_callback, name="test_job") - unsubscribe = data_coordinator.jobs.subscribe(subscription) + unsubscribe = jobs_coordinator.subscribe(subscription) # Existing matching job is delivered immediately, and a callable unsubscribe # is returned (not the None result of the callback) @@ -422,3 +439,55 @@ async def test_subscribe_returns_unsubscribe_when_job_already_matches( await hass.async_block_till_done() assert len(received) == 1 + + +@pytest.mark.usefixtures("all_setup_requests") +async def test_job_manager_periodic_refresh( + hass: HomeAssistant, jobs_info: AsyncMock +) -> None: + """Test job manager performs periodic refresh as backstop for dropped WS events.""" + jobs_info.return_value = JobsInfo( + ignore_conditions=[], + jobs=[ + Job( + name="test_job", + reference="test", + uuid=uuid4(), + progress=0, + stage=None, + done=False, + errors=[], + created=datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + extra=None, + child_jobs=[], + ) + ], + ) + + result = await async_setup_component(hass, DOMAIN, {}) + assert result + jobs_info.assert_called_once() + + jobs_coordinator: SupervisorJobsCoordinator = hass.data[JOBS_COORDINATOR] + assert len(jobs_coordinator.current_jobs) == 1 + + # Subscribe to job updates + job_data: Job | None = None + + @callback + def mock_subscription_callback(job: Job) -> None: + nonlocal job_data + job_data = job + + subscription = JobSubscription(mock_subscription_callback, name="test_job") + jobs_coordinator.subscribe(subscription) + + # Reset mock to verify periodic refresh + jobs_info.reset_mock() + + # Advance time past the SUPERVISOR_JOBS_UPDATE_INTERVAL to trigger periodic refresh + async_fire_time_changed(hass, dt_util.utcnow() + SUPERVISOR_JOBS_UPDATE_INTERVAL) + await hass.async_block_till_done() + + # Periodic refresh should have called jobs_info + jobs_info.assert_called() diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index b53b3a40995c..a6d873103347 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -120,7 +120,7 @@ async def test_sensor( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -171,7 +171,7 @@ async def test_stats_addon_sensor( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_switch.py b/tests/components/hassio/test_switch.py index a194e46f91c7..50109d00be7c 100644 --- a/tests/components/hassio/test_switch.py +++ b/tests/components/hassio/test_switch.py @@ -34,7 +34,7 @@ async def setup_integration( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 33647a54ae36..6dbd236771e4 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -135,7 +135,7 @@ async def test_update_entities( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -157,7 +157,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -186,7 +186,7 @@ async def test_update_addon_progress( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -289,7 +289,7 @@ async def test_addon_update_progress_startup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -386,7 +386,7 @@ async def test_update_addon_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -480,7 +480,7 @@ async def test_update_addon_with_backup_removes_old_backups( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -523,7 +523,9 @@ async def test_update_addon_with_backup_removes_old_backups( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) -async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: +async def test_update_os( + hass: HomeAssistant, supervisor_client: AsyncMock, os_info: AsyncMock +) -> None: """Test updating OS update entity.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -532,12 +534,20 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() - supervisor_client.os.update.return_value = None + async def mock_os_update(*args: Any) -> None: + """Simulate Supervisor reporting the new version after the update.""" + os_info.return_value = replace( + os_info.return_value, + version="1.0.0dev2222", + update_available=False, + ) + + supervisor_client.os.update.side_effect = mock_os_update with patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", ) as mock_create_backup: @@ -550,6 +560,13 @@ async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> N mock_create_backup.assert_not_called() supervisor_client.os.update.assert_called_once_with(OSUpdate(version=None)) + # The coordinator is refreshed after install so the new version + # shows up immediately + state = hass.states.get("update.home_assistant_operating_system_update") + assert state is not None + assert state.state == "off" + assert state.attributes["installed_version"] == "1.0.0dev2222" + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), @@ -628,7 +645,7 @@ async def test_update_os_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -666,7 +683,7 @@ async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -698,7 +715,7 @@ async def test_update_core_progress( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -848,7 +865,7 @@ async def test_core_update_progress_startup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -944,7 +961,7 @@ async def test_update_core_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -983,7 +1000,7 @@ async def test_update_core_sets_progress_immediately( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1027,7 +1044,7 @@ async def test_update_core_resets_progress_on_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1066,7 +1083,7 @@ async def test_update_addon_sets_progress_immediately( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1113,7 +1130,7 @@ async def test_update_addon_resets_progress_on_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1240,7 +1257,7 @@ async def test_update_addon_stays_in_progress_until_refresh( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1315,7 +1332,7 @@ async def test_update_addon_completes_on_any_version_change( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1346,7 +1363,7 @@ async def test_update_supervisor( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1380,7 +1397,7 @@ async def test_update_supervisor_progress( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1482,7 +1499,7 @@ async def test_update_supervisor_stays_in_progress_until_restart( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1548,7 +1565,7 @@ async def test_update_supervisor_completes_on_any_version_change( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1602,7 +1619,7 @@ async def test_update_addon_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1638,7 +1655,7 @@ async def test_update_addon_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -1675,7 +1692,7 @@ async def test_update_os_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1703,7 +1720,7 @@ async def test_update_os_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -1739,7 +1756,7 @@ async def test_update_supervisor_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1766,7 +1783,7 @@ async def test_update_core_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -1794,7 +1811,7 @@ async def test_update_core_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -1831,7 +1848,7 @@ async def test_release_notes_between_versions( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1867,7 +1884,7 @@ async def test_release_notes_full( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1947,7 +1964,7 @@ async def test_not_release_notes( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -1977,7 +1994,7 @@ async def test_no_os_entity( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -2001,7 +2018,7 @@ async def test_setting_up_core_update_when_addon_fails( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() assert result diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 2749484cbcac..3b666f9e430f 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -375,6 +375,56 @@ async def test_websocket_non_admin_user( assert msg["error"]["message"] == "Unauthorized" +async def test_websocket_store_reload_refreshes_update_entities( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, + addons_list: AsyncMock, +) -> None: + """Test add-on update entities refresh after a store reload via the API proxy.""" + addons_list.return_value = [ + replace( + addons_list.return_value[0], + update_available=False, + version_latest="2.0.0", + ) + ] + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component(hass, DOMAIN, {"hassio": {}}) + await hass.async_block_till_done() + + assert hass.states.get("update.test_update").state == "off" + + addons_list.return_value = [ + replace( + addons_list.return_value[0], + update_available=True, + version_latest="2.0.1", + ) + ] + aioclient_mock.post( + "http://127.0.0.1/store/reload", json={"result": "ok", "data": {}} + ) + + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json_auto_id( + { + WS_TYPE: WS_TYPE_API, + ATTR_ENDPOINT: "/store/reload", + ATTR_METHOD: "post", + } + ) + msg = await websocket_client.receive_json() + assert msg["success"] + + assert hass.states.get("update.test_update").state == "on" + supervisor_client.store.reload.assert_not_called() + + async def test_update_addon( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -389,7 +439,7 @@ async def test_update_addon( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -492,7 +542,7 @@ async def test_update_addon_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -635,7 +685,7 @@ async def test_update_addon_with_backup_removes_old_backups( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -698,7 +748,7 @@ async def test_update_core( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await hass.async_block_till_done() @@ -793,7 +843,7 @@ async def test_update_core_with_backup( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -832,7 +882,7 @@ async def test_update_addon_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -872,7 +922,7 @@ async def test_update_addon_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) @@ -911,7 +961,7 @@ async def test_update_core_with_error( assert await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) await hass.async_block_till_done() @@ -939,7 +989,7 @@ async def test_update_core_with_backup_and_error( result = await async_setup_component( hass, DOMAIN, - {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + {"hassio": {}}, ) assert result await setup_backup_integration(hass) diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 58685f5cf8f4..e0dad7c41b70 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -259,6 +259,7 @@ dict({ 'device': dict({ 'area_id': None, + 'config_subentry_id': None, 'configuration_url': None, 'connections': list([ ]), diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 72218dc50ed4..148988f8a9ad 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -46,7 +46,7 @@ dict({ 'can_expand': True, 'can_play': False, - 'can_search': False, + 'can_search': True, 'children': list([ dict({ 'can_expand': False, @@ -100,7 +100,7 @@ dict({ 'can_expand': True, 'can_play': False, - 'can_search': False, + 'can_search': True, 'children_media_class': 'music', 'media_class': 'directory', 'media_content_id': 'media-source://media_source/local/.', diff --git a/tests/components/hikvision/test_binary_sensor.py b/tests/components/hikvision/test_binary_sensor.py index 09fa8d1f26d7..45eb0e76e67c 100644 --- a/tests/components/hikvision/test_binary_sensor.py +++ b/tests/components/hikvision/test_binary_sensor.py @@ -165,6 +165,43 @@ async def test_binary_sensor_nvr_device( assert len(states) == 2 +@pytest.mark.parametrize("amount_of_channels", [2]) +async def test_binary_sensor_duplicate_channels_deduplicated( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hikcamera: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test duplicate channel entries do not create colliding unique IDs. + + pyhik can report the same channel several times for a sensor type when the + channel has multiple notification methods enabled. Only one entity should be + created per channel and no duplicate unique ID warnings should occur. + """ + mock_hikcamera.return_value.get_type = "NVR" + mock_hikcamera.return_value.current_event_states = { + # Channel 1 reported three times, channel 2 reported twice. + "Line Crossing": [(False, 1), (False, 1), (False, 1), (False, 2), (False, 2)], + } + + await setup_integration(hass, mock_config_entry) + + # One entity per distinct channel, not one per reported entry. + states = hass.states.async_entity_ids("binary_sensor") + assert len(states) == 2 + + unique_ids = { + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert unique_ids == { + f"{TEST_DEVICE_ID}_Line Crossing_1", + f"{TEST_DEVICE_ID}_Line Crossing_2", + } + + async def test_binary_sensor_state_on( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index f2618a385a4e..f0736fad5ae9 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -173,18 +173,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, history_stats_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test config entry is removed when source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test config entry is removed when the source entity is removed.""" assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() @@ -196,15 +188,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.history_stats.async_unload_entry", wraps=history_stats.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -212,8 +201,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("sensor.my_history_stats") - # Check that the history_stats config entry is not in the device + # Check that the source device is not removed sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert history_stats_config_entry.entry_id not in sensor_device.config_entries # Check that the history_stats config entry is removed @@ -383,7 +373,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes history_stats config entry from device.""" + """Test migration from v1.1 keeps the history_stats entity linked to the source device.""" history_stats_config_entry = MockConfigEntry( data={}, @@ -402,21 +392,12 @@ async def test_migration_1_1( ) history_stats_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(history_stats_config_entry.entry_id) await hass.async_block_till_done() assert history_stats_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not on the source device and the helper # entity is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index d166a759a96f..b23939cb4030 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -291,7 +291,7 @@ async def test_setting_location(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_SET_LOCATION, - {"latitude": 30, "longitude": 40, "elevation": 0}, + {"latitude": 30, "longitude": 40, "elevation": 0.6}, blocking=True, ) assert hass.config.latitude == 30 diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py new file mode 100644 index 000000000000..53cb35748031 --- /dev/null +++ b/tests/components/homeassistant/test_llm.py @@ -0,0 +1,395 @@ +"""Tests for the homeassistant LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant import llm as ha_llm +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.homeassistant.llm import async_get_exposed_entities +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + llm, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +ENTITY_ID = "light.kitchen" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose an entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Kitchen Light"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_live_context_always_offered(hass: HomeAssistant) -> None: + """Test GetLiveContext is offered even when nothing is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "GetLiveContext" in [tool.name for tool in result.tools] + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert ha_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_prompt_includes_context(hass: HomeAssistant) -> None: + """Test the platform contributes the live-context and static-overview prompt.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert result.prompt is not None + assert ha_llm.DYNAMIC_CONTEXT_PROMPT in result.prompt + assert "Static Context:" in result.prompt + assert "Kitchen Light" in result.prompt + + +async def test_prompt_no_entities(hass: HomeAssistant) -> None: + """Test the platform contributes the no-entities prompt when nothing is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert result.prompt == ha_llm.NO_ENTITIES_PROMPT + + +async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None: + """Test GetLiveContext reports an error when nothing is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(tool for tool in result.tools if tool.name == "GetLiveContext") + + response = await tool.async_call( + hass, llm.ToolInput("GetLiveContext", {}), llm_context + ) + assert response == {"success": False, "error": ha_llm.NO_ENTITIES_PROMPT} + + +async def test_get_live_context_tool(hass: HomeAssistant) -> None: + """Test GetLiveContext returns exposed entity state.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next((tool for tool in result.tools if tool.name == "GetLiveContext"), None) + assert tool is not None + + response = await tool.async_call( + hass, llm.ToolInput("GetLiveContext", {}), llm_context + ) + assert response["success"] is True + assert "Kitchen Light" in response["result"] + + +async def test_get_exposed_entities_timestamp_conversion(hass: HomeAssistant) -> None: + """Test that async_get_exposed_entities converts timestamp states to local time.""" + # 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 + hass.states.async_set( + "sensor.test_timestamp", + "2024-01-15T10:30:00+00:00", + {"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"}, + ) + for entity_id in ( + "sensor.test_timestamp", + "sensor.regular_sensor", + "sensor.invalid_timestamp", + "sensor.empty_timestamp", + ): + async_expose_entity(hass, "conversation", entity_id, True) + + exposed = async_get_exposed_entities(hass, "conversation", include_state=True) + + # Timestamp state is converted to local time + assert exposed["sensor.test_timestamp"]["state"] == "2024-01-15T05:30:00-05:00" + # Regular sensor without device_class keeps its original value + assert exposed["sensor.regular_sensor"]["state"] == "2024-01-15T10:30:00+00:00" + # Invalid timestamp remains as-is + assert exposed["sensor.invalid_timestamp"]["state"] == "not-a-timestamp" + # Empty timestamp remains empty + assert exposed["sensor.empty_timestamp"]["state"] == "" + + # With include_state=False no state (and therefore no conversion) is included + exposed_no_state = async_get_exposed_entities( + hass, "conversation", include_state=False + ) + assert "state" not in exposed_no_state["sensor.test_timestamp"] + + +async def test_get_live_context_tool_filter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test the filter parameters of the GetLiveContext tool.""" + # The autouse fixture exposes light.kitchen; drop it for a clean entity set. + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert await async_setup_component(hass, "intent", {}) + + llm_context = _llm_context() + + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + + office = area_registry.async_create("Office") + area_registry.async_update(office.id, aliases={"Workspace"}) + area_registry.async_create("Kitchen") + + office_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "office-1")}, + suggested_area="Office", + ) + kitchen_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "kitchen-1")}, + suggested_area="Kitchen", + ) + + office_light = entity_registry.async_get_or_create( + "light", + "test", + "office_light", + original_name="Office Light", + device_id=office_device.id, + suggested_object_id="office_light", + ) + kitchen_light = entity_registry.async_get_or_create( + "light", + "test", + "kitchen_light", + original_name="Kitchen Light", + device_id=kitchen_device.id, + suggested_object_id="kitchen_light", + ) + office_switch = entity_registry.async_get_or_create( + "switch", + "test", + "office_switch", + original_name="Office Switch", + device_id=office_device.id, + suggested_object_id="office_switch", + ) + front_door = entity_registry.async_get_or_create( + "lock", + "test", + "front_door", + original_name="Front Door", + suggested_object_id="front_door", + ) + # Two entities sharing the same name in different areas + office_ac = entity_registry.async_get_or_create( + "climate", + "test", + "office_ac", + original_name="AC", + device_id=office_device.id, + suggested_object_id="office_ac", + ) + kitchen_ac = entity_registry.async_get_or_create( + "climate", + "test", + "kitchen_ac", + original_name="AC", + device_id=kitchen_device.id, + suggested_object_id="kitchen_ac", + ) + entity_registry.async_update_entity( + kitchen_light.entity_id, aliases=[er.COMPUTED_NAME, "Cooking Lamp"] + ) + + for entity_id in ( + office_light.entity_id, + kitchen_light.entity_id, + office_switch.entity_id, + front_door.entity_id, + office_ac.entity_id, + kitchen_ac.entity_id, + ): + async_expose_entity(hass, "conversation", entity_id, True) + + hass.states.async_set(office_light.entity_id, "on") + hass.states.async_set(kitchen_light.entity_id, "off") + hass.states.async_set(office_switch.entity_id, "on") + hass.states.async_set(front_door.entity_id, "locked") + hass.states.async_set(office_ac.entity_id, "cool") + hass.states.async_set(kitchen_ac.entity_id, "heat") + + await hass.async_block_till_done() + tools = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(t for t in tools.tools if t.name == "GetLiveContext") + + async def _get_live_context(tool_args: dict) -> dict: + return await tool.async_call( + hass, llm.ToolInput("GetLiveContext", tool_args), llm_context + ) + + # Filter by area and domain (example 1) + result = await _get_live_context({"area": "Office", "domain": "light"}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + assert "Front Door" not in result["result"] + + # Filter by name (example 2) + result = await _get_live_context({"name": "Front Door"}) + assert result["success"] is True + assert "Front Door" in result["result"] + assert "Office Light" not in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + + # Name filter is case insensitive + result = await _get_live_context({"name": "front door"}) + assert result["success"] is True + assert "Front Door" in result["result"] + + # Area filter matches area aliases + result = await _get_live_context({"area": "workspace"}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Front Door" not in result["result"] + + # Domain filter accepts a list + result = await _get_live_context({"domain": ["switch", "lock"]}) + assert result["success"] is True + assert "Office Switch" in result["result"] + assert "Front Door" in result["result"] + assert "Office Light" not in result["result"] + assert "Kitchen Light" not in result["result"] + + # Domain filter is case insensitive + result = await _get_live_context({"domain": "Light"}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" in result["result"] + assert "Office Switch" not in result["result"] + assert "Front Door" not in result["result"] + + # No filters returns all exposed entities + result = await _get_live_context({}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Front Door" in result["result"] + + # Filter that matches nothing returns a descriptive error + result = await _get_live_context({"name": "Does Not Exist"}) + assert result == { + "success": False, + "error": "No exposed entities matched name 'Does Not Exist'", + } + + # Name filter strips surrounding whitespace + result = await _get_live_context({"name": " Front Door "}) + assert result["success"] is True + assert "Front Door" in result["result"] + + # Area filter strips surrounding whitespace + result = await _get_live_context({"area": " Office "}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + + # Name filter accepts entity_id + result = await _get_live_context({"name": office_light.entity_id}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Office Switch" not in result["result"] + + # Area filter accepts area_id + result = await _get_live_context({"area": office.id}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" in result["result"] + assert "Kitchen Light" not in result["result"] + assert "Front Door" not in result["result"] + + # Name filter matches entity aliases + result = await _get_live_context({"name": "cooking lamp"}) + assert result["success"] is True + assert "Kitchen Light" in result["result"] + assert "Office Light" not in result["result"] + + # Combining name + area narrows the result + result = await _get_live_context({"name": "Office Light", "area": "Office"}) + assert result["success"] is True + assert "Office Light" in result["result"] + assert "Office Switch" not in result["result"] + + # Combining name + area returns the failing constraint in the error + result = await _get_live_context({"name": "Office Light", "area": "Kitchen"}) + assert result == { + "success": False, + "error": "No exposed entities found in area 'Kitchen'", + } + + # Unknown area distinguishes "invalid area" from "no entities in area" + result = await _get_live_context({"area": "Garage"}) + assert result == { + "success": False, + "error": "Area 'Garage' does not exist", + } + + # Unknown domain reports which domain(s) failed + result = await _get_live_context({"domain": "fan"}) + assert result == { + "success": False, + "error": "No exposed entities found in domain(s): fan", + } + + # Entities sharing a name are all returned rather than failing as an + # ambiguous match, since this tool only returns context. + result = await _get_live_context({"name": "AC"}) + assert result["success"] is True + assert result["result"].count("domain: climate") == 2 + assert "Office" in result["result"] + assert "Kitchen" in result["result"] + + # Combining a shared name with an area narrows to the single match + result = await _get_live_context({"name": "AC", "area": "Kitchen"}) + assert result["success"] is True + assert result["result"].count("domain: climate") == 1 + assert "Kitchen" in result["result"] + assert "Office" not in result["result"] diff --git a/tests/components/homee/snapshots/test_init.ambr b/tests/components/homee/snapshots/test_init.ambr index 4e073efb62fc..9d4e9ce436b1 100644 --- a/tests/components/homee/snapshots/test_init.ambr +++ b/tests/components/homee/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_general_data DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'TestHomee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.2.3', 'via_device_id': None, @@ -37,8 +36,8 @@ # name: test_general_data.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -59,7 +58,6 @@ 'model_id': None, 'name': 'Shutter with position and slats', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '4.54', 'via_device_id': , diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index 98e2a681de72..f8d4b8abb584 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -83,6 +83,8 @@ async def test_open_close_stop_cover( calls = mock_homee.set_value.call_args_list for index, call in enumerate(calls): assert call[0] == (mock_homee.nodes[0].id, 1, index) + enum_value = call[0][2] + assert f"{enum_value}" == str(index) async def test_open_close_reverse_cover( @@ -114,6 +116,10 @@ async def test_open_close_reverse_cover( assert calls[0][0] == (mock_homee.nodes[0].id, 1, 1) # Open assert calls[1][0] == (mock_homee.nodes[0].id, 1, 0) # Close + for call in calls: + enum_value = call[0][2] + assert f"{enum_value}" in ("0", "1") + async def test_set_cover_position( hass: HomeAssistant, @@ -191,6 +197,8 @@ async def test_close_open_slats( calls = mock_homee.set_value.call_args_list for index, call in enumerate(calls): assert call[0] == (mock_homee.nodes[0].id, 2, index) + enum_value = call[0][2] + assert f"{enum_value}" == str(index) async def test_close_open_reversed_slats( @@ -229,6 +237,10 @@ async def test_close_open_reversed_slats( assert calls[0][0] == (mock_homee.nodes[0].id, 2, 2) # Close assert calls[1][0] == (mock_homee.nodes[0].id, 2, 1) # Open + for call in calls: + enum_value = call[0][2] + assert f"{enum_value}" in ("1", "2") + async def test_set_slat_position( hass: HomeAssistant, diff --git a/tests/components/homee/test_init.py b/tests/components/homee/test_init.py index 66c4effe49ae..b601b4de294e 100644 --- a/tests/components/homee/test_init.py +++ b/tests/components/homee/test_init.py @@ -147,9 +147,9 @@ async def test_attribute_availability( await hass.async_block_till_done() assert ( - hass.states.get("siren.test_siren").state is not STATE_UNAVAILABLE + hass.states.get("siren.test_siren").state != STATE_UNAVAILABLE if state < AttributeState.INACTIVE - else STATE_UNAVAILABLE + else hass.states.get("siren.test_siren").state == STATE_UNAVAILABLE ) diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 8a3999f9514b..583853177644 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -740,7 +740,7 @@ async def test_call_service( ], ) async def test_call_service_resyncs_on_failure( - hass: HomeAssistant, hk_driver, raise_exception: Exception + hass: HomeAssistant, hk_driver: HomeDriver, raise_exception: Exception ) -> None: """When the dispatched service raises, re-push the entity's current state. @@ -768,6 +768,51 @@ async def test_call_service_resyncs_on_failure( assert pushed_state.state == STATE_OFF +async def test_call_service_resync_failure_is_logged( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test a failing resync is logged instead of hitting the task handler.""" + entity_id = "homekit.accessory" + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + async_mock_service( + hass, "cover", "open_cover", raise_exception=HomeAssistantError("nope") + ) + + with patch.object( + acc, "async_update_state", side_effect=RuntimeError("resync boom") + ): + acc.async_call_service( + "cover", "open_cover", {ATTR_ENTITY_ID: entity_id}, "value" + ) + await hass.async_block_till_done() + + assert "re-syncing HomeKit state failed" in caplog.text + + +async def test_call_service_resync_skip_is_logged( + hass: HomeAssistant, hk_driver: HomeDriver, caplog: pytest.LogCaptureFixture +) -> None: + """Test a resync skipped for a missing entity state is diagnosable.""" + entity_id = "homekit.accessory" + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) + async_mock_service( + hass, "cover", "open_cover", raise_exception=HomeAssistantError("nope") + ) + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + acc.async_call_service("cover", "open_cover", {ATTR_ENTITY_ID: entity_id}, "value") + await hass.async_block_till_done() + + assert "cannot re-sync HomeKit state" in caplog.text + + def test_home_bridge(hk_driver) -> None: """Test HomeBridge class.""" bridge = HomeBridge("hass", hk_driver, BRIDGE_NAME) diff --git a/tests/components/homekit/test_accessory_type.py b/tests/components/homekit/test_accessory_type.py new file mode 100644 index 000000000000..3f9ae443a967 --- /dev/null +++ b/tests/components/homekit/test_accessory_type.py @@ -0,0 +1,431 @@ +"""Test the HomeKit accessory type routing.""" + +import asyncio +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODES, + ATTR_HVAC_MODES, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homekit import HomeKit +from homeassistant.components.homekit.const import ( + DEFAULT_PORT, + DOMAIN, + HOMEKIT_MODE_ACCESSORY, + HOMEKIT_MODE_BRIDGE, + PERSIST_LOCK_DATA, +) +from homeassistant.components.homekit.type_heater_coolers import HeaterCooler +from homeassistant.components.homekit.type_thermostats import Thermostat +from homeassistant.components.homekit.util import get_aid_storage_filename_for_entry_id +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entityfilter import ( + CONF_EXCLUDE_DOMAINS, + CONF_EXCLUDE_ENTITIES, + CONF_EXCLUDE_ENTITY_GLOBS, + CONF_INCLUDE_DOMAINS, + CONF_INCLUDE_ENTITIES, + CONF_INCLUDE_ENTITY_GLOBS, + convert_filter, +) + +from .util import PATH_HOMEKIT + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def patch_source_ip() -> Generator[None]: + """Patch homeassistant and pyhap functions for getting local address.""" + with patch("pyhap.util.get_local_address", return_value="10.10.10.10"): + yield + + +ENTITY_ID = "climate.demo" +CAPABLE_ATTRS = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], +} + + +HUMIDITY_ATTRS = { + **CAPABLE_ATTRS, + ATTR_SUPPORTED_FEATURES: ( + CAPABLE_ATTRS[ATTR_SUPPORTED_FEATURES] | ClimateEntityFeature.TARGET_HUMIDITY + ), +} + + +async def _async_stop_bridge(homekit: HomeKit) -> None: + """Stop the bridge and flush the delayed aid storage save. + + The flush lets a following start read the stored routing choices. + """ + with patch("pyhap.accessory_driver.AccessoryDriver.async_stop"): + await homekit.async_stop() + assert homekit.aid_storage is not None + await homekit.aid_storage.async_save() + + +async def _async_start_bridge( + hass: HomeAssistant, + entry: MockConfigEntry, + entity_config: dict[str, Any] | None = None, + homekit_mode: str = HOMEKIT_MODE_BRIDGE, + existing_pairing: bool = False, +) -> HomeKit: + """Start a HomeKit instance exposing the demo climate entity.""" + hass.data.setdefault(PERSIST_LOCK_DATA, asyncio.Lock()) + entity_filter = convert_filter( + { + CONF_INCLUDE_DOMAINS: [], + CONF_INCLUDE_ENTITIES: [ENTITY_ID], + CONF_EXCLUDE_DOMAINS: [], + CONF_EXCLUDE_ENTITIES: [], + CONF_INCLUDE_ENTITY_GLOBS: [], + CONF_EXCLUDE_ENTITY_GLOBS: [], + } + ) + homekit = HomeKit( + hass=hass, + name="mock_name", + port=DEFAULT_PORT, + ip_address=None, + entity_filter=entity_filter, + exclude_accessory_mode=False, + entity_config=entity_config or {}, + homekit_mode=homekit_mode, + advertise_ips=None, + entry_id=entry.entry_id, + entry_title=entry.title, + ) + original_setup = HomeKit.setup + + def _setup(homekit: HomeKit, async_zeroconf_instance: Any, uuid: str) -> bool: + """Run the real driver setup, faking persisted pairing state if asked.""" + loaded = original_setup(homekit, async_zeroconf_instance, uuid) + return loaded or existing_pairing + + with ( + patch(f"{PATH_HOMEKIT}.async_show_setup_message"), + patch("pyhap.accessory_driver.AccessoryDriver.async_start"), + patch(f"{PATH_HOMEKIT}.HomeKit.setup", _setup), + ): + await homekit.async_start() + await hass.async_block_till_done() + return homekit + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_existing_entity_stays_thermostat( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test a previously bridged entity keeps the Thermostat.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + accessories = list(homekit.bridge.accessories.values()) + assert len(accessories) == 1 + assert isinstance(accessories[0], Thermostat) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_new_entity_routes_to_heater_cooler( + hass: HomeAssistant, +) -> None: + """Test a never bridged entity gets the HeaterCooler.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + accessories = list(homekit.bridge.accessories.values()) + assert len(accessories) == 1 + assert isinstance(accessories[0], HeaterCooler) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_reset_does_not_allocate_for_unbridged_entities( + hass: HomeAssistant, +) -> None: + """Test resetting an entity another bridge owns does not allocate.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + + # The reset service fans out to every instance, so an allocation here + # would wrongly mark the entity as previously bridged + await homekit.async_reset_accessories(["climate.not_bridged_here"]) + assert homekit.aid_storage is not None + assert ( + homekit.aid_storage.get_allocated_aid_for_entity_id("climate.not_bridged_here") + is None + ) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_failed_accessory_creation_is_not_recorded( + hass: HomeAssistant, +) -> None: + """Test a failed accessory creation does not persist the auto routing.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=ValueError): + homekit = await _async_start_bridge(hass, entry) + + assert homekit.aid_storage is not None + assert homekit.aid_storage.get_accessory_type(ENTITY_ID) is None + # The aid allocation is rolled back so the entity is still new + assert not homekit.aid_storage.allocations + await _async_stop_bridge(homekit) + + # The next start treats the entity as never bridged and retries + # the automatic choice + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], HeaterCooler) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_heater_cooler_choice_survives_restart( + hass: HomeAssistant, +) -> None: + """Test the automatic HeaterCooler choice persists across restarts.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], HeaterCooler) + await _async_stop_bridge(homekit) + + # The entity now has an aid allocation, so only the stored choice + # keeps it on the HeaterCooler after a restart. + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], HeaterCooler) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_gained_humidity_setpoint_drops_stored_choice( + hass: HomeAssistant, +) -> None: + """Test a stored HeaterCooler choice is dropped for a humidity setpoint.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], HeaterCooler) + await _async_stop_bridge(homekit) + + # The entity gains a humidity setpoint, which the HeaterCooler cannot + # control, so the stored routing is dropped + hass.states.async_set( + ENTITY_ID, + HVACMode.COOL, + HUMIDITY_ATTRS, + ) + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], Thermostat) + assert homekit.aid_storage is not None + assert homekit.aid_storage.get_accessory_type(ENTITY_ID) is None + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_automatic_keeps_explicit_choice( + hass: HomeAssistant, +) -> None: + """Test an explicit type updates the stored routing for automatic.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + # A new entity picks the HeaterCooler and the choice is stored + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], HeaterCooler) + await _async_stop_bridge(homekit) + + # An explicit Thermostat overrides and updates the stored routing + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "thermostat"}} + ) + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], Thermostat) + await _async_stop_bridge(homekit) + + # Back on automatic the entity keeps the Thermostat instead of + # flipping back to the HeaterCooler + homekit = await _async_start_bridge(hass, entry) + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], Thermostat) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_accessory_mode_existing_pairing_stays_thermostat( + hass: HomeAssistant, +) -> None: + """Test an existing accessory mode pairing keeps the Thermostat.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY, existing_pairing=True + ) + + assert isinstance(homekit.driver.accessory, Thermostat) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_stored_thermostat_survives_pairing_reset( + hass: HomeAssistant, +) -> None: + """Test a stored Thermostat choice is kept when the entity looks new.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + # An explicit Thermostat choice is stored with the accessory + homekit = await _async_start_bridge( + hass, + entry, + {ENTITY_ID: {CONF_TYPE: "thermostat"}}, + homekit_mode=HOMEKIT_MODE_ACCESSORY, + ) + assert isinstance(homekit.driver.accessory, Thermostat) + await _async_stop_bridge(homekit) + + # A pairing reset makes the entry look brand new, but Automatic still + # keeps the stored Thermostat instead of flipping to the HeaterCooler + homekit = await _async_start_bridge( + hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY + ) + assert isinstance(homekit.driver.accessory, Thermostat) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_accessory_mode_new_pairing_routes_heater_cooler( + hass: HomeAssistant, +) -> None: + """Test a brand new accessory mode pairing picks the HeaterCooler.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, homekit_mode=HOMEKIT_MODE_ACCESSORY + ) + + assert isinstance(homekit.driver.accessory, HeaterCooler) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_explicit_heater_cooler_wins_over_humidity_safeguard( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test an explicit heater_cooler type wins for a humidity entity.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set( + ENTITY_ID, + HVACMode.COOL, + HUMIDITY_ATTRS, + ) + + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "heater_cooler"}} + ) + + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], HeaterCooler) + await _async_stop_bridge(homekit) + + +@pytest.mark.usefixtures("mock_async_zeroconf", "hk_driver") +async def test_explicit_type_wins_for_existing_entity( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test an entity with a configured type uses it.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entry.add_to_hass(hass) + hass_storage[get_aid_storage_filename_for_entry_id(entry.entry_id)] = { + "version": 1, + "data": {"allocations": {ENTITY_ID: 1234567}}, + } + hass.states.async_set(ENTITY_ID, HVACMode.COOL, CAPABLE_ATTRS) + + homekit = await _async_start_bridge( + hass, entry, {ENTITY_ID: {CONF_TYPE: "thermostat"}} + ) + + accessories = list(homekit.bridge.accessories.values()) + assert isinstance(accessories[0], Thermostat) + await _async_stop_bridge(homekit) diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 6dbac422f079..1a12cfb1636d 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -647,3 +647,158 @@ async def test_handle_unique_id_change( # Verify that the old unique id is removed from the allocations # and that the new unique id assumes the old aid assert aid_storage.allocations == {"demo.light.new_unique": 4202023227} + + +async def test_entity_is_allocated( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test detecting whether an entity already has an allocated aid.""" + config_entry = MockConfigEntry(domain="test", data={}) + 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")}, + ) + light_ent = entity_registry.async_get_or_create( + "light", "device", "unique_id", device_id=device_entry.id + ) + hass.states.async_set(light_ent.entity_id, "on") + hass.states.async_set("remote.has_no_unique_id", "on") + + with patch( + "homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save" + ): + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + + # Nothing allocated yet + assert not aid_storage.entity_is_allocated(light_ent.entity_id) + assert not aid_storage.entity_is_allocated("remote.has_no_unique_id") + + # Allocation is keyed by the system unique id for registered entities + aid_storage.get_or_allocate_aid_for_entity_id(light_ent.entity_id) + assert aid_storage.entity_is_allocated(light_ent.entity_id) + + # Unregistered entities are keyed by entity id + aid_storage.get_or_allocate_aid_for_entity_id("remote.has_no_unique_id") + assert aid_storage.entity_is_allocated("remote.has_no_unique_id") + + # A changed unique id is still recognized through previous_unique_id + entity_registry.async_update_entity( + light_ent.entity_id, new_unique_id="new_unique_id" + ) + assert aid_storage.entity_is_allocated(light_ent.entity_id) + + +async def test_accessory_type_round_trip(hass: HomeAssistant) -> None: + """Test the stored accessory type persists through storage.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + assert aid_storage.get_accessory_type("climate.demo") is None + + # Setting the current value again is a no-op + aid_storage.async_set_accessory_type("climate.demo", None) + assert aid_storage.get_accessory_type("climate.demo") is None + + aid_storage.async_set_accessory_type("climate.demo", "heater_cooler") + aid_storage.async_set_accessory_type("climate.demo", "heater_cooler") + await aid_storage.async_save() + + fresh_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await fresh_storage.async_initialize() + assert fresh_storage.get_accessory_type("climate.demo") == "heater_cooler" + + fresh_storage.async_set_accessory_type("climate.demo", None) + await fresh_storage.async_save() + + final_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await final_storage.async_initialize() + assert final_storage.get_accessory_type("climate.demo") is None + + +async def test_accessory_type_survives_repeated_unique_id_changes( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test reads heal the stored key so a second migration cannot orphan it.""" + config_entry = MockConfigEntry(domain="test", data={}) + 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")}, + ) + climate_ent = entity_registry.async_get_or_create( + "climate", "device", "u1", device_id=device_entry.id + ) + + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + aid_storage.async_set_accessory_type(climate_ent.entity_id, "heater_cooler") + + # Only the latest previous unique id stays resolvable, so the read + # moves the entry forward after each migration + entity_registry.async_update_entity(climate_ent.entity_id, new_unique_id="u2") + await hass.async_block_till_done() + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + assert aid_storage.accessory_types == {"device.climate.u2": "heater_cooler"} + + entity_registry.async_update_entity(climate_ent.entity_id, new_unique_id="u3") + await hass.async_block_till_done() + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + assert aid_storage.accessory_types == {"device.climate.u3": "heater_cooler"} + + +async def test_accessory_type_survives_entity_renames( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the stored accessory type follows the entity through renames.""" + config_entry = MockConfigEntry(domain="test", data={}) + 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")}, + ) + climate_ent = entity_registry.async_get_or_create( + "climate", "device", "unique_id", device_id=device_entry.id + ) + + aid_storage = AccessoryAidStorage(hass, config_entry.entry_id) + await aid_storage.async_initialize() + + aid_storage.async_set_accessory_type(climate_ent.entity_id, "heater_cooler") + assert aid_storage.get_accessory_type(climate_ent.entity_id) == "heater_cooler" + # Registered entities are stored by the system unique id + assert aid_storage.accessory_types == {"device.climate.unique_id": "heater_cooler"} + + # An entity id rename keeps the choice through the stable identity + entity_registry.async_update_entity( + climate_ent.entity_id, new_entity_id="climate.renamed" + ) + await hass.async_block_till_done() + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # A unique id change is still recognized through previous_unique_id + entity_registry.async_update_entity( + "climate.renamed", new_unique_id="new_unique_id" + ) + await hass.async_block_till_done() + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # Allocating migrates the stored choice to the new unique id + aid_storage.get_or_allocate_aid_for_entity_id("climate.renamed") + assert aid_storage.accessory_types == { + "device.climate.new_unique_id": "heater_cooler" + } + assert aid_storage.get_accessory_type("climate.renamed") == "heater_cooler" + + # Clearing the choice removes the stored identity + aid_storage.async_set_accessory_type("climate.renamed", None) + assert aid_storage.get_accessory_type("climate.renamed") is None + assert not aid_storage.accessory_types diff --git a/tests/components/homekit/test_climate_util.py b/tests/components/homekit/test_climate_util.py new file mode 100644 index 000000000000..974c49062160 --- /dev/null +++ b/tests/components/homekit/test_climate_util.py @@ -0,0 +1,94 @@ +"""Test the HomeKit climate helper functions.""" + +import pytest + +from homeassistant.components.climate import ( + ATTR_FAN_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODES, +) +from homeassistant.components.homekit.climate_util import ( + HEAT_COOL_DEADBAND, + get_fan_modes_and_speeds, + get_swing_on_mode, + get_temperature_range_from_state, + resolve_target_temp_range, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import State + + +@pytest.mark.parametrize( + ("current_high", "current_low", "new_high", "new_low", "expected"), + [ + # Ordered pair within bounds is returned unchanged. + (24.0, 20.0, 25.0, None, (25.0, 20.0)), + (24.0, 20.0, None, 21.0, (24.0, 21.0)), + # A narrow but ordered band is preserved; the deadband is not forced. + (24.0, 20.0, None, 23.0, (24.0, 23.0)), + # New high crossing below the low enforces the deadband. + (24.0, 20.0, 18.0, None, (18.0, 18.0 - HEAT_COOL_DEADBAND)), + # New low crossing above the high enforces the deadband. + (20.0, 16.0, None, 22.0, (22.0 + HEAT_COOL_DEADBAND, 22.0)), + # Low dragged to the max: the deadband survives the clamp by moving high. + (20.0, 18.0, None, 30.0, (30.0, 30.0 - HEAT_COOL_DEADBAND)), + # High dragged to the min: the deadband survives by moving high up. + (24.0, 20.0, 7.0, None, (7.0 + HEAT_COOL_DEADBAND, 7.0)), + ], +) +def test_resolve_target_temp_range( + current_high: float, + current_low: float, + new_high: float | None, + new_low: float | None, + expected: tuple[float, float], +) -> None: + """Test the range resolver keeps an ordered, in-bounds pair with a deadband.""" + assert ( + resolve_target_temp_range( + current_high, current_low, new_high, new_low, 7.0, 30.0 + ) + == expected + ) + + +@pytest.mark.parametrize( + ("attrs", "expected"), + [ + # A reported bound of exactly 0 is honored, not treated as missing. + ({ATTR_MIN_TEMP: 0, ATTR_MAX_TEMP: 25}, (0.0, 25.0)), + # A missing minimum falls back to the default. + ({ATTR_MAX_TEMP: 25}, (7.0, 25.0)), + # A missing maximum falls back to the default. + ({ATTR_MIN_TEMP: 10}, (10.0, 35.0)), + # Both missing use both defaults. + ({}, (7.0, 35.0)), + ], +) +def test_get_temperature_range_from_state( + attrs: dict[str, float], expected: tuple[float, float] +) -> None: + """Test reported bounds are honored, including an explicit 0, else defaults.""" + state = State("climate.test", "cool", attrs) + assert ( + get_temperature_range_from_state(state, UnitOfTemperature.CELSIUS, 7.0, 35.0) + == expected + ) + + +def test_get_fan_modes_and_speeds_ignores_non_string() -> None: + """Test non-string fan modes are ignored rather than raising.""" + fan_modes, speeds = get_fan_modes_and_speeds( + {ATTR_FAN_MODES: ["low", None, "high", 3]} + ) + assert fan_modes == {"low": "low", "high": "high"} + assert speeds == ["low", "high"] + + +def test_get_swing_on_mode_none_when_no_swing_modes() -> None: + """Test the swing helper returns None when the entity has no swing modes.""" + assert get_swing_on_mode({}) is None + assert get_swing_on_mode({ATTR_SWING_MODES: []}) is None + assert get_swing_on_mode({ATTR_SWING_MODES: ["custom"]}) is None + assert get_swing_on_mode({ATTR_SWING_MODES: ["off", "vertical"]}) == "vertical" diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index a1d4b0dd0517..1a3541ed6a1d 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1,11 +1,13 @@ """Test the HomeKit config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( CONF_FILTER, DOMAIN, @@ -337,6 +339,12 @@ async def test_options_flow_exclude_mode(hass: HomeAssistant) -> None: user_input={"entities": ["climate.old"]}, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" with patch("homeassistant.components.homekit.async_setup_entry", return_value=True): @@ -391,7 +399,7 @@ async def test_options_flow_devices( demo_config_entry.add_to_hass(hass) with patch("homeassistant.components.homekit.HomeKit") as mock_homekit: - mock_homekit.return_value = homekit = Mock() + mock_homekit.return_value = homekit = Mock(bridge=None, driver=None) type(homekit).async_start = AsyncMock() assert await async_setup_component(hass, DOMAIN, {"homekit": {}}) assert await async_setup_component(hass, "homeassistant", {}) @@ -428,6 +436,12 @@ async def test_options_flow_devices( }, ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" # The stale "notexist" device must be stripped from the form @@ -467,6 +481,12 @@ async def test_options_flow_devices( result["flow_id"], user_input={"entities": ["climate.old"]}, ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) assert result2["step_id"] == "bridged_device_triggers" assert result2["data_schema"]({})["devices"] == [device_id] @@ -519,6 +539,12 @@ async def test_options_flow_include_mode_with_non_existant_entity( }, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" result3 = await hass.config_entries.options.async_configure( @@ -639,6 +665,12 @@ async def test_options_flow_include_mode_basic(hass: HomeAssistant) -> None: user_input={"entities": ["climate.new"]}, ) assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "bridged_device_triggers" result3 = await hass.config_entries.options.async_configure( @@ -818,6 +850,13 @@ async def test_options_flow_include_mode_with_cameras(hass: HomeAssistant) -> No user_input={"camera_copy": ["camera.native_h264"]}, ) assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "climate" + + result3 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "bridged_device_triggers" result4 = await hass.config_entries.options.async_configure( @@ -962,6 +1001,13 @@ async def test_options_flow_with_camera_audio(hass: HomeAssistant) -> None: user_input={"camera_audio": ["camera.audio"]}, ) assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "climate" + + result3 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "bridged_device_triggers" result4 = await hass.config_entries.options.async_configure( @@ -1611,3 +1657,248 @@ async def test_options_flow_include_mode_allows_hidden_entities( } await hass.async_block_till_done() await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_options_flow_climate_accessory_type_round_trip( + hass: HomeAssistant, +) -> None: + """Test setting and clearing the climate accessory type.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.new", "off") + await hass.async_block_till_done() + + async def _configure(choice: str) -> None: + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["climate"], + "include_exclude_mode": "include", + }, + ) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": ["climate.new"]}, + ) + assert result2["step_id"] == "climate" + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"new (climate.new)": choice}, + ) + assert result2["step_id"] == "bridged_device_triggers" + with patch( + "homeassistant.components.homekit.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + await _configure("heater_cooler") + assert config_entry.options["entity_config"]["climate.new"]["type"] == ( + "heater_cooler" + ) + + await _configure("thermostat") + assert config_entry.options["entity_config"]["climate.new"]["type"] == "thermostat" + + await _configure("automatic") + assert "entity_config" not in config_entry.options + + +async def test_options_flow_cameras_step_with_whole_domain_included( + hass: HomeAssistant, +) -> None: + """Test the cameras step is offered for a whole camera domain include.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("camera.native_h264", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["fan", "camera"], + "include_exclude_mode": "include", + }, + ) + assert result["step_id"] == "include" + + # No camera is selected explicitly, so the whole domain is included + # and the camera options are still offered + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": []}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "cameras" + await hass.config_entries.async_unload(config_entry.entry_id) + + +@pytest.mark.parametrize( + ( + "mode_options", + "init_input", + "entities_step", + "entities_input", + "extra_submits", + ), + [ + pytest.param( + {}, + {"domains": ["climate"], "include_exclude_mode": "include"}, + "include", + {"entities": ["climate.new"]}, + [{}], + id="bridge", + ), + pytest.param( + {"mode": "accessory"}, + { + "domains": ["climate"], + "include_exclude_mode": "include", + "mode": "accessory", + }, + "accessory", + {"entities": "climate.new"}, + [], + id="accessory", + ), + ], +) +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_options_flow_climate_step_shows_current_accessory( + port_mock: MagicMock, + hass: HomeAssistant, + hk_driver: HomeDriver, + mode_options: dict[str, str], + init_input: dict[str, Any], + entities_step: str, + entities_input: dict[str, Any], + extra_submits: list[dict[str, Any]], +) -> None: + """Test the climate labels show the accessory the entity uses now.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "mock_name", CONF_PORT: 12345}, + options={ + **mode_options, + "filter": { + "include_domains": [], + "include_entities": ["climate.new"], + "exclude_domains": [], + "exclude_entities": [], + }, + }, + ) + config_entry.add_to_hass(hass) + + # A basic climate entity bridges as a Thermostat + hass.states.async_set("climate.new", "off") + await hass.async_block_till_done() + + with ( + patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver), + patch("pyhap.util.get_local_address", return_value="10.10.10.10"), + ): + hk_driver.async_start = AsyncMock() + hk_driver.async_stop = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=init_input + ) + assert result["step_id"] == entities_step + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=entities_input + ) + assert result2["step_id"] == "climate" + assert [str(key) for key in result2["data_schema"].schema] == [ + "new (climate.new) [Thermostat]" + ] + + # The annotated label still round trips to the entity id + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"new (climate.new) [Thermostat]": "heater_cooler"}, + ) + for submit_input in extra_submits: + result3 = await hass.config_entries.options.async_configure( + result3["flow_id"], user_input=submit_input + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert config_entry.options["entity_config"]["climate.new"]["type"] == ( + "heater_cooler" + ) + await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_options_flow_climate_step_with_whole_domain_included( + hass: HomeAssistant, +) -> None: + """Test the climate step lists all climate entities for a domain include.""" + config_entry = _mock_config_entry_with_options_populated() + config_entry.add_to_hass(hass) + + hass.states.async_set("climate.new", "off") + hass.states.async_set("climate.old", "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["fan", "climate"], + "include_exclude_mode": "include", + }, + ) + assert result["step_id"] == "include" + + # No climate entity is selected explicitly, so the whole domain is + # included and every climate entity is offered in the climate step. + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"entities": []}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "climate" + assert [str(key) for key in result2["data_schema"].schema] == [ + "new (climate.new)", + "old (climate.old)", + ] + + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ + "new (climate.new)": "heater_cooler", + "old (climate.old)": "automatic", + }, + ) + assert result2["step_id"] == "bridged_device_triggers" + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={}, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + "devices": [], + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": ["climate", "fan"], + "include_entities": [], + }, + "entity_config": {"climate.new": {"type": "heater_cooler"}}, + } + await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 91b192d283f2..a6247c9a293c 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -4,20 +4,26 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.climate import ATTR_CURRENT_HUMIDITY, ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.homekit import TYPE_AIR_PURIFIER -from homeassistant.components.homekit.accessories import TYPES, get_accessory +from homeassistant.components.homekit.accessories import ( + TYPES, + climate_supports_heater_cooler, + get_accessory, +) from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_FAN, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from homeassistant.components.homekit.type_sensors import ( @@ -455,6 +461,239 @@ def test_type_camera(type_name, entity_id, state, attrs) -> None: assert mock_type.called +@pytest.mark.parametrize( + ("type_name", "entity_id", "state", "attrs"), + [ + # Basic climate without fan/swing support -> Thermostat + ("Thermostat", "climate.basic", "heat", {}), + # Climate with only FAN_MODE feature but no fan_modes -> Thermostat + ( + "Thermostat", + "climate.fan_feature_only", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE}, + ), + # Climate with only SWING_MODE feature but no swing_modes -> Thermostat + ( + "Thermostat", + "climate.swing_feature_only", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE}, + ), + # Climate with FAN_MODE feature and fan_modes list -> HeaterCooler + ( + "HeaterCooler", + "climate.with_fan", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "medium", "high"], + }, + ), + # Timing fan modes (auto/on/off/circulate) are not predefined speeds, so + # an entity exposing only those has zero speeds -> Thermostat + ( + "Thermostat", + "climate.timing_fan_modes_only", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate"], + }, + ), + # Those timing modes plus a single predefined speed still count as one + # speed, which cannot drive the slider -> Thermostat + ( + "Thermostat", + "climate.single_fan_speed", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate", "high"], + }, + ), + # Timing modes are ignored, but two real speeds among them qualify + ( + "HeaterCooler", + "climate.two_speeds_with_timing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["off", "auto", "on", "circulate", "low", "high"], + }, + ), + # A single predefined fan speed plus a swing mode -> HeaterCooler + ( + "HeaterCooler", + "climate.single_fan_speed_with_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["auto", "high"], + "swing_modes": ["on", "off"], + }, + ), + # Climate with SWING_MODE feature and swing_modes list -> HeaterCooler + ( + "HeaterCooler", + "climate.with_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["on", "off"], + }, + ), + # Climate with both FAN_MODE and SWING_MODE features and modes -> HeaterCooler + ( + "HeaterCooler", + "climate.with_fan_and_swing", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["low", "high"], + "swing_modes": ["on", "off"], + }, + ), + # Climate with FAN_MODE feature and empty fan_modes list -> Thermostat + ( + "Thermostat", + "climate.empty_fan_modes", + "heat", + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, "fan_modes": []}, + ), + # Climate with SWING_MODE feature and empty swing_modes list -> Thermostat + ( + "Thermostat", + "climate.empty_swing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": [], + }, + ), + # Climate with only custom (non-predefined) fan modes -> Thermostat + ( + "Thermostat", + "climate.custom_fan_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["quiet", "turbo"], + }, + ), + # Climate with only custom (non-predefined) swing modes -> Thermostat + ( + "Thermostat", + "climate.custom_swing_modes", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["off", "custom"], + }, + ), + # Swing without an advertised off mode -> Thermostat (off writes + # would be rejected by the entity) + ( + "Thermostat", + "climate.swing_without_off", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + "swing_modes": ["vertical"], + }, + ), + # Climate with other features but no fan/swing -> Thermostat + ( + "Thermostat", + "climate.other_features", + "heat", + { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + }, + ), + # Fan speeds with a humidity setpoint -> Thermostat (controls humidity) + ( + "Thermostat", + "climate.fan_and_target_humidity", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_HUMIDITY, + "fan_modes": ["low", "high"], + }, + ), + # Fan speeds with display-only humidity -> HeaterCooler (kept via sensor) + ( + "HeaterCooler", + "climate.fan_and_current_humidity", + "heat", + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "high"], + ATTR_CURRENT_HUMIDITY: 45, + }, + ), + ], +) +def test_climate_supports_heater_cooler( + type_name: str, entity_id: str, state: str, attrs: dict[str, object] +) -> None: + """Test the capability predicate behind automatic HeaterCooler routing.""" + entity_state = State(entity_id, state, attrs) + assert climate_supports_heater_cooler(entity_state) is (type_name == "HeaterCooler") + + +def test_climate_without_configured_type_is_thermostat() -> None: + """Test a climate entity without a configured type gets the Thermostat.""" + attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + "fan_modes": ["low", "high"], + } + mock_type = Mock() + with patch.dict(TYPES, {"Thermostat": mock_type}): + entity_state = State("climate.test", "heat", attrs) + get_accessory(None, None, entity_state, 2, {}) + assert mock_type.called + + +@pytest.mark.parametrize( + ("config_type", "attrs", "type_name"), + [ + # Would auto-route to Thermostat, but the config forces HeaterCooler + ( + TYPE_HEATER_COOLER, + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE}, + "HeaterCooler", + ), + # Would auto-route to HeaterCooler, but the config forces Thermostat + ( + TYPE_THERMOSTAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE, + "fan_modes": ["low", "high"], + "swing_modes": ["on", "off"], + }, + "Thermostat", + ), + ], +) +def test_climate_accessory_type_override( + config_type: str, attrs: dict[str, object], type_name: str +) -> None: + """Test a configured type overrides the capability based routing.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State("climate.test", "heat", attrs) + get_accessory(None, None, entity_state, 2, {CONF_TYPE: config_type}) + assert mock_type.called + + @pytest.mark.parametrize( ("expected_type", "entity_id", "attrs"), [ diff --git a/tests/components/homekit/test_type_heater_coolers.py b/tests/components/homekit/test_type_heater_coolers.py new file mode 100644 index 000000000000..a36feaf42b5b --- /dev/null +++ b/tests/components/homekit/test_type_heater_coolers.py @@ -0,0 +1,3113 @@ +"""Test different accessory types: HeaterCooler.""" + +import asyncio +from typing import Any +from unittest.mock import patch + +from pyhap.const import ( + CATEGORY_AIR_CONDITIONER, + CATEGORY_HEATER, + HAP_REPR_AID, + HAP_REPR_CHARS, + HAP_REPR_IID, + HAP_REPR_VALUE, +) +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_SWING_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.homekit.accessories import HomeDriver +from homeassistant.components.homekit.climate_base import ( + FAN_STATE_ACTIVE, + FAN_STATE_IDLE, +) +from homeassistant.components.homekit.const import ( + CHAR_ACTIVE, + CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_FAN_STATE, + CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_NAME, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + CHAR_TARGET_FAN_STATE, + CHAR_TARGET_HEATER_COOLER_STATE, + PROP_MAX_VALUE, + PROP_MIN_STEP, + PROP_MIN_VALUE, + SERV_HEATER_COOLER, + SERV_HUMIDITY_SENSOR, +) +from homeassistant.components.homekit.type_heater_coolers import ( + HC_COOLING, + HC_HEATING, + HC_IDLE, + HC_INACTIVE, + HC_TARGET_AUTO, + HC_TARGET_COOL, + HC_TARGET_HEAT, + HeaterCooler, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import async_mock_service + + +def _write_chars( + hk_driver: HomeDriver, acc: HeaterCooler, char_values: dict[str, float] +) -> None: + """Write characteristic values through the HAP client path.""" + serv = acc.get_service(SERV_HEATER_COOLER) + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: serv.get_characteristic(name).to_HAP()[HAP_REPR_IID], + HAP_REPR_VALUE: value, + } + for name, value in char_values.items() + ] + }, + "mock_addr", + ) + + +async def test_heatercooler_basic(hass: HomeAssistant, hk_driver: HomeDriver) -> None: + """Test basic HeaterCooler functionality.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_MIN_TEMP: 10.0, + ATTR_MAX_TEMP: 30.0, + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.aid == 1 + assert acc.category == CATEGORY_AIR_CONDITIONER + + # Check initial state (OFF) + assert acc.char_active.value == 0 + assert acc.char_current_state.value == HC_INACTIVE # OFF reports Inactive + assert acc.char_target_state.value == HC_TARGET_AUTO + assert acc.char_current_temp.value == 18.0 + assert acc.char_cool.value == 20.0 + assert acc.char_heat.value == 20.0 + + # Check temperature properties + assert acc.char_cool.properties[PROP_MIN_VALUE] == 10.0 + assert acc.char_cool.properties[PROP_MAX_VALUE] == 30.0 + assert acc.char_heat.properties[PROP_MIN_VALUE] == 10.0 + assert acc.char_heat.properties[PROP_MAX_VALUE] == 30.0 + + # The mode and range attributes must trigger an accessory reload so the + # characteristic set stays in sync with the device. + assert set(acc._reload_on_change_attrs) >= { + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_FAN_MODES, + ATTR_SWING_MODES, + ATTR_HVAC_MODES, + } + + +async def test_heatercooler_with_fan_and_swing( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with fan and swing mode support.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_FAN_MODE: FAN_LOW, + ATTR_SWING_MODE: "off", + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Check that fan and swing characteristics are present + assert hasattr(acc, "char_speed") + assert hasattr(acc, "char_swing") + assert acc.char_speed.value == 25 # FAN_LOW maps to 25% (index 0 of 4 speeds) + assert acc.char_swing.value == 0 # off + + +@pytest.mark.parametrize( + ("hvac_modes", "expected_auto_mode"), + [ + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + HVACMode.AUTO, + id="auto_only", + ), + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL, HVACMode.OFF], + HVACMode.HEAT_COOL, + id="heat_cool_only", + ), + # HEAT_COOL keeps its thresholds adjustable, AUTO may follow a + # schedule, so HEAT_COOL backs the HomeKit Auto target + pytest.param( + [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.AUTO, + HVACMode.OFF, + ], + HVACMode.HEAT_COOL, + id="heat_cool_preferred_over_auto", + ), + ], +) +async def test_heatercooler_auto_target_backing_mode( + hass: HomeAssistant, + hk_driver: HomeDriver, + hvac_modes: list[HVACMode], + expected_auto_mode: HVACMode, +) -> None: + """Test which range mode backs the HomeKit Auto target.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: hvac_modes, + } + + hass.states.async_set(entity_id, hvac_modes[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc._hk_to_ha_target[HC_TARGET_AUTO] == expected_auto_mode + + +async def test_heatercooler_off_with_bundled_target_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a target bundled with off is remembered for the next power on.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Turning off with a new target only sends the off write + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + {CHAR_ACTIVE: 0, CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}, + ) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.OFF + + # Power on activates the mode the tile displays + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_off_with_unsupported_bundled_target( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target bundled with off is put back on the tile.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + {CHAR_ACTIVE: 0, CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}, + ) + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_rejected_mode_is_not_remembered( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected mode write does not become the restore mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + # The entity rejects the mode write while off + async_mock_service( + hass, + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + raise_exception=HomeAssistantError("mode rejected"), + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + assert acc._last_known_mode == HVACMode.HEAT + + # Turning Active on retries the last accepted mode, not the rejected one + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_modes_heat_cool_only_no_auto( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test that Auto is not offered for entities without a range mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Auto has no backing mode, so it must not be mapped or offered to HomeKit + assert HC_TARGET_AUTO not in acc._hk_to_ha_target + assert ( + HC_TARGET_AUTO not in acc.char_target_state.properties["ValidValues"].values() + ) + assert acc.char_target_state.value == HC_TARGET_HEAT + + +async def test_heatercooler_cooling_only_no_heat_target( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cooling-only entity does not expose the Heat target.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Heat has no backing mode, so HomeKit must not offer it + assert HC_TARGET_HEAT not in acc._hk_to_ha_target + valid_values = acc.char_target_state.properties["ValidValues"].values() + assert HC_TARGET_HEAT not in valid_values + assert HC_TARGET_AUTO not in valid_values + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_temperature_step( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler relies on the HomeKit default temperature step.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # No explicit min step so HomeKit keeps its 0.1 default for unit precision + assert acc.char_cool.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heat.properties[PROP_MIN_STEP] == 0.1 + + +async def test_heatercooler_fahrenheit( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with Fahrenheit units.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_MIN_TEMP: 45.0, # Fahrenheit + ATTR_MAX_TEMP: 95.0, # Fahrenheit + ATTR_TEMPERATURE: 68.0, # Fahrenheit + ATTR_CURRENT_TEMPERATURE: 65.0, + } + + hass.config.units = US_CUSTOMARY_SYSTEM + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Target and current temperatures are converted from F to C for HomeKit + assert acc.char_heat.value == 20.0 # 68F + assert acc.char_cool.value == 20.0 # 68F + assert acc.char_current_temp.value == pytest.approx(18.3, abs=0.1) # 65F + + +async def test_heatercooler_fahrenheit_default_temp_range( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the Celsius default range is not reconverted in Fahrenheit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No min/max temp, so the Celsius defaults (7/35) are used as-is + ATTR_TEMPERATURE: 68.0, + } + + hass.config.units = US_CUSTOMARY_SYSTEM + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # The default bounds stay 7/35 C rather than being misread as Fahrenheit + assert acc.char_cool.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cool.properties[PROP_MAX_VALUE] == 35.0 + + +async def test_heatercooler_state_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test state updates from Home Assistant to HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test heating mode + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 19.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_HEAT + assert acc.char_current_state.value == HC_HEATING + assert acc.char_heat.value == 22.0 + assert acc.char_cool.value == 22.0 + assert acc.char_current_temp.value == 19.0 + + # Test cooling mode + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.COOLING, + ATTR_TEMPERATURE: 18.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_COOL + assert acc.char_current_state.value == HC_COOLING + assert acc.char_heat.value == 18.0 + assert acc.char_cool.value == 18.0 + + # Test auto mode + hass.states.async_set( + entity_id, + HVACMode.AUTO, + { + **base_attrs, + ATTR_HVAC_ACTION: HVACAction.IDLE, + ATTR_TEMPERATURE: 20.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_active.value == 1 + assert acc.char_target_state.value == HC_TARGET_AUTO + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_dual_temperature_support( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HeaterCooler with dual temperature support (TARGET_TEMP_HIGH/LOW).""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.char_cool.value == 24.0 # TARGET_TEMP_HIGH + assert acc.char_heat.value == 18.0 # TARGET_TEMP_LOW + + # Update temperatures + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 26.0, + ATTR_TARGET_TEMP_LOW: 16.0, + }, + ) + await hass.async_block_till_done() + + assert acc.char_cool.value == 26.0 + assert acc.char_heat.value == 16.0 + + +async def test_heatercooler_fan_speed_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan speed updates.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_FAN_MODE: FAN_LOW, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test different fan speeds + fan_speed_tests = [ + (FAN_LOW, 25), # low -> 25% (index 0) + (FAN_MIDDLE, 50), # middle -> 50% (index 1) + (FAN_MEDIUM, 75), # medium -> 75% (index 2) + (FAN_HIGH, 100), # high -> 100% (index 3) + ] + + for fan_mode, expected_percentage in fan_speed_tests: + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_FAN_MODE: fan_mode} + ) + await hass.async_block_till_done() + assert acc.char_speed.value == expected_percentage + + +async def test_heatercooler_no_swing_toggle_without_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the swing toggle is not exposed without an advertised off mode.""" + entity_id = "climate.test" + # Routed through the fan speeds; the swing list has no off mode, so + # the toggle's off write would be rejected by the entity + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_HIGH], + ATTR_SWING_MODES: ["vertical"], + ATTR_SWING_MODE: "vertical", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.swing_on_mode is None + serv = acc.get_service(SERV_HEATER_COOLER) + assert serv.get_characteristic(CHAR_ROTATION_SPEED) is not None + with pytest.raises(ValueError): + serv.get_characteristic(CHAR_SWING_MODE) + + +async def test_heatercooler_swing_mode_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode updates.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test swing mode on/off + swing_tests = [ + ("off", 0), + ("vertical", 1), + ("horizontal", 1), + ("both", 1), + ] + + for swing_mode, expected_value in swing_tests: + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_SWING_MODE: swing_mode} + ) + await hass.async_block_till_done() + assert acc.char_swing.value == expected_value + + +async def test_heatercooler_unavailable_states( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test handling of unavailable and unknown states.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Test unavailable state + hass.states.async_set(entity_id, STATE_UNAVAILABLE, base_attrs) + await hass.async_block_till_done() + + # Manually trigger state update since the test might not automatically trigger callbacks + unavailable_state = hass.states.get(entity_id) + acc.async_update_state(unavailable_state) + + assert acc.char_active.value == 0 + + # Test unknown state + hass.states.async_set(entity_id, STATE_UNKNOWN, base_attrs) + await hass.async_block_till_done() + + unknown_state = hass.states.get(entity_id) + acc.async_update_state(unknown_state) + + assert acc.char_active.value == 0 + + +async def test_heatercooler_action_derivation( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test action derivation when hvac_action is not provided.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, # 2 degrees below target + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Should derive heating action (current < target - delta) + assert acc.char_current_state.value == HC_HEATING + + # Test cooling derivation + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_CURRENT_TEMPERATURE: 22.0, # 2 degrees above target + }, + ) + await hass.async_block_till_done() + + assert acc.char_current_state.value == HC_COOLING + + # Test idle state (within delta) + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + **base_attrs, + ATTR_CURRENT_TEMPERATURE: 20.1, # Within 0.25 delta + }, + ) + await hass.async_block_till_done() + + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_set_active_off( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting active to off via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Turning off alongside a temperature write should only set the OFF mode + _write_chars( + hk_driver, acc, {CHAR_ACTIVE: 0, CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0} + ) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.OFF + assert len(call_set_temperature) == 0 + + +async def test_heatercooler_set_active_off_no_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning off an entity without an OFF mode issues no service call.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL], # no OFF mode + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc._supports_off is False + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + # The rejected write must not leave HomeKit showing the unit as off + assert acc.char_active.value == 1 + + +async def test_heatercooler_unsupported_target_mode_write_restored( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target mode write restores the characteristic.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_COOL + + # Heat is not supported; the write must not reach Home Assistant and + # the characteristic must return to the cool target + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_unsupported_target_write_dry_only_entity( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the restore falls back to the default target for dry only entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.DRY, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Dry has no target representation, so the last known mode cannot be + # restored; the write must fall back to the default target instead of + # leaving the unsupported value on the tile + assert acc.char_target_state.value == HC_TARGET_AUTO + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 0 + assert acc.char_target_state.value == HC_TARGET_AUTO + + +async def test_heatercooler_set_active_on( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting active to on via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + # Start in OFF state + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Set last known mode for test + acc._last_known_mode = HVACMode.HEAT + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + + # Set active to 1 (on) when currently off + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_set_active_on_heat_only( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning a heat-only entity on uses a supported mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Off at startup must fall back to a supported mode, not COOL + assert acc._last_known_mode == HVACMode.HEAT + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 1 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_power_on_with_thresholds_uses_activated_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test thresholds batched with Active on resolve against the new mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ), + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + ATTR_TEMPERATURE: None, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + # Power on restores heat cool, so the thresholds go out as a range + # write instead of a single setpoint picked from the off state + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 26.0, abs=0.1 + ) + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 16.0, abs=0.1 + ) + + +async def test_heatercooler_double_off_does_not_corrupt_mode_memory( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test repeated off writes never make off the restore mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The entity accepts the calls but reports its state late + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 0}) + await hass.async_block_till_done() + + assert acc._last_known_mode == HVACMode.COOL + + # Turning back on works even though the state still reads cool, since + # the pending off write means the entity is about to stop running + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + + +async def test_heatercooler_no_off_entity_applies_rest_of_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected off write does not drop the bundled writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL], # no off + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 0, + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0, + }, + ) + await hass.async_block_till_done() + + # The off is rejected, so the entity keeps running and the bundled + # mode and setpoint writes still apply + assert acc.char_active.value == 1 + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 21.0, abs=0.1 + ) + + +async def test_heatercooler_pending_mode_does_not_mask_target_reject( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an unsupported target is rejected while a mode is pending.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Turning on leaves heat pending; the entity reports late + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + + # Cool is not supported, so the characteristic is put back on heat + # instead of the pending mode masking the rejection + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_HEAT + + +async def test_heatercooler_range_only_one_sided_entity( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a range only entity with one sided modes gets range writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Both sides exist so the write can carry the full range + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 21.0, abs=0.1 + ) + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 22.0, abs=0.1 + ) + assert ATTR_TEMPERATURE not in call_set_temperature[0].data + + +async def test_heatercooler_state_callback_during_write_wins( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a state reported during the call beats the queued mode values.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The entity normalizes the requested mode and reports it before the + # blocking service call returns, like a synchronous integration + async def _hvac(call: ServiceCall) -> None: + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _hvac) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + # The reported mode is fresher than the queued cool, so it stays + assert acc._pending_mode is None + assert acc._last_known_mode == HVACMode.HEAT_COOL + + +async def test_heatercooler_pending_mode_bridges_slow_state_updates( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an accepted mode stays effective until the entity reports it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The mode service is accepted but the entity state is not updated, + # like a push integration that reports later + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + await hass.async_block_till_done() + + # A threshold batch resolves against the accepted cool mode, not the + # stale heat state, so the cooling side is not dropped + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + + # A mid transition update still reporting the old mode keeps the + # bridge, the displayed target, and the restore mode + hass.states.async_set( + entity_id, HVACMode.HEAT, {**base_attrs, ATTR_CURRENT_TEMPERATURE: 23.0} + ) + await hass.async_block_till_done() + assert acc._pending_mode == HVACMode.COOL + assert acc.char_target_state.value == HC_TARGET_COOL + assert acc._last_known_mode == HVACMode.COOL + + # Once the entity reports a mode change, its state is authoritative again + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + assert acc._pending_mode is None + + +async def test_heatercooler_batch_resolves_after_prior_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a batch sees the mode applied by the batch before it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + gate = asyncio.Event() + + async def _slow_hvac(call: ServiceCall) -> None: + await gate.wait() + hass.states.async_set(entity_id, call.data[ATTR_HVAC_MODE], base_attrs) + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _slow_hvac) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # The threshold batch arrives while the mode switch is still pending; + # it must resolve against COOL, not the stale HEAT state + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL}) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + gate.set() + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + + +async def test_heatercooler_failed_write_aborts_batch( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a rejected mode write aborts the rest of the batch.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_FAN_MODE: "low", + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + async_mock_service( + hass, + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + raise_exception=HomeAssistantError("mode rejected"), + ) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_ROTATION_SPEED: 100, + }, + ) + await hass.async_block_till_done() + + # The rejected mode change stops the temperature and fan writes + assert len(call_set_temperature) == 0 + assert len(call_set_fan_mode) == 0 + + +async def test_heatercooler_write_batches_are_serialized( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a second batch waits for the first to finish.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + order: list[str] = [] + gate = asyncio.Event() + + async def _slow_hvac(call: ServiceCall) -> None: + order.append(f"hvac:{call.data[ATTR_HVAC_MODE]}") + if len(order) == 1: + await gate.wait() + + async def _temp(call: ServiceCall) -> None: + order.append("temp") + + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, _slow_hvac) + hass.services.async_register(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, _temp) + + # The first batch blocks in its mode write while the second arrives + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + }, + ) + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_HEAT}) + gate.set() + await hass.async_block_till_done() + + # The first batch finishes its temperature write before the second + # batch's mode write starts + assert order == ["hvac:cool", "temp", "hvac:heat"] + + +async def test_heatercooler_set_chars_dispatch_order( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan writes are dispatched after the mode and temperature writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_FAN_MODE: "low", + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + services: list[str] = [] + + async def _record_and_wait( + domain: str, service: str, data: dict[str, Any], value: Any | None = None + ) -> bool: + services.append(service) + return True + + with patch.object(acc, "async_call_service_and_wait", _record_and_wait): + # Turning on activates heat, so the heating threshold applies + _write_chars( + hk_driver, + acc, + { + CHAR_ACTIVE: 1, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_ROTATION_SPEED: 100, + }, + ) + await hass.async_block_till_done() + + # Fan is dispatched after both the hvac mode and the temperature write + assert services.index(SERVICE_SET_FAN_MODE) > services.index(SERVICE_SET_HVAC_MODE) + assert services.index(SERVICE_SET_FAN_MODE) > services.index( + SERVICE_SET_TEMPERATURE + ) + + +async def test_heatercooler_set_target_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting target heater cooler state via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + + # Test setting different target modes + mode_tests = [ + (HC_TARGET_HEAT, HVACMode.HEAT), + (HC_TARGET_COOL, HVACMode.COOL), + (HC_TARGET_AUTO, HVACMode.AUTO), + ] + + for hk_mode, _expected_ha_mode in mode_tests: + _write_chars(hk_driver, acc, {CHAR_TARGET_HEATER_COOLER_STATE: hk_mode}) + await hass.async_block_till_done() + + assert len(call_set_hvac_mode) == 3 + assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVACMode.HEAT + assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.COOL + assert call_set_hvac_mode[2].data[ATTR_HVAC_MODE] == HVACMode.AUTO + + +async def test_heatercooler_set_temperature_single( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting temperature for single-temperature entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Set heating temperature in HEAT mode + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 22.0 + + # Change to COOL mode and set cooling temperature + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 2 + assert call_set_temperature[1].data[ATTR_TEMPERATURE] == 18.0 + + +async def test_heatercooler_set_temperature_dual( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting temperature for dual-temperature entities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Set both temperatures + _write_chars( + hk_driver, + acc, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 26.0 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 16.0 + + +async def test_heatercooler_dual_capable_entity_in_single_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test dual capable entities in a single setpoint mode use temperature.""" + entity_id = "climate.test" + # Entities like ecobee publish the range keys even in heat or cool + # mode, where only the single setpoint carries a value + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ), + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + ATTR_TARGET_TEMP_HIGH: None, + ATTR_TARGET_TEMP_LOW: None, + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The single setpoint is displayed instead of stale placeholder values + assert acc.char_heat.value == pytest.approx(22.0, abs=0.1) + + # A threshold write in heat mode sends the single setpoint + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 21.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 21.0, abs=0.1 + ) + assert ATTR_TARGET_TEMP_HIGH not in call_set_temperature[0].data + + # Switching to Auto in the same batch sends a range write instead + async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_AUTO, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 26.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 16.0, + }, + ) + await hass.async_block_till_done() + + assert call_set_temperature[-1].data[ATTR_TARGET_TEMP_HIGH] == pytest.approx( + 26.0, abs=0.1 + ) + assert call_set_temperature[-1].data[ATTR_TARGET_TEMP_LOW] == pytest.approx( + 16.0, abs=0.1 + ) + + +async def test_heatercooler_range_keys_without_range_capability( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cool only entity reporting range keys still gets single writes.""" + entity_id = "climate.test" + # A contradictory device config: range keys without any range mode + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 18.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Only the cooling threshold exists, so a range write is impossible + # and the setpoint goes out as a single temperature + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 1 + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == pytest.approx( + 22.0, abs=0.1 + ) + assert ATTR_TARGET_TEMP_HIGH not in call_set_temperature[0].data + + +async def test_heatercooler_set_fan_speed( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting fan speed via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: [FAN_LOW, FAN_MIDDLE, FAN_MEDIUM, FAN_HIGH], + ATTR_FAN_MODE: FAN_LOW, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # A None mode means the write is ignored and dispatches no service call + speed_tests = [ + (0, None), + (25, FAN_LOW), + (50, FAN_MIDDLE), + (75, FAN_MEDIUM), + (100, FAN_HIGH), + ] + + for speed_percent, _ in speed_tests: + _write_chars(hk_driver, acc, {CHAR_ROTATION_SPEED: speed_percent}) + await hass.async_block_till_done() + + expected_calls = [mode for _, mode in speed_tests if mode is not None] + assert len(call_set_fan_mode) == len(expected_calls) + for call, expected_mode in zip(call_set_fan_mode, expected_calls, strict=True): + assert call.data[ATTR_FAN_MODE] == expected_mode + + +async def test_heatercooler_set_swing_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test setting swing mode via HomeKit.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical", "horizontal", "both"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test swing on + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 1}) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 1 + assert call_set_swing_mode[0].data[ATTR_SWING_MODE] == "both" # swing_on_mode + + # Test swing off + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 0}) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 2 + assert call_set_swing_mode[1].data[ATTR_SWING_MODE] == "off" # SWING_OFF + + +async def test_heatercooler_capitalized_fan_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test capitalized fan modes are sent back to the service unchanged.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["Auto", "Low", "Medium", "High"], + ATTR_FAN_MODE: "Low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # The auto mode puts the speed on the fan service; the rotation speed + # writes must use the entity's original casing + acc.char_speed.client_update_value(100) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "High" + + acc.char_speed.client_update_value(25) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "Low" + + +async def test_heatercooler_capitalized_swing_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test capitalized swing modes are detected and preserved.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["Off", "On", "Both"], + ATTR_SWING_MODE: "Off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # Predefined swing modes are detected despite the capitalization + assert acc.swing_on_mode == "On" + assert acc.swing_off_mode == "Off" + + # On/off writes preserve the entity's original casing + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 1}) + await hass.async_block_till_done() + assert call_set_swing_mode[0].data[ATTR_SWING_MODE] == "On" + + _write_chars(hk_driver, acc, {CHAR_SWING_MODE: 0}) + await hass.async_block_till_done() + assert call_set_swing_mode[1].data[ATTR_SWING_MODE] == "Off" + + # A capitalized current swing value still reads as on + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_SWING_MODE: "On"} + ) + await hass.async_block_till_done() + assert acc.char_swing.value == 1 + + +async def test_heatercooler_swing_mode_fallback( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode fallback when no swing modes are available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.SWING_MODE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: [], # Empty swing modes + ATTR_SWING_MODE: None, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test setting swing mode off when no swing modes are available + # This should not make a service call because there are no swing modes + acc._set_swing_mode(0) + await hass.async_block_till_done() + + assert len(call_set_swing_mode) == 0 + + +async def test_heatercooler_fan_speed_no_fan_modes( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test fan speed handling when no fan modes are available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No fan modes + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + + # Test setting fan speed when no fan modes are available + acc._set_fan_speed(50) + await hass.async_block_till_done() + + # No service call should be made + assert len(call_set_fan_mode) == 0 + + +async def test_heatercooler_swing_mode_no_swing_attribute( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test swing mode handling when swing_on_mode attribute is not available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # No swing mode support + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # Test setting swing mode when swing_on_mode attribute is not available + acc._set_swing_mode(1) + await hass.async_block_till_done() + + # No service call should be made + assert len(call_set_swing_mode) == 0 + + +async def test_heatercooler_single_temp_no_entity_state( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test single temperature handling when entity state is not available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # Remove entity state; a threshold write must not call the service + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}) + await hass.async_block_till_done() + + assert len(call_set_temperature) == 0 + + +@pytest.mark.parametrize( + ("state", "current_temp", "chars", "expected"), + [ + # Cool mode uses the cooling threshold; heat mode uses the heating one. + pytest.param( + HVACMode.COOL, + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="cool_uses_cooling_threshold", + ), + pytest.param( + HVACMode.HEAT, + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="heat_uses_heating_threshold", + ), + # HEAT_COOL with both thresholds picks the one further from the temp. + pytest.param( + HVACMode.HEAT_COOL, + 19.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 25.0, # |25-19| > |18-19| + id="heat_cool_picks_further_cooling", + ), + pytest.param( + HVACMode.HEAT_COOL, + 24.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 18.0, # |18-24| > |25-24| + id="heat_cool_picks_further_heating", + ), + # AUTO behaves like HEAT_COOL for a single set point. + pytest.param( + HVACMode.AUTO, + 24.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 18.0, # |18-24| > |25-24| + id="auto_picks_further_heating", + ), + pytest.param( + HVACMode.AUTO, + 19.0, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 25.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + 25.0, # |25-19| > |18-19| + id="auto_picks_further_cooling", + ), + # HEAT_COOL with a single threshold falls back to it. + pytest.param( + HVACMode.HEAT_COOL, + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="heat_cool_single_cooling", + ), + pytest.param( + HVACMode.HEAT_COOL, + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="heat_cool_single_heating", + ), + # An unknown mode falls back to whichever threshold was written. + pytest.param( + "unknown_mode", + 20.0, + {CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0}, + 22.0, + id="unknown_mode_cooling", + ), + pytest.param( + "unknown_mode", + 20.0, + {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}, + 18.0, + id="unknown_mode_heating", + ), + ], +) +async def test_heatercooler_complex_temperature_selection( + hass: HomeAssistant, + hk_driver: HomeDriver, + state: str, + current_temp: float, + chars: dict[str, float], + expected: float, +) -> None: + """Test single set point selection driven by threshold characteristic writes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.AUTO, + HVACMode.OFF, + ], + ATTR_TEMPERATURE: current_temp, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + hass.states.async_set(entity_id, state, base_attrs) + await hass.async_block_till_done() + + _write_chars(hk_driver, acc, chars) + await hass.async_block_till_done() + + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + expected, abs=0.1 + ) + + +@pytest.mark.parametrize( + "state", + [STATE_UNKNOWN, STATE_UNAVAILABLE, "invalid_mode", HVACMode.AUTO], +) +async def test_heatercooler_target_state_unchanged_for_unusable_states( + hass: HomeAssistant, hk_driver: HomeDriver, state: str +) -> None: + """Test the target state is left unchanged for states with no mapping.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # A known Cool target is established from the current state + assert acc.char_target_state.value == HC_TARGET_COOL + + # Unknown, unavailable, invalid, and unsupported Auto have no mapping, so + # the target characteristic keeps its previous value. + hass.states.async_set(entity_id, state, base_attrs) + await hass.async_block_till_done() + assert acc.char_target_state.value == HC_TARGET_COOL + + +async def test_heatercooler_derive_action_edge_case( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the derived current state for modes without a heating/cooling action.""" + entity_id = "climate.test" + features = ClimateEntityFeature.TARGET_TEMPERATURE + hvac_modes = [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.OFF, + ] + base_attrs = { + ATTR_SUPPORTED_FEATURES: features, + ATTR_HVAC_MODES: hvac_modes, + ATTR_TEMPERATURE: 21.0, + ATTR_CURRENT_TEMPERATURE: 20.0, + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Dry and fan modes have no heating/cooling action, so the state is idle + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + hass.states.async_set(entity_id, HVACMode.FAN_ONLY, base_attrs) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # Cool mode without a target temperature cannot derive an action + hass.states.async_set( + entity_id, + HVACMode.COOL, + { + ATTR_SUPPORTED_FEATURES: features, + ATTR_HVAC_MODES: hvac_modes, + ATTR_CURRENT_TEMPERATURE: 20.0, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # Heat mode already at temperature is idle, not heating + hass.states.async_set( + entity_id, + HVACMode.HEAT, + {**base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 21.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_heat_cool_no_current_temp_diff( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test HEAT_COOL mode temperature selection when no current temperature available.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.OFF, + ], + # No current temperature + } + + # Create entity first + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + # HEAT_COOL with both thresholds but no current temp defaults to heating + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, base_attrs) + await hass.async_block_till_done() + + _write_chars( + hk_driver, + acc, + { + CHAR_COOLING_THRESHOLD_TEMPERATURE: 22.0, + CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0, + }, + ) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 18.0, abs=0.1 + ) + + +async def test_heatercooler_derive_action_auto_without_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test auto mode action derivation without a target temperature range.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Without any setpoint the derived state is idle + hass.states.async_set( + entity_id, HVACMode.AUTO, {**base_attrs, ATTR_CURRENT_TEMPERATURE: 21.1} + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + # A single setpoint drives both sides of the hysteresis band + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 23.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_COOLING + + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 19.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_HEATING + + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_TEMPERATURE: 21.0, ATTR_CURRENT_TEMPERATURE: 21.1}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_off_at_startup_activates_displayed_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test turning on an off-at-startup entity activates the displayed mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The tile shows Auto, so turning on must activate Auto, not the first mode. + assert acc.char_target_state.value == HC_TARGET_AUTO + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.AUTO + + +@pytest.mark.parametrize( + "mode_sequence", + [ + pytest.param( + [HVACMode.COOL, HVACMode.DRY, HVACMode.OFF], id="mode_change_to_dry" + ), + pytest.param([HVACMode.DRY, HVACMode.OFF], id="starts_in_dry"), + ], +) +async def test_heatercooler_unrepresentable_mode_not_restored( + hass: HomeAssistant, hk_driver: HomeDriver, mode_sequence: list[HVACMode] +) -> None: + """Test a mode without a HomeKit target is not restored by Active.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.COOL, + HVACMode.DRY, + HVACMode.OFF, + ], + } + + hass.states.async_set(entity_id, mode_sequence[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + for mode in mode_sequence[1:]: + hass.states.async_set(entity_id, mode, base_attrs) + await hass.async_block_till_done() + + # Dry has no HomeKit target, so the tile shows Cool and turning + # Active on must restore Cool, not Dry. + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + + +async def test_heatercooler_power_on_restores_last_active_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test going off keeps the last mode on the tile and power-on restores it.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO, HVACMode.OFF], + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_state.value == HC_TARGET_HEAT + + # Going off keeps the Heat target on the tile rather than flipping to Auto. + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + assert acc.char_target_state.value == HC_TARGET_HEAT + + # Turning back on restores the mode the tile is showing. + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + _write_chars(hk_driver, acc, {CHAR_ACTIVE: 1}) + await hass.async_block_till_done() + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.HEAT + + +async def test_heatercooler_cool_mode_ignores_heating_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a heating-threshold write is ignored for a single-setpoint cool entity.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + # Cool mode uses the cooling threshold; a heating-threshold write is ignored. + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + assert len(call_set_temperature) == 0 + + +async def test_heatercooler_batched_mode_decides_setpoint_side( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a mode written in the same batch decides the setpoint side.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_hvac_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE) + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + + # The entity is still heating, but the batch switches to Cool, so the + # cooling threshold is the setpoint rather than being ignored. + _write_chars( + hk_driver, + acc, + { + CHAR_TARGET_HEATER_COOLER_STATE: HC_TARGET_COOL, + CHAR_COOLING_THRESHOLD_TEMPERATURE: 24.0, + }, + ) + await hass.async_block_till_done() + + assert call_set_hvac_mode[-1].data[ATTR_HVAC_MODE] == HVACMode.COOL + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 24.0, abs=0.1 + ) + + +async def test_heatercooler_fan_only_target_falls_back( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a fan-only entity maps Auto to its mode and hides the thresholds.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + ATTR_HVAC_MODES: [HVACMode.FAN_ONLY, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.FAN_ONLY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # No heat/cool/range mode, so Auto maps to the first supported mode + valid_values = acc.char_target_state.properties["ValidValues"] + assert valid_values == {HVACMode.FAN_ONLY: HC_TARGET_AUTO} + assert acc.char_target_state.value == HC_TARGET_AUTO + + # No target temperature support, so the threshold sliders are not exposed + assert not hasattr(acc, "char_cool") + assert not hasattr(acc, "char_heat") + assert acc.char_speed.value == 100 + + +async def test_heatercooler_off_only_target_falls_back_to_off( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a degenerate off-only entity maps Auto to off, not an unsupported mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.FAN_MODE, + ATTR_HVAC_MODES: [HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + } + + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The only mode is off, so Auto maps to off rather than an unsupported Auto + valid_values = acc.char_target_state.properties["ValidValues"] + assert valid_values == {HVACMode.OFF: HC_TARGET_AUTO} + + +async def test_heatercooler_derive_action_cooling_triggered( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test _derive_action returns COOLING for auto mode when temp is significantly higher than target.""" + entity_id = "climate.test" + + # Test entity in auto mode with current temp higher than target + delta + # Target will be ATTR_TARGET_TEMP_HIGH (20.0), current temp should be > target + 0.25 (delta) + hass.states.async_set( + entity_id, + HVACMode.AUTO, + { + ATTR_HVAC_MODES: [HVACMode.AUTO, HVACMode.OFF], + ATTR_CURRENT_TEMPERATURE: 22.0, # 2°C higher than target + ATTR_TARGET_TEMP_HIGH: 20.0, # Target (becomes the target in _derive_action) + # Note: No ATTR_HVAC_ACTION so _derive_action gets called + }, + ) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "HeaterCooler", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Current 22°C is above target 20°C plus the hysteresis band, so the + # derived action is cooling. + assert acc.char_current_state.value == HC_COOLING + + +async def test_heatercooler_zero_min_temp_preserved( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a reported min temperature of 0 is used, not the default floor.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_MIN_TEMP: 0.0, + ATTR_MAX_TEMP: 30.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_cool.properties[PROP_MIN_VALUE] == 0.0 + + +async def test_heatercooler_reports_humidity_via_linked_sensor( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a reported current humidity is exposed via a linked sensor.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["low", "high"], + ATTR_CURRENT_HUMIDITY: 55, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 55 + + # Every service carries an explicit primary flag so the Home app shows + # the HeaterCooler tile, not the linked humidity sensor + serv = acc.get_service(SERV_HEATER_COOLER) + assert serv.is_primary_service is True + humidity_serv = acc.get_service(SERV_HUMIDITY_SENSOR) + assert humidity_serv.is_primary_service is False + assert humidity_serv.get_characteristic(CHAR_NAME).value == "Climate Humidity" + + hass.states.async_set( + entity_id, HVACMode.COOL, {**base_attrs, ATTR_CURRENT_HUMIDITY: 60} + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + + +async def test_heatercooler_custom_fan_modes_no_rotation_speed( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test entities with only custom fan modes skip the rotation speed char.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # Non-standard names that do not intersect the predefined speeds + ATTR_FAN_MODES: ["quiet", "turbo"], + ATTR_FAN_MODE: "quiet", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + # This must not raise ZeroDivisionError during init + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.ordered_fan_speeds == [] + assert not hasattr(acc, "char_speed") + + +async def test_heatercooler_custom_swing_modes_no_swing_char( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test entities with only custom swing modes skip the swing char.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "custom"], + ATTR_SWING_MODE: "off", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + assert acc.swing_on_mode is None + assert not hasattr(acc, "char_swing") + + +async def test_heatercooler_derive_action_fahrenheit( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test derived action uses a unit independent hysteresis band.""" + entity_id = "climate.test" + hass.config.units = US_CUSTOMARY_SYSTEM + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + # hvac_action intentionally omitted so the action is derived + ATTR_TEMPERATURE: 68.0, + ATTR_CURRENT_TEMPERATURE: 72.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # 72F current vs 68F target in cool mode is well past the 0.25C band + assert acc.char_current_state.value == HC_COOLING + + # Within the hysteresis band (0.2F ~= 0.11C < 0.25C) stays idle + hass.states.async_set( + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_TEMPERATURE: 68.0, ATTR_CURRENT_TEMPERATURE: 68.2}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_derive_action_auto_with_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test derived action in auto mode using the target temperature range.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO, HVACMode.OFF], + # hvac_action intentionally omitted so the action is derived, and no + # ATTR_TEMPERATURE so it falls back to the thresholds. + ATTR_TARGET_TEMP_HIGH: 24.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 26.0, + } + + hass.states.async_set(entity_id, HVACMode.AUTO, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + acc.run() + await hass.async_block_till_done() + + # 26°C is above the high threshold, so the derived action is cooling + assert acc.char_current_state.value == HC_COOLING + + # Below the low threshold the derived action is heating + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_CURRENT_TEMPERATURE: 18.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_HEATING + + # Comfortably between the thresholds it stays idle, not heating + hass.states.async_set( + entity_id, + HVACMode.AUTO, + {**base_attrs, ATTR_CURRENT_TEMPERATURE: 22.0}, + ) + await hass.async_block_till_done() + assert acc.char_current_state.value == HC_IDLE + + +async def test_heatercooler_auto_fan_mode_linked_fan_service( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test an auto fan mode exposes the fan through a linked fan service.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "low", "high"], + ATTR_FAN_MODE: "auto", + ATTR_HVAC_ACTION: HVACAction.COOLING, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # The rotation speed lives on the fan service, not the HeaterCooler + assert CHAR_TARGET_FAN_STATE in acc.fan_chars + assert CHAR_ROTATION_SPEED in acc.fan_chars + assert CHAR_CURRENT_FAN_STATE in acc.fan_chars + assert acc.char_target_fan_state.value == 1 + assert acc.char_current_fan_state.value == FAN_STATE_ACTIVE + assert acc.char_fan_active.value == 1 + + # Leaving auto selects the middle manual speed; re-enabling restores auto + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_target_fan_state.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "low" + + acc.char_target_fan_state.client_update_value(1) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "auto" + + # A manual fan mode is reflected back into the fan service chars + hass.states.async_set( + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_FAN_MODE: "high", ATTR_HVAC_ACTION: HVACAction.IDLE}, + ) + await hass.async_block_till_done() + assert acc.char_target_fan_state.value == 0 + assert acc.char_speed.value == 100 + assert acc.char_current_fan_state.value == FAN_STATE_IDLE + + # Turning the unit off turns the fan inactive + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) + await hass.async_block_till_done() + assert acc.char_fan_active.value == 0 + + # An entity that is unavailable when the accessory is created starts + # with the fan inactive; state change propagation filters unavailable + # in the base accessory, so only the creation path sees it. + hass.states.async_set(entity_id, STATE_UNAVAILABLE, base_attrs) + await hass.async_block_till_done() + acc_unavailable = HeaterCooler(hass, hk_driver, "Climate", entity_id, 2, None) + assert acc_unavailable.char_fan_active.value == 0 + + +@pytest.mark.parametrize( + ("hvac_modes", "expected_category"), + [ + pytest.param( + [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="cooling_capable", + ), + pytest.param( + [HVACMode.HEAT_COOL, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="heat_cool", + ), + pytest.param( + [HVACMode.HEAT, HVACMode.OFF], + CATEGORY_HEATER, + id="heat_only", + ), + pytest.param( + [HVACMode.DRY, HVACMode.OFF], + CATEGORY_AIR_CONDITIONER, + id="dry_only_is_not_a_heater", + ), + ], +) +async def test_heatercooler_category( + hass: HomeAssistant, + hk_driver: HomeDriver, + hvac_modes: list[HVACMode], + expected_category: int, +) -> None: + """Test the advertised category matches the device capabilities.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: hvac_modes, + } + + hass.states.async_set(entity_id, hvac_modes[0], base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + assert acc.category == expected_category + + +async def test_heatercooler_auto_fan_service_without_speeds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test auto plus on fan modes get a fan service without a speed slider.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "on"], + ATTR_FAN_MODE: "on", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert CHAR_TARGET_FAN_STATE in acc.fan_chars + assert CHAR_ROTATION_SPEED not in acc.fan_chars + assert not hasattr(acc, "char_speed") + assert acc.char_target_fan_state.value == 0 + + # Leaving auto falls back to the on mode + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_target_fan_state.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "on" + + +async def test_heatercooler_fan_active_resets_without_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a fan off write resets to on when the fan has no off mode.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "low", "high"], + ATTR_FAN_MODE: "low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_fan_active.client_update_value(0) + await hass.async_block_till_done() + assert len(call_set_fan_mode) == 0 + assert acc.char_fan_active.value == 1 + + +async def test_heatercooler_fan_active_toggles_with_off_mode( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test the fan active toggle maps to the fan off and on modes.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ), + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF], + ATTR_FAN_MODES: ["auto", "off", "low", "high"], + ATTR_FAN_MODE: "low", + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + acc.char_fan_active.client_update_value(0) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "off" + + acc.char_fan_active.client_update_value(1) + await hass.async_block_till_done() + assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "low" + + +async def test_heatercooler_cool_only_single_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a cool-only entity exposes only the cooling threshold.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.COOL, HVACMode.OFF], + ATTR_TEMPERATURE: 22.0, + } + + hass.states.async_set(entity_id, HVACMode.COOL, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert not hasattr(acc, "char_heat") + assert acc.char_cool.value == pytest.approx(22.0, abs=0.1) + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_COOLING_THRESHOLD_TEMPERATURE: 24.0}) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 24.0, abs=0.1 + ) + + +async def test_heatercooler_heat_only_single_threshold( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a heat-only entity exposes only the heating threshold.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + ATTR_TEMPERATURE: 21.0, + } + + hass.states.async_set(entity_id, HVACMode.HEAT, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + assert not hasattr(acc, "char_cool") + assert acc.char_heat.value == pytest.approx(21.0, abs=0.1) + + call_set_temperature = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE + ) + _write_chars(hk_driver, acc, {CHAR_HEATING_THRESHOLD_TEMPERATURE: 19.0}) + await hass.async_block_till_done() + assert call_set_temperature[-1].data[ATTR_TEMPERATURE] == pytest.approx( + 19.0, abs=0.1 + ) + + +async def test_heatercooler_dry_only_keeps_both_thresholds( + hass: HomeAssistant, hk_driver: HomeDriver +) -> None: + """Test a dry-only entity with a setpoint keeps both thresholds.""" + entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.SWING_MODE + ), + ATTR_HVAC_MODES: [HVACMode.DRY, HVACMode.OFF], + ATTR_SWING_MODES: ["off", "vertical"], + ATTR_TEMPERATURE: 23.0, + } + + hass.states.async_set(entity_id, HVACMode.DRY, base_attrs) + await hass.async_block_till_done() + + acc = HeaterCooler(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + # Neither side is capable, so both stay to keep the setpoint controllable + assert acc.char_cool.value == pytest.approx(23.0, abs=0.1) + assert acc.char_heat.value == pytest.approx(23.0, abs=0.1) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 796caba2c0db..2bc060960df4 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -5,9 +5,11 @@ from datetime import timedelta from freezegun import freeze_time import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_VALUE, CHAR_CONFIGURED_NAME, + CHAR_NAME, SERV_OUTLET, TYPE_FAUCET, TYPE_SHOWER, @@ -618,6 +620,57 @@ async def test_input_select_switch( assert acc.select_chars["option3"].value is False +@pytest.mark.parametrize( + "domain", + ["input_select", "select"], +) +async def test_select_switch_with_options_needing_name_cleanup( + hass: HomeAssistant, hk_driver: HomeDriver, events: list[Event], domain: str +) -> None: + """Test select options altered by HomeKit name cleanup still sync state.""" + entity_id = f"{domain}.test" + options = ["always_on", "always on"] + + hass.states.async_set(entity_id, "always_on", {ATTR_OPTIONS: options}) + await hass.async_block_till_done() + acc = SelectSwitch(hass, hk_driver, "SelectSwitch", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + + outlets = [serv for serv in acc.services if serv.display_name == SERV_OUTLET] + assert [serv.get_characteristic(CHAR_NAME).value for serv in outlets] == [ + "always on", + "always on", + ] + assert [ + serv.get_characteristic(CHAR_CONFIGURED_NAME).value for serv in outlets + ] == ["always on", "always on"] + + assert {option: char.value for option, char in acc.select_chars.items()} == { + "always_on": True, + "always on": False, + } + + hass.states.async_set(entity_id, "always on", {ATTR_OPTIONS: options}) + await hass.async_block_till_done() + assert {option: char.value for option, char in acc.select_chars.items()} == { + "always_on": False, + "always on": True, + } + + call_select_option = async_mock_service(hass, domain, SERVICE_SELECT_OPTION) + acc.select_chars["always_on"].client_update_value(True) + await hass.async_block_till_done() + + assert call_select_option + assert call_select_option[0].data == { + "entity_id": entity_id, + "option": "always_on", + } + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + @pytest.mark.parametrize( "domain", ["button", "input_button"], diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e8e4111d2ce7..32c83e76715d 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -44,6 +44,11 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.homekit.accessories import HomeDriver +from homeassistant.components.homekit.climate_base import ( + FAN_STATE_ACTIVE, + FAN_STATE_IDLE, + FAN_STATE_INACTIVE, +) from homeassistant.components.homekit.const import ( ATTR_VALUE, CHAR_CURRENT_FAN_STATE, @@ -57,9 +62,6 @@ from homeassistant.components.homekit.const import ( PROP_MIN_VALUE, ) from homeassistant.components.homekit.type_thermostats import ( - FAN_STATE_ACTIVE, - FAN_STATE_IDLE, - FAN_STATE_INACTIVE, HC_HEAT_COOL_AUTO, HC_HEAT_COOL_COOL, HC_HEAT_COOL_HEAT, @@ -998,7 +1000,20 @@ async def test_thermostat_get_temperature_range(hass: HomeAssistant, hk_driver) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state - assert acc.get_temperature_range(state) == (15.5, 21.0) + # 60F is 15.56C, rounded inward to the 0.1 step so the slider cannot + # go below the entity's own minimum + assert acc.get_temperature_range(state) == (15.6, 21.1) + + # A range too narrow to hold a step keeps the exact limits instead + # of expanding beyond them + acc._unit = UnitOfTemperature.CELSIUS + hass.states.async_set( + entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 20.11, ATTR_MAX_TEMP: 20.14} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (20.11, 20.14) async def test_thermostat_temperature_step_whole( @@ -1971,7 +1986,9 @@ async def test_water_heater_get_temperature_range( state = hass.states.get(entity_id) assert state await hass.async_block_till_done() - assert acc.get_temperature_range(state) == (15.5, 21.0) + # 60F is 15.56C, rounded inward to the 0.1 step so the slider cannot + # go below the entity's own minimum + assert acc.get_temperature_range(state) == (15.6, 21.1) async def test_water_heater_restore( @@ -2336,13 +2353,13 @@ async def test_thermostat_with_fan_modes_with_auto( assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == FAN_LOW - char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] + char_fan_active_iid = acc.char_fan_active.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_active_iid, + HAP_REPR_IID: char_fan_active_iid, HAP_REPR_VALUE: 0, } ] @@ -2351,7 +2368,7 @@ async def test_thermostat_with_fan_modes_with_auto( ) await hass.async_block_till_done() - assert acc.char_active.value == 1 + assert acc.char_fan_active.value == 1 char_target_fan_state_iid = acc.char_target_fan_state.to_HAP()[HAP_REPR_IID] @@ -2434,7 +2451,7 @@ async def test_thermostat_with_fan_modes_with_off( assert CHAR_TARGET_FAN_STATE not in acc.fan_chars assert CHAR_SWING_MODE in acc.fan_chars assert CHAR_CURRENT_FAN_STATE in acc.fan_chars - assert acc.char_active.value == 1 + assert acc.char_fan_active.value == 1 hass.states.async_set( entity_id, @@ -2460,16 +2477,16 @@ async def test_thermostat_with_fan_modes_with_off( }, ) await hass.async_block_till_done() - assert acc.char_active.value == 0 + assert acc.char_fan_active.value == 0 call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) - char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] + char_fan_active_iid = acc.char_fan_active.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_active_iid, + HAP_REPR_IID: char_fan_active_iid, HAP_REPR_VALUE: 1, } ] @@ -2487,7 +2504,7 @@ async def test_thermostat_with_fan_modes_with_off( HAP_REPR_CHARS: [ { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_active_iid, + HAP_REPR_IID: char_fan_active_iid, HAP_REPR_VALUE: 0, } ] @@ -2968,3 +2985,35 @@ async def test_thermostat_with_capitalized_fan_modes( assert len(call_set_fan_mode) == 2 assert call_set_fan_mode[-1].data[ATTR_ENTITY_ID] == entity_id assert call_set_fan_mode[-1].data[ATTR_FAN_MODE] == "Low" + + +async def test_climate_base_fan_swing_guards(hass: HomeAssistant, hk_driver) -> None: + """Test the shared fan and swing setters no-op without predefined modes.""" + entity_id = "climate.test" + hass.states.async_set( + entity_id, + HVACMode.HEAT, + { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [HVACMode.HEAT, HVACMode.OFF], + }, + ) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + acc.run() + await hass.async_block_till_done() + + call_set_fan_mode = async_mock_service(hass, CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE) + call_set_swing_mode = async_mock_service( + hass, CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE + ) + + # No predefined fan speeds, so a fan speed write is ignored. + acc._set_fan_speed(50) + # No swing mode, so a swing write is ignored. + acc._set_swing_mode(1) + await hass.async_block_till_done() + + assert len(call_set_fan_mode) == 0 + assert len(call_set_swing_mode) == 0 diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index b5e273237a3d..05d09b89848a 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -47,10 +47,12 @@ from homeassistant.components.homekit.const import ( FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, TYPE_FAUCET, + TYPE_HEATER_COOLER, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, + TYPE_THERMOSTAT, TYPE_VALVE, ) from homeassistant.components.homekit.models import HomeKitEntryData @@ -140,6 +142,7 @@ def test_validate_entity_config() -> None: } }, {"fan.test": {CONF_TYPE: "invalid_type"}}, + {"climate.test": {CONF_TYPE: "invalid_type"}}, { "valve.test": { # Must be sensor (timestamp) entity @@ -236,6 +239,12 @@ def test_validate_entity_config() -> None: assert vec({"switch.demo": {CONF_TYPE: TYPE_VALVE}}) == { "switch.demo": {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD: 20} } + assert vec({"climate.demo": {CONF_TYPE: TYPE_HEATER_COOLER}}) == { + "climate.demo": {CONF_TYPE: TYPE_HEATER_COOLER, CONF_LOW_BATTERY_THRESHOLD: 20} + } + assert vec({"climate.demo": {CONF_TYPE: TYPE_THERMOSTAT}}) == { + "climate.demo": {CONF_TYPE: TYPE_THERMOSTAT, CONF_LOW_BATTERY_THRESHOLD: 20} + } config = { CONF_TYPE: TYPE_SPRINKLER, CONF_LINKED_VALVE_DURATION: "input_number.valve_duration", diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 7d0cc7666fb5..1cfba8296a31 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -4,8 +4,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -26,7 +26,6 @@ 'model_id': None, 'name': 'Airversa AP2 1808', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '0.8.16', 'via_device_id': None, @@ -653,8 +652,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +674,6 @@ 'model_id': None, 'name': 'eufy HomeBase2-0AAA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000A', 'sw_version': '2.1.6', 'via_device_id': None, @@ -731,8 +729,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -753,7 +751,6 @@ 'model_id': None, 'name': 'eufyCam2-0000', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000D', 'sw_version': '1.6.7', 'via_device_id': , @@ -993,8 +990,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1015,7 +1012,6 @@ 'model_id': None, 'name': 'eufyCam2-000A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000B', 'sw_version': '1.6.7', 'via_device_id': , @@ -1255,8 +1251,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1277,7 +1273,6 @@ 'model_id': None, 'name': 'eufyCam2-000A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A0000A000000000C', 'sw_version': '1.6.7', 'via_device_id': , @@ -1521,8 +1516,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1543,7 +1538,6 @@ 'model_id': None, 'name': 'Aqara-Hub-E1-00A0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00aa00000a0', 'sw_version': '3.3.0', 'via_device_id': None, @@ -1744,8 +1738,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1766,7 +1760,6 @@ 'model_id': None, 'name': 'Contact Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '158d0007c59c6a', 'sw_version': '0', 'via_device_id': , @@ -1921,8 +1914,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1943,7 +1936,6 @@ 'model_id': None, 'name': 'Aqara Hub-1563', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0000000123456789', 'sw_version': '1.4.7', 'via_device_id': None, @@ -2212,8 +2204,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2234,7 +2226,6 @@ 'model_id': None, 'name': 'Programmable Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111a1111a1a111', 'sw_version': '9', 'via_device_id': None, @@ -2344,8 +2335,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2366,7 +2357,6 @@ 'model_id': None, 'name': 'ArloBabyA0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00A0000000000', 'sw_version': '1.10.931', 'via_device_id': None, @@ -2867,8 +2857,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2889,7 +2879,6 @@ 'model_id': None, 'name': 'InWall Outlet-0394DE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1020301376', 'sw_version': '1.0.0', 'via_device_id': None, @@ -3351,8 +3340,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3373,7 +3362,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB3C', 'sw_version': '1.0.0', 'via_device_id': , @@ -3526,8 +3514,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3548,7 +3536,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -4020,8 +4007,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4042,7 +4029,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB1C', 'sw_version': '1.0.0', 'via_device_id': , @@ -4195,8 +4181,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4217,7 +4203,6 @@ 'model_id': None, 'name': 'Porch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB2C', 'sw_version': '1.0.0', 'via_device_id': , @@ -4374,8 +4359,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4396,7 +4381,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -4644,8 +4628,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4666,7 +4650,6 @@ 'model_id': None, 'name': 'Basement Window 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -4907,8 +4890,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4929,7 +4912,6 @@ 'model_id': None, 'name': 'Deck Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5170,8 +5152,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5192,7 +5174,6 @@ 'model_id': None, 'name': 'Front Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5433,8 +5414,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5455,7 +5436,6 @@ 'model_id': None, 'name': 'Garage Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5696,8 +5676,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5718,7 +5698,6 @@ 'model_id': None, 'name': 'Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -5966,8 +5945,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5988,7 +5967,6 @@ 'model_id': None, 'name': 'Living Room Window 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6229,8 +6207,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6251,7 +6229,6 @@ 'model_id': None, 'name': 'Loft window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6492,8 +6469,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6514,7 +6491,6 @@ 'model_id': None, 'name': 'Master BR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -6762,8 +6738,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6784,7 +6760,6 @@ 'model_id': None, 'name': 'Master BR Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7025,8 +7000,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7047,7 +7022,6 @@ 'model_id': None, 'name': 'Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '4.8.70226', 'via_device_id': None, @@ -7433,8 +7407,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7455,7 +7429,6 @@ 'model_id': None, 'name': 'Upstairs BR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7703,8 +7676,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7725,7 +7698,6 @@ 'model_id': None, 'name': 'Upstairs BR Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '1.0.0', 'via_device_id': , @@ -7970,8 +7942,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7992,7 +7964,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -8468,8 +8439,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8490,7 +8461,6 @@ 'model_id': None, 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB3C', 'sw_version': '1.0.0', 'via_device_id': , @@ -8591,8 +8561,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8613,7 +8583,6 @@ 'model_id': None, 'name': 'HomeW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789012', 'sw_version': '4.2.394', 'via_device_id': None, @@ -8896,8 +8865,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8918,7 +8887,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB1C', 'sw_version': '1.0.0', 'via_device_id': , @@ -9071,8 +9039,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9093,7 +9061,6 @@ 'model_id': None, 'name': 'Porch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AB2C', 'sw_version': '1.0.0', 'via_device_id': , @@ -9250,8 +9217,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9272,7 +9239,6 @@ 'model_id': None, 'name': 'My ecobee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456789016', 'sw_version': '4.7.340214', 'via_device_id': None, @@ -9757,8 +9723,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9779,7 +9745,6 @@ 'model_id': None, 'name': 'Master Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '4.5.130201', 'via_device_id': None, @@ -10074,8 +10039,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10096,7 +10061,6 @@ 'model_id': None, 'name': 'Eve Degree AA11', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AA00A0A00000', 'sw_version': '1.2.8', 'via_device_id': None, @@ -10465,8 +10429,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10487,7 +10451,6 @@ 'model_id': None, 'name': 'Eve Energy 50FF', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AA00A0A00000', 'sw_version': '1.2.9', 'via_device_id': None, @@ -10844,8 +10807,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -10866,7 +10829,6 @@ 'model_id': None, 'name': 'HAA-C718B3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'C718B3-1', 'sw_version': '5.0.18', 'via_device_id': None, @@ -11068,8 +11030,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11090,7 +11052,6 @@ 'model_id': None, 'name': 'HAA-C718B3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'C718B3-2', 'sw_version': '5.0.18', 'via_device_id': None, @@ -11194,8 +11155,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11216,7 +11177,6 @@ 'model_id': None, 'name': 'Family Room North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.family_door_north', 'sw_version': '3.6.2', 'via_device_id': , @@ -11369,8 +11329,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11391,7 +11351,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -11447,8 +11406,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11469,7 +11428,6 @@ 'model_id': None, 'name': 'Kitchen Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.kitchen_window', 'sw_version': '3.6.2', 'via_device_id': , @@ -11626,8 +11584,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11648,7 +11606,6 @@ 'model_id': None, 'name': 'Ceiling Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.ceiling_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -11757,8 +11714,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11779,7 +11736,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -11835,8 +11791,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11857,7 +11813,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -11971,8 +11926,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -11993,7 +11948,6 @@ 'model_id': None, 'name': '89 Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'climate.89_living_room', 'sw_version': '2024.2.0', 'via_device_id': , @@ -12325,8 +12279,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12347,7 +12301,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12407,8 +12360,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12429,7 +12382,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12485,8 +12437,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12507,7 +12459,6 @@ 'model_id': None, 'name': 'Laundry Smoke ED78', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'light.laundry_smoke_ed78', 'sw_version': '1.4.84', 'via_device_id': , @@ -12671,8 +12622,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12693,7 +12644,6 @@ 'model_id': None, 'name': 'Family Room North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.family_door_north', 'sw_version': '3.6.2', 'via_device_id': , @@ -12846,8 +12796,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12868,7 +12818,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -12924,8 +12873,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12946,7 +12895,6 @@ 'model_id': None, 'name': 'Kitchen Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'cover.kitchen_window', 'sw_version': '3.6.2', 'via_device_id': , @@ -13103,8 +13051,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13125,7 +13073,6 @@ 'model_id': None, 'name': 'Ceiling Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.ceiling_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13234,8 +13181,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13256,7 +13203,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -13312,8 +13258,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13334,7 +13280,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13449,8 +13394,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13471,7 +13416,6 @@ 'model_id': None, 'name': 'Home Assistant Bridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '0.104.0.dev0', 'via_device_id': None, @@ -13527,8 +13471,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13549,7 +13493,6 @@ 'model_id': None, 'name': 'Living Room Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'fan.living_room_fan', 'sw_version': '0.104.0.dev0', 'via_device_id': , @@ -13664,8 +13607,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -13686,7 +13629,6 @@ 'model_id': None, 'name': '89 Living Room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'climate.89_living_room', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14027,8 +13969,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14049,7 +13991,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14109,8 +14050,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14131,7 +14072,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14187,8 +14127,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14209,7 +14149,6 @@ 'model_id': None, 'name': 'Humidifier 182A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'humidifier.humidifier_182a', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14380,8 +14319,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14402,7 +14341,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14458,8 +14396,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14480,7 +14418,6 @@ 'model_id': None, 'name': 'Humidifier 182A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'humidifier.humidifier_182a', 'sw_version': '2024.2.0', 'via_device_id': , @@ -14651,8 +14588,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14673,7 +14610,6 @@ 'model_id': None, 'name': 'HASS Bridge S6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'homekit.bridge', 'sw_version': '2024.2.0', 'via_device_id': None, @@ -14729,8 +14665,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14751,7 +14687,6 @@ 'model_id': None, 'name': 'Laundry Smoke ED78', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'light.laundry_smoke_ed78', 'sw_version': '1.4.84', 'via_device_id': , @@ -14925,8 +14860,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -14947,7 +14882,6 @@ 'model_id': None, 'name': 'Air Conditioner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00000001', 'sw_version': '1.0.0', 'via_device_id': None, @@ -15139,8 +15073,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15161,7 +15095,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462395276914', 'sw_version': '1.46.13', 'via_device_id': , @@ -15279,8 +15212,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15301,7 +15234,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462395276939', 'sw_version': '1.46.13', 'via_device_id': , @@ -15419,8 +15351,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15441,7 +15373,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462403113447', 'sw_version': '1.46.13', 'via_device_id': , @@ -15559,8 +15490,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15581,7 +15512,6 @@ 'model_id': None, 'name': 'Hue ambiance candle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462403233419', 'sw_version': '1.46.13', 'via_device_id': , @@ -15699,8 +15629,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15721,7 +15651,6 @@ 'model_id': None, 'name': 'Hue ambiance spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462412411853', 'sw_version': '1.46.13', 'via_device_id': , @@ -15849,8 +15778,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -15871,7 +15800,6 @@ 'model_id': None, 'name': 'Hue ambiance spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462412413293', 'sw_version': '1.46.13', 'via_device_id': , @@ -15999,8 +15927,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16021,7 +15949,6 @@ 'model_id': None, 'name': 'Hue dimmer switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462389072572', 'sw_version': '45.1.17846', 'via_device_id': , @@ -16339,8 +16266,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16361,7 +16288,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462378982941', 'sw_version': '1.46.13', 'via_device_id': , @@ -16471,8 +16397,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16493,7 +16419,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462378983942', 'sw_version': '1.46.13', 'via_device_id': , @@ -16603,8 +16528,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16625,7 +16550,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462379122122', 'sw_version': '1.46.13', 'via_device_id': , @@ -16735,8 +16659,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16757,7 +16681,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462379123707', 'sw_version': '1.46.13', 'via_device_id': , @@ -16867,8 +16790,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16889,7 +16812,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462383114163', 'sw_version': '1.46.13', 'via_device_id': , @@ -16999,8 +16921,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17021,7 +16943,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462383114193', 'sw_version': '1.46.13', 'via_device_id': , @@ -17131,8 +17052,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17153,7 +17074,6 @@ 'model_id': None, 'name': 'Hue white lamp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6623462385996792', 'sw_version': '1.46.13', 'via_device_id': , @@ -17263,8 +17183,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17285,7 +17205,6 @@ 'model_id': None, 'name': 'Philips hue - 482544', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': '1.32.1932126170', 'via_device_id': None, @@ -17345,8 +17264,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17367,7 +17286,6 @@ 'model_id': None, 'name': 'Koogeek-LS1-20833F', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAA011111111111', 'sw_version': '2.2.15', 'via_device_id': None, @@ -17491,8 +17409,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17513,7 +17431,6 @@ 'model_id': None, 'name': 'Koogeek-P1-A00AA0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'EUCP03190xxxxx48', 'sw_version': '2.3.7', 'via_device_id': None, @@ -17670,8 +17587,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17692,7 +17609,6 @@ 'model_id': None, 'name': 'Koogeek-SW2-187A91', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'CNNT061751001372', 'sw_version': '1.0.3', 'via_device_id': None, @@ -17892,8 +17808,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -17914,7 +17830,6 @@ 'model_id': None, 'name': 'Lennox', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'XXXXXXXX', 'sw_version': '3.40.XX', 'via_device_id': None, @@ -18196,8 +18111,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18218,7 +18133,6 @@ 'model_id': None, 'name': 'LG webOS TV AF80', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '999AAAAAA999', 'sw_version': '04.71.04', 'via_device_id': None, @@ -18388,8 +18302,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18410,7 +18324,6 @@ 'model_id': None, 'name': 'Caséta® Wireless Fan Speed Control', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '39024290', 'sw_version': '001.005', 'via_device_id': , @@ -18519,8 +18432,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18541,7 +18454,6 @@ 'model_id': None, 'name': 'Smart Bridge 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12344331', 'sw_version': '08.08', 'via_device_id': None, @@ -18601,8 +18513,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18623,7 +18535,6 @@ 'model_id': None, 'name': 'MSS425F-15cc', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'HH41234', 'sw_version': '4.2.3', 'via_device_id': None, @@ -18903,8 +18814,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18925,7 +18836,6 @@ 'model_id': None, 'name': 'MSS565-28da', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'BB1121', 'sw_version': '4.1.9', 'via_device_id': None, @@ -19039,8 +18949,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19061,7 +18971,6 @@ 'model_id': None, 'name': 'Mysa-85dda9', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAA000', 'sw_version': '2.8.1', 'via_device_id': None, @@ -19395,8 +19304,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19417,7 +19326,6 @@ 'model_id': None, 'name': 'Nanoleaf Strip 3B32', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAA011111111111', 'sw_version': '1.4.40', 'via_device_id': None, @@ -19677,8 +19585,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -19699,7 +19607,6 @@ 'model_id': None, 'name': 'Netatmo-Doorbell-g738658', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'g738658', 'sw_version': '80.0.0', 'via_device_id': None, @@ -19994,8 +19901,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20016,7 +19923,6 @@ 'model_id': None, 'name': 'Smart CO Alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '1.0.3', 'via_device_id': None, @@ -20166,8 +20072,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20188,7 +20094,6 @@ 'model_id': None, 'name': 'Healthy Home Coach', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAAAAAAAA', 'sw_version': '59', 'via_device_id': None, @@ -20498,8 +20403,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20520,7 +20425,6 @@ 'model_id': None, 'name': 'RainMachine-00ce4a', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '00aa0000aa0a', 'sw_version': '1.0.4', 'via_device_id': None, @@ -20956,8 +20860,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -20978,7 +20882,6 @@ 'model_id': None, 'name': 'Master Bath South', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21131,8 +21034,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21153,7 +21056,6 @@ 'model_id': None, 'name': 'RYSE SmartBridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0101.3521.0436', 'sw_version': '1.3.0', 'via_device_id': None, @@ -21209,8 +21111,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21231,7 +21133,6 @@ 'model_id': None, 'name': 'RYSE SmartShade', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '', 'sw_version': '', 'via_device_id': , @@ -21388,8 +21289,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21410,7 +21311,6 @@ 'model_id': None, 'name': 'BR Left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21563,8 +21463,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21585,7 +21485,6 @@ 'model_id': None, 'name': 'LR Left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21738,8 +21637,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21760,7 +21659,6 @@ 'model_id': None, 'name': 'LR Right', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -21913,8 +21811,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -21935,7 +21833,6 @@ 'model_id': None, 'name': 'RYSE SmartBridge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0401.3521.0679', 'sw_version': '1.3.0', 'via_device_id': None, @@ -21991,8 +21888,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22013,7 +21910,6 @@ 'model_id': None, 'name': 'RZSS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1.0.0', 'sw_version': '3.0.8', 'via_device_id': , @@ -22170,8 +22066,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22192,7 +22088,6 @@ 'model_id': None, 'name': 'SENSE ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AAAAAAA000', 'sw_version': '004.027.000', 'via_device_id': None, @@ -22297,8 +22192,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22319,7 +22214,6 @@ 'model_id': None, 'name': 'SIMPLEconnect Fan-06F674', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890abcd', 'sw_version': '', 'via_device_id': None, @@ -22487,8 +22381,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22509,7 +22403,6 @@ 'model_id': None, 'name': 'VELUX Internal Cover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '0.0.0', 'via_device_id': None, @@ -22617,8 +22510,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -22639,7 +22532,6 @@ 'model_id': None, 'name': 'U by Moen-015F44', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '3.3.0', 'via_device_id': None, @@ -23042,8 +22934,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23064,7 +22956,6 @@ 'model_id': None, 'name': 'VELUX Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '16.0.0', 'via_device_id': None, @@ -23274,8 +23165,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23296,7 +23187,6 @@ 'model_id': None, 'name': 'VELUX Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1a11a1', 'sw_version': '70', 'via_device_id': None, @@ -23352,8 +23242,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23374,7 +23264,6 @@ 'model_id': None, 'name': 'VELUX Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a11b111', 'sw_version': '16', 'via_device_id': , @@ -23580,8 +23469,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23602,7 +23491,6 @@ 'model_id': None, 'name': 'VELUX Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1111111a114a111a', 'sw_version': '48', 'via_device_id': , @@ -23710,8 +23598,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23732,7 +23620,6 @@ 'model_id': None, 'name': 'VELUX Window', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '0.0.0', 'via_device_id': None, @@ -23840,8 +23727,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23862,7 +23749,6 @@ 'model_id': None, 'name': 'VELUX External Cover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '**REDACTED**', 'sw_version': '15.0.0', 'via_device_id': None, @@ -23969,8 +23855,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -23991,7 +23877,6 @@ 'model_id': None, 'name': 'VOCOlinc-Flowerbud-0d324b', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'AM01121849000327', 'sw_version': '3.121.2', 'via_device_id': None, @@ -24289,8 +24174,8 @@ dict({ 'device': DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24311,7 +24196,6 @@ 'model_id': None, 'name': 'VOCOlinc-VP3-123456', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'EU0121203xxxxx07', 'sw_version': '1.101.2', 'via_device_id': None, diff --git a/tests/components/homematicip_cloud/__init__.py b/tests/components/homematicip_cloud/__init__.py index 1d89bd73183c..542a985b63ed 100644 --- a/tests/components/homematicip_cloud/__init__.py +++ b/tests/components/homematicip_cloud/__init__.py @@ -1 +1 @@ -"""Tests for the HomematicIP Cloud component.""" +"""Tests for the HomematicIP Cloud integration.""" diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index 26e359422d0b..6d882f5e2a47 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -171,6 +171,17 @@ def full_flush_lock_controller_device_data_fixture() -> dict[str, Any]: "label": "", "supportedOptionalFeatures": {}, }, + "9": { + "authorized": True, + "channelRole": "DOOR_OPENER_ACTUATOR", + "deviceId": "3014F7110000000000000026", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 4, + "groups": [], + "index": 9, + "label": "", + "supportedOptionalFeatures": {}, + }, }, "homeId": "00000000-0000-0000-0000-000000000001", "id": "3014F7110000000000000026", diff --git a/tests/components/homematicip_cloud/test_button.py b/tests/components/homematicip_cloud/test_button.py index a1eb06a88617..7ea135c1a316 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -1,6 +1,7 @@ """Tests for HomematicIP Cloud button.""" from typing import Any +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory @@ -51,7 +52,14 @@ async def test_hmip_full_flush_lock_controller_button( default_mock_hap_factory: HomeFactory, full_flush_lock_controller_device_data: dict[str, Any], ) -> None: - """Test HomematicIP full flush lock controller opener button.""" + """Test HomematicIP full flush lock controller opener button. + + The button must pull the latch on the ACCESS_AUTHORIZATION_CHANNEL with + role DOOR_OPENER_ACTUATOR (channel 9 in the fixture), not call + send_start_impulse on the underlying DOOR_SWITCH_CHANNEL. The former is + the only endpoint non-admin clients are allowed to invoke; the latter + fails with CLIENT_ACCESS_DENIED for non-admin clients. + """ entity_id = "button.universal_motorschloss_controller_door_opener" entity_name = "Universal Motorschloss Controller Door opener" device_model = "HmIP-FLC" @@ -66,18 +74,53 @@ async def test_hmip_full_flush_lock_controller_button( assert state assert state.state == STATE_UNKNOWN - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - freezer.move_to(now) - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, + hmip_device = mock_hap.hmip_device_by_entity_id[entity_id] + auth_channel = next( + ch + for ch in hmip_device.functionalChannels + if ch.functionalChannelType.name == "ACCESS_AUTHORIZATION_CHANNEL" + and ch.channelRole == "DOOR_OPENER_ACTUATOR" ) - hmip_device = mock_hap.hmip_device_by_entity_id[entity_id] - assert hmip_device.mock_calls[-1][0] == "send_start_impulse_async" + with ( + patch.object( + auth_channel, "async_pull_latch", new_callable=AsyncMock + ) as mock_pull_latch, + patch.object( + hmip_device, "send_start_impulse_async", new_callable=AsyncMock + ) as mock_send_start_impulse, + ): + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_pull_latch.assert_awaited_once_with() + mock_send_start_impulse.assert_not_awaited() state = hass.states.get(entity_id) assert state assert state.state == now.isoformat() + + +async def test_hmip_full_flush_lock_controller_button_missing_channel( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + full_flush_lock_controller_device_data: dict[str, Any], +) -> None: + """Button is not created when the door-opener auth channel is missing.""" + # Strip the access-authorization channel for DOOR_OPENER_ACTUATOR so the + # setup detection function rejects the device. + full_flush_lock_controller_device_data["functionalChannels"].pop("9") + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Universal Motorschloss Controller"], + extra_devices=[full_flush_lock_controller_device_data], + ) + + entity_id = "button.universal_motorschloss_controller_door_opener" + assert hass.states.get(entity_id) is None + assert entity_id not in mock_hap.hmip_device_by_entity_id diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index f09a5b84b884..8ba9773c0d30 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -1,6 +1,7 @@ """Tests for HomematicIP Cloud sensor.""" from homematicip.base.enums import ValveState, WindowState +import pytest from homeassistant.components.homematicip_cloud import DOMAIN from homeassistant.components.homematicip_cloud.entity import ( @@ -340,6 +341,29 @@ async def test_hmip_illuminance_sensor2( assert ha_state.attributes[ATTR_LOWEST_ILLUMINATION] == 785.2 +async def test_hmip_motion_detector_push_button_single_illuminance( + hass: HomeAssistant, + default_mock_hap_factory: HomeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test MotionDetectorPushButton produces exactly one illuminance sensor.""" + await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Bewegungsmelder für 55er Rahmen – innen"] + ) + illuminance_states = [ + state + for state in hass.states.async_all("sensor") + if state.entity_id.endswith("_illuminance") + ] + assert len(illuminance_states) == 1 + assert ( + illuminance_states[0].entity_id + == "sensor.bewegungsmelder_fur_55er_rahmen_innen_illuminance" + ) + assert illuminance_states[0].state == "14.2" + assert "does not generate unique IDs" not in caplog.text + + async def test_hmip_windspeed_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index d1c4c93824cf..6d717631cbe2 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_identify_button.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 856ca1f32b34..e06105aa381d 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -62,8 +62,8 @@ # name: test_number_entities[HWE-SKT-11].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -88,7 +88,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -157,8 +156,8 @@ # name: test_number_entities[HWE-SKT-21].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -183,7 +182,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr index 2fd3327220ff..7c1138e33c03 100644 --- a/tests/components/homewizard/snapshots/test_select.ambr +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -63,8 +63,8 @@ # name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -89,7 +89,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index cdd4f676608e..5c45661005f3 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_battery_cycles:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -90,8 +89,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -116,7 +115,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -183,8 +181,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -209,7 +207,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -276,8 +273,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -302,7 +299,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -369,8 +365,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -395,7 +391,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -462,8 +457,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -488,7 +483,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -555,8 +549,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -581,7 +575,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -648,8 +641,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_state_of_charge:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -674,7 +667,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -741,8 +733,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_uptime:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -767,7 +759,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -827,8 +818,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -853,7 +844,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -920,8 +910,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_wi_fi_rssi:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -946,7 +936,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -1009,8 +998,8 @@ # name: test_sensors[HWE-BAT-entity_ids11][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1035,7 +1024,6 @@ 'model_id': 'HWE-BAT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '1.00', 'via_device_id': None, @@ -1094,8 +1082,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1120,7 +1108,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1187,8 +1174,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1213,7 +1200,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1280,8 +1266,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1306,7 +1292,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1373,8 +1358,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1399,7 +1384,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1466,8 +1450,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1492,7 +1476,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1559,8 +1542,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1585,7 +1568,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1652,8 +1634,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1678,7 +1660,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1742,8 +1723,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1768,7 +1749,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1835,8 +1815,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1861,7 +1841,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -1928,8 +1907,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -1954,7 +1933,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2021,8 +1999,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2047,7 +2025,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2106,8 +2083,8 @@ # name: test_sensors[HWE-KWH1-entity_ids8][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2132,7 +2109,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2195,8 +2171,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2221,7 +2197,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2288,8 +2263,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2314,7 +2289,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2381,8 +2355,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2407,7 +2381,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2474,8 +2447,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_apparent_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2500,7 +2473,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2567,8 +2539,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2593,7 +2565,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2660,8 +2631,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2686,7 +2657,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2753,8 +2723,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2779,7 +2749,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2846,8 +2815,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2872,7 +2841,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -2939,8 +2907,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -2965,7 +2933,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3032,8 +2999,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3058,7 +3025,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3125,8 +3091,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3151,7 +3117,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3218,8 +3183,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3244,7 +3209,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3311,8 +3275,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3337,7 +3301,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3401,8 +3364,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3427,7 +3390,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3491,8 +3453,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_factor_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3517,7 +3479,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3581,8 +3542,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3607,7 +3568,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3674,8 +3634,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3700,7 +3660,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3767,8 +3726,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3793,7 +3752,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3860,8 +3818,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3886,7 +3844,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -3953,8 +3910,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -3979,7 +3936,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4046,8 +4002,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4072,7 +4028,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4139,8 +4094,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4165,7 +4120,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4232,8 +4186,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_reactive_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4258,7 +4212,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4325,8 +4278,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4351,7 +4304,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4418,8 +4370,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4444,7 +4396,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4511,8 +4462,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4537,7 +4488,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4604,8 +4554,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4630,7 +4580,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4689,8 +4638,8 @@ # name: test_sensors[HWE-KWH3-entity_ids9][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4715,7 +4664,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -4778,8 +4726,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4804,7 +4752,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -4868,8 +4815,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_battery_group_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4894,7 +4841,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -4961,8 +4907,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_battery_group_target_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -4987,7 +4933,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5054,8 +4999,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5080,7 +5025,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5147,8 +5091,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5173,7 +5117,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5240,8 +5183,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5266,7 +5209,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5333,8 +5275,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5359,7 +5301,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5418,8 +5359,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5444,7 +5385,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5511,8 +5451,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5537,7 +5477,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5604,8 +5543,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5630,7 +5569,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5697,8 +5635,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5723,7 +5661,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5790,8 +5727,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5816,7 +5753,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5883,8 +5819,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -5909,7 +5845,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -5976,8 +5911,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6002,7 +5937,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6069,8 +6003,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6095,7 +6029,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6162,8 +6095,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6188,7 +6121,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6255,8 +6187,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6281,7 +6213,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6348,8 +6279,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6374,7 +6305,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6441,8 +6371,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6467,7 +6397,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6526,8 +6455,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6552,7 +6481,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6616,8 +6544,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6642,7 +6570,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6709,8 +6636,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6735,7 +6662,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6794,8 +6720,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6820,7 +6746,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6887,8 +6812,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -6913,7 +6838,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -6980,8 +6904,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7006,7 +6930,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7073,8 +6996,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7099,7 +7022,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7158,8 +7080,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7184,7 +7106,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7243,8 +7164,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7269,7 +7190,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7342,8 +7262,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7368,7 +7288,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7435,8 +7354,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7461,7 +7380,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7528,8 +7446,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7554,7 +7472,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7621,8 +7538,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7647,7 +7564,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7714,8 +7630,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7740,7 +7656,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7799,8 +7714,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7825,7 +7740,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7884,8 +7798,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7910,7 +7824,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -7969,8 +7882,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -7995,7 +7908,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8054,8 +7966,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8080,7 +7992,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8139,8 +8050,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8165,7 +8076,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8224,8 +8134,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8250,7 +8160,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8317,8 +8226,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8343,7 +8252,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8402,8 +8310,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8428,7 +8336,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -8491,8 +8398,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8513,7 +8420,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_G001', 'sw_version': None, 'via_device_id': , @@ -8580,8 +8486,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8602,7 +8508,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_H001', 'sw_version': None, 'via_device_id': , @@ -8669,8 +8574,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8691,7 +8596,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_IH001', 'sw_version': None, 'via_device_id': , @@ -8754,8 +8658,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8776,7 +8680,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_WW001', 'sw_version': None, 'via_device_id': , @@ -8843,8 +8746,8 @@ # name: test_sensors[HWE-P1-entity_ids0][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8865,7 +8768,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_W001', 'sw_version': None, 'via_device_id': , @@ -8932,8 +8834,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -8958,7 +8860,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9022,8 +8923,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9048,7 +8949,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9115,8 +9015,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9141,7 +9041,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9208,8 +9107,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9234,7 +9133,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9301,8 +9199,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9327,7 +9225,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9386,8 +9283,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9412,7 +9309,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9479,8 +9375,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9505,7 +9401,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9572,8 +9467,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9598,7 +9493,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9665,8 +9559,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9691,7 +9585,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9758,8 +9651,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9784,7 +9677,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9851,8 +9743,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9877,7 +9769,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -9944,8 +9835,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -9970,7 +9861,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10037,8 +9927,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10063,7 +9953,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10130,8 +10019,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10156,7 +10045,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10223,8 +10111,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10249,7 +10137,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10316,8 +10203,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10342,7 +10229,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10409,8 +10295,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10435,7 +10321,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10494,8 +10379,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10520,7 +10405,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10584,8 +10468,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10610,7 +10494,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10677,8 +10560,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10703,7 +10586,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10762,8 +10644,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10788,7 +10670,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10855,8 +10736,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10881,7 +10762,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -10948,8 +10828,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -10974,7 +10854,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11041,8 +10920,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11067,7 +10946,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11126,8 +11004,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11152,7 +11030,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11211,8 +11088,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11237,7 +11114,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11310,8 +11186,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11336,7 +11212,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11403,8 +11278,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11429,7 +11304,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11496,8 +11370,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11522,7 +11396,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11589,8 +11462,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11615,7 +11488,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11682,8 +11554,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11708,7 +11580,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11767,8 +11638,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11793,7 +11664,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11852,8 +11722,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11878,7 +11748,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -11937,8 +11806,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -11963,7 +11832,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12022,8 +11890,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12048,7 +11916,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12107,8 +11974,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12133,7 +12000,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12192,8 +12058,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12218,7 +12084,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12285,8 +12150,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12311,7 +12176,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12370,8 +12234,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12396,7 +12260,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12459,8 +12322,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12481,7 +12344,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12548,8 +12410,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12570,7 +12432,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12637,8 +12498,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12659,7 +12520,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12722,8 +12582,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12744,7 +12604,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12811,8 +12670,8 @@ # name: test_sensors[HWE-P1-invalid-EAN-entity_ids10][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -12833,7 +12692,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 'sw_version': None, 'via_device_id': , @@ -12900,8 +12758,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -12926,7 +12784,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -12990,8 +12847,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_battery_group_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13016,7 +12873,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13083,8 +12939,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_battery_group_target_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13109,7 +12965,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13176,8 +13031,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13202,7 +13057,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13269,8 +13123,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13295,7 +13149,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13362,8 +13215,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13388,7 +13241,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13455,8 +13307,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_dsmr_version:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13481,7 +13333,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13540,8 +13391,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13566,7 +13417,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13633,8 +13483,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13659,7 +13509,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13726,8 +13575,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13752,7 +13601,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13819,8 +13667,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13845,7 +13693,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -13912,8 +13759,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -13938,7 +13785,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14005,8 +13851,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14031,7 +13877,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14098,8 +13943,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14124,7 +13969,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14191,8 +14035,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14217,7 +14061,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14284,8 +14127,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14310,7 +14153,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14377,8 +14219,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14403,7 +14245,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14470,8 +14311,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14496,7 +14337,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14563,8 +14403,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14589,7 +14429,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14648,8 +14487,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_peak_demand_current_month:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14674,7 +14513,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14738,8 +14576,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14764,7 +14602,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14831,8 +14668,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14857,7 +14694,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -14916,8 +14752,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -14942,7 +14778,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15009,8 +14844,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15035,7 +14870,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15102,8 +14936,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15128,7 +14962,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15195,8 +15028,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_smart_meter_identifier:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15221,7 +15054,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15280,8 +15112,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_smart_meter_model:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15306,7 +15138,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15365,8 +15196,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_tariff:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15391,7 +15222,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15464,8 +15294,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15490,7 +15320,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15557,8 +15386,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15583,7 +15412,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15650,8 +15478,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15676,7 +15504,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15743,8 +15570,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15769,7 +15596,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15836,8 +15662,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15862,7 +15688,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -15921,8 +15746,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -15947,7 +15772,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16006,8 +15830,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16032,7 +15856,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16091,8 +15914,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16117,7 +15940,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16176,8 +15998,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16202,7 +16024,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16261,8 +16082,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16287,7 +16108,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16346,8 +16166,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16372,7 +16192,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16439,8 +16258,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16465,7 +16284,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16524,8 +16342,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -16550,7 +16368,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -16613,8 +16430,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.gas_meter_gas:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16635,7 +16452,6 @@ 'model_id': None, 'name': 'Gas meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'gas_meter_G001', 'sw_version': None, 'via_device_id': , @@ -16702,8 +16518,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.heat_meter_energy:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16724,7 +16540,6 @@ 'model_id': None, 'name': 'Heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'heat_meter_H001', 'sw_version': None, 'via_device_id': , @@ -16791,8 +16606,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.inlet_heat_meter:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16813,7 +16628,6 @@ 'model_id': None, 'name': 'Inlet heat meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'inlet_heat_meter_IH001', 'sw_version': None, 'via_device_id': , @@ -16876,8 +16690,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.warm_water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16898,7 +16712,6 @@ 'model_id': None, 'name': 'Warm water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'warm_water_meter_WW001', 'sw_version': None, 'via_device_id': , @@ -16965,8 +16778,8 @@ # name: test_sensors[HWE-P1-predictive-entity_ids1][sensor.water_meter_water:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -16987,7 +16800,6 @@ 'model_id': None, 'name': 'Water meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'water_meter_W001', 'sw_version': None, 'via_device_id': , @@ -17054,8 +16866,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17080,7 +16892,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17144,8 +16955,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17170,7 +16981,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17237,8 +17047,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17263,7 +17073,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17330,8 +17139,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17356,7 +17165,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17423,8 +17231,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17449,7 +17257,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17516,8 +17323,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17542,7 +17349,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17609,8 +17415,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17635,7 +17441,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17702,8 +17507,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17728,7 +17533,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17795,8 +17599,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_export_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17821,7 +17625,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17888,8 +17691,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -17914,7 +17717,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -17981,8 +17783,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18007,7 +17809,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18074,8 +17875,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18100,7 +17901,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18167,8 +17967,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18193,7 +17993,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18260,8 +18059,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_energy_import_tariff_4:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18286,7 +18085,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18353,8 +18151,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18379,7 +18177,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18446,8 +18243,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_long_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18472,7 +18269,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18531,8 +18327,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18557,7 +18353,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18624,8 +18419,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_failures_detected:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18650,7 +18445,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18709,8 +18503,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18735,7 +18529,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18802,8 +18595,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18828,7 +18621,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18895,8 +18687,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -18921,7 +18713,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -18988,8 +18779,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19014,7 +18805,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19081,8 +18871,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19107,7 +18897,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19174,8 +18963,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19200,7 +18989,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19267,8 +19055,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19293,7 +19081,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19360,8 +19147,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19386,7 +19173,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19445,8 +19231,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19471,7 +19257,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19530,8 +19315,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_sags_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19556,7 +19341,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19615,8 +19399,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19641,7 +19425,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19700,8 +19483,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19726,7 +19509,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19785,8 +19567,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_voltage_swells_detected_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19811,7 +19593,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19870,8 +19651,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19896,7 +19677,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -19963,8 +19743,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -19989,7 +19769,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -20048,8 +19827,8 @@ # name: test_sensors[HWE-P1-zero-values-entity_ids2][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20074,7 +19853,6 @@ 'model_id': 'HWE-P1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.19', 'via_device_id': None, @@ -20137,8 +19915,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20163,7 +19941,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20230,8 +20007,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20256,7 +20033,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20323,8 +20099,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20349,7 +20125,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20416,8 +20191,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20442,7 +20217,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20509,8 +20283,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20535,7 +20309,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20602,8 +20375,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20628,7 +20401,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20687,8 +20459,8 @@ # name: test_sensors[HWE-SKT-11-entity_ids3][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20713,7 +20485,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -20776,8 +20547,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20802,7 +20573,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -20869,8 +20639,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20895,7 +20665,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -20962,8 +20731,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -20988,7 +20757,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21055,8 +20823,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21081,7 +20849,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21148,8 +20915,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21174,7 +20941,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21241,8 +21007,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21267,7 +21033,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21334,8 +21099,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21360,7 +21125,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21424,8 +21188,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21450,7 +21214,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21517,8 +21280,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21543,7 +21306,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21610,8 +21372,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21636,7 +21398,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21703,8 +21464,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21729,7 +21490,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21796,8 +21556,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21822,7 +21582,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21881,8 +21640,8 @@ # name: test_sensors[HWE-SKT-21-entity_ids4][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21907,7 +21666,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -21970,8 +21728,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_total_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -21996,7 +21754,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22063,8 +21820,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_water_usage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22089,7 +21846,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22156,8 +21912,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22182,7 +21938,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22241,8 +21996,8 @@ # name: test_sensors[HWE-WTR-entity_ids5][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22267,7 +22022,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -22330,8 +22084,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22356,7 +22110,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22423,8 +22176,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22449,7 +22202,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22516,8 +22268,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22542,7 +22294,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22609,8 +22360,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22635,7 +22386,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22702,8 +22452,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22728,7 +22478,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22795,8 +22544,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22821,7 +22570,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22888,8 +22636,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_power_factor:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -22914,7 +22662,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -22978,8 +22725,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23004,7 +22751,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23071,8 +22817,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23097,7 +22843,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23164,8 +22909,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23190,7 +22935,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23257,8 +23001,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23283,7 +23027,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23342,8 +23085,8 @@ # name: test_sensors[SDM230-entity_ids6][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23368,7 +23111,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23431,8 +23173,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23457,7 +23199,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23524,8 +23265,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23550,7 +23291,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23617,8 +23357,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23643,7 +23383,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23710,8 +23449,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_apparent_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23736,7 +23475,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23803,8 +23541,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23829,7 +23567,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23896,8 +23633,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -23922,7 +23659,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -23989,8 +23725,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24015,7 +23751,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24082,8 +23817,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_current_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24108,7 +23843,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24175,8 +23909,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24201,7 +23935,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24268,8 +24001,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_energy_import:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24294,7 +24027,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24361,8 +24093,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_frequency:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24387,7 +24119,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24454,8 +24185,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24480,7 +24211,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24547,8 +24277,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24573,7 +24303,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24637,8 +24366,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24663,7 +24392,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24727,8 +24455,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_factor_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24753,7 +24481,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24817,8 +24544,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24843,7 +24570,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -24910,8 +24636,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24936,7 +24662,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25003,8 +24728,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25029,7 +24754,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25096,8 +24820,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_production_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25122,7 +24846,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25189,8 +24912,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25215,7 +24938,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25282,8 +25004,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25308,7 +25030,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25375,8 +25096,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25401,7 +25122,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25468,8 +25188,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_reactive_power_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25494,7 +25214,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25561,8 +25280,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_1:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25587,7 +25306,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25654,8 +25372,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_2:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25680,7 +25398,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25747,8 +25464,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_voltage_phase_3:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25773,7 +25490,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25840,8 +25556,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_wi_fi_ssid:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25866,7 +25582,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -25925,8 +25640,8 @@ # name: test_sensors[SDM630-entity_ids7][sensor.device_wi_fi_strength:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -25951,7 +25666,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index d5d2a7ed1ebe..eb2a6c0e5483 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_switch_entities[HWE-KWH1-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -78,7 +78,6 @@ 'model_id': 'HWE-KWH1', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -137,8 +136,8 @@ # name: test_switch_entities[HWE-KWH3-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -163,7 +162,6 @@ 'model_id': 'HWE-KWH3', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -223,8 +221,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -249,7 +247,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -308,8 +305,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -334,7 +331,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -393,8 +389,8 @@ # name: test_switch_entities[HWE-SKT-11-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -419,7 +415,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.03', 'via_device_id': None, @@ -479,8 +474,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device-state-power_on].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -505,7 +500,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -564,8 +558,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -590,7 +584,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -649,8 +642,8 @@ # name: test_switch_entities[HWE-SKT-21-switch.device_switch_lock-state-switch_lock].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -675,7 +668,6 @@ 'model_id': 'HWE-SKT', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '4.07', 'via_device_id': None, @@ -734,8 +726,8 @@ # name: test_switch_entities[HWE-WTR-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -760,7 +752,6 @@ 'model_id': 'HWE-WTR', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '2.03', 'via_device_id': None, @@ -819,8 +810,8 @@ # name: test_switch_entities[SDM230-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -845,7 +836,6 @@ 'model_id': 'SDM230-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, @@ -904,8 +894,8 @@ # name: test_switch_entities[SDM630-switch.device_cloud_connection-system-cloud_enabled].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -930,7 +920,6 @@ 'model_id': 'SDM630-wifi', 'name': 'Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '5c2fafabcdef', 'sw_version': '3.06', 'via_device_id': None, diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index ac24876413d7..1d18a527e0c9 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -196,7 +196,10 @@ async def test_remove_stale_device( assert len(device_entries) == 2 assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) assert any((DOMAIN, 7654321) in device.identifiers for device in device_entries) - assert any( + # Identifiers are unique per config entry, so Honeywell and OtherDomain have + # separate devices for 7654321; Honeywell's devices do not carry the OtherDomain + # identifier + assert not any( ("OtherDomain", 7654321) in device.identifiers for device in device_entries ) assert len(device_entries_other) == 1 diff --git a/tests/components/html5/test_event.py b/tests/components/html5/test_event.py index cd4be641f355..25e783df5e6b 100644 --- a/tests/components/html5/test_event.py +++ b/tests/components/html5/test_event.py @@ -9,12 +9,13 @@ from aiohttp.hdrs import AUTHORIZATION import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.html5.const import DOMAIN from homeassistant.components.html5.notify import ATTR_ACTION, ATTR_TAG, ATTR_TYPE from homeassistant.components.notify import ATTR_DATA, ATTR_TARGET from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN, 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 homeassistant.setup import async_setup_component from .test_notify import SUBSCRIPTION_1 @@ -118,3 +119,82 @@ async def test_events( assert state.attributes.get("action") == event_payload.get(ATTR_ACTION) assert state.attributes.get("tag") == event_payload[ATTR_TAG] assert state.attributes.get("customKey") == event_payload[ATTR_DATA]["customKey"] + + +@pytest.mark.parametrize("event_type", ["clicked", "received", "closed"]) +@pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid") +async def test_deprecation_event_bus( + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, + event_type: str, +) -> None: + """Test deprecation of events on the event bus.""" + load_config.return_value = {"device": SUBSCRIPTION_1} + await async_setup_component(hass, "http", {}) + + 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 + + config_entry.async_on_unload( + hass.bus.async_listen(f"html5_notification.{event_type}", lambda _: None) + ) + client = await hass_client() + + resp = await client.post( + "/api/notify.html5/callback", + json={"type": event_type, "tag": "12345", "target": "device"}, + headers={AUTHORIZATION: "Bearer JWT"}, + ) + + assert resp.status == HTTPStatus.OK + body = await resp.json() + assert body == {"event": event_type, "status": "ok"} + + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_event_bus_html5_notification.{event_type}", + ) + + +@pytest.mark.parametrize("event_type", ["clicked", "received", "closed"]) +@pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid") +async def test_deprecation_event_bus_no_listeners( + hass: HomeAssistant, + config_entry: MockConfigEntry, + load_config: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, + event_type: str, +) -> None: + """Test no issue is created when there are no listeners.""" + load_config.return_value = {"device": SUBSCRIPTION_1} + await async_setup_component(hass, "http", {}) + + 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() + + resp = await client.post( + "/api/notify.html5/callback", + json={"type": event_type, "tag": "12345", "target": "device"}, + headers={AUTHORIZATION: "Bearer JWT"}, + ) + + assert resp.status == HTTPStatus.OK + body = await resp.json() + assert body == {"event": event_type, "status": "ok"} + + assert not issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_event_bus_html5_notification.{event_type}", + ) diff --git a/tests/components/http/test_const.py b/tests/components/http/test_const.py new file mode 100644 index 000000000000..c921b18cbab2 --- /dev/null +++ b/tests/components/http/test_const.py @@ -0,0 +1,52 @@ +"""Tests for the HTTP const helpers.""" + +from pathlib import Path +from unittest.mock import MagicMock + +from aiohttp import web +from aiohttp.test_utils import make_mocked_request + +from homeassistant.components.http.const import ( + KEY_SUPERVISOR_UNIX_SOCKET, + is_supervisor_unix_socket_request, +) +from homeassistant.helpers.http import KEY_HASS + + +def _make_request(supervisor_path: Path | None, sockname: str | None) -> web.Request: + """Build a mocked request with the given supervisor socket configuration.""" + app = web.Application() + hass = MagicMock() + hass.http.supervisor_unix_socket_path = supervisor_path + app[KEY_HASS] = hass + transport = MagicMock() + transport.get_extra_info.return_value = sockname + return make_mocked_request("GET", "/", app=app, transport=transport) + + +def test_supervisor_unix_socket_request_matches() -> None: + """Test a request over the Supervisor Unix socket is detected.""" + path = Path("/run/supervisor.sock") + request = _make_request(path, str(path)) + assert is_supervisor_unix_socket_request(request) is True + + +def test_supervisor_unix_socket_request_no_path_skips_transport() -> None: + """Test the transport is not probed when no socket path is configured.""" + request = _make_request(None, "/run/supervisor.sock") + transport = request.transport + transport.get_extra_info.reset_mock() + assert is_supervisor_unix_socket_request(request) is False + transport.get_extra_info.assert_not_called() + + +def test_supervisor_unix_socket_request_is_cached() -> None: + """Test the result is computed once and cached on the request.""" + path = Path("/run/supervisor.sock") + request = _make_request(path, str(path)) + transport = request.transport + transport.get_extra_info.reset_mock() + assert is_supervisor_unix_socket_request(request) is True + assert is_supervisor_unix_socket_request(request) is True + transport.get_extra_info.assert_called_once_with("sockname") + assert request[KEY_SUPERVISOR_UNIX_SOCKET] is True diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5b231c8c3687..bf5dcd6c0c5f 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,23 +1,35 @@ """The tests for the Home Assistant HTTP component.""" import asyncio -from collections.abc import Callable -from datetime import timedelta +from collections.abc import Callable, Generator +import errno from http import HTTPStatus -from ipaddress import ip_network import logging import os from pathlib import Path -from unittest.mock import ANY, Mock, patch +import socket +import ssl +from typing import Any +from unittest.mock import ANY, AsyncMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import cloud, http from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.http import DOMAIN -from homeassistant.const import HASSIO_USER_NAME +from homeassistant.components.http.config import ( + _DEFAULT_CONFIG, + AUTO_REVERT_DELAY, + HTTP_STORAGE_SCHEMA, + async_get_and_load_store, + default_server_port, +) +from homeassistant.components.http.const import ENV_SETUP_PORT +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError @@ -25,8 +37,12 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern -from tests.common import async_call_logger_set_level, async_fire_time_changed -from tests.typing import ClientSessionGenerator +from tests.common import ( + async_call_logger_set_level, + async_fire_time_changed, + async_mock_service, +) +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) @@ -38,6 +54,49 @@ def disable_http_server(socket_enabled: None) -> None: return +# The unpatched original, for tests that exercise the real implementation. +_REAL_CREATE_SERVER = http.HomeAssistantHTTP._async_create_server + + +async def _ephemeral_server(hass: HomeAssistant) -> asyncio.Server: + """Create a bound but not serving server on an ephemeral localhost port.""" + return await hass.loop.create_server( + asyncio.Protocol, "127.0.0.1", 0, start_serving=False + ) + + +@pytest.fixture(autouse=True) +def mock_create_server() -> Generator[Mock]: + """Bind an ephemeral localhost server instead of the configured address. + + Binding the configured address for real would make parallel tests collide + on ports; an ephemeral localhost server keeps the serving path real. + """ + servers: list[asyncio.Server] = [] + + async def _bind_ephemeral(self: http.HomeAssistantHTTP) -> asyncio.Server: + server = await self.hass.loop.create_server( + self._make_protocol, + "127.0.0.1", + 0, + ssl=self.context, + start_serving=False, + ) + servers.append(server) + return server + + with patch( + "homeassistant.components.http.HomeAssistantHTTP._async_create_server", + autospec=True, + side_effect=_bind_ephemeral, + ) as mock_create: + yield mock_create + + # Close any server that is not already closed (closing twice is a no-op). + for server in servers: + server.close() + + def _setup_broken_ssl_pem_files(tmp_path: Path) -> tuple[Path, Path]: test_dir = tmp_path / "test_broken_ssl" test_dir.mkdir() @@ -161,6 +220,31 @@ async def test_proxy_config(hass: HomeAssistant) -> None: ) +async def test_proxy_config_forwarded_request( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test a request with X-Forwarded-For from a trusted proxy is accepted. + + The config store persists trusted proxies as strings; this exercises the + forwarded middleware with a config loaded from the store to guard against + passing the strings through instead of IP network objects. + """ + hass_storage[DOMAIN] = _stable_http_storage( + { + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + } + ) + assert await async_setup_component(hass, "api", {}) + client = await hass_client() + + resp = await client.get("/api/", headers={"X-Forwarded-For": "203.0.113.5"}) + + assert resp.status == HTTPStatus.OK + + async def test_proxy_config_only_use_xff(hass: HomeAssistant) -> None: """Test use_x_forwarded_for must config together with trusted_proxies.""" assert ( @@ -315,26 +399,46 @@ async def test_peer_cert(hass: HomeAssistant, tmp_path: Path) -> None: assert len(mock_load_verify_locations.mock_calls) == 1 +def _stable_http_storage( + stable: dict, *, pending: dict | None = None, yaml_migration_done: bool = True +) -> dict: + """Build a hass_storage entry seeded with a confirmed-working stable config. + + ``stable`` (and ``pending`` if given) are normalised through the storage + schema, matching what real users have on disk after migration / writes — + the load path does direct key access and assumes the payload is complete. + """ + normalised_stable = dict(HTTP_STORAGE_SCHEMA(stable)) + normalised_pending = dict(HTTP_STORAGE_SCHEMA(pending)) if pending else None + return { + "version": 2, + "key": DOMAIN, + "data": { + "stable": normalised_stable, + "pending": normalised_pending, + "yaml_migration_done": yaml_migration_done, + }, + } + + async def test_emergency_ssl_certificate_when_invalid( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test http starts with emergency self-signed cert on invalid cert.""" cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - - hass.config.recovery_mode = True - assert ( - await async_setup_component( - hass, - DOMAIN, - { - "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, - }, - ) - is True + # In recovery mode YAML is ignored, so seed the broken SSL paths into the + # store's stable slot — that's the only config recovery mode will look at. + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} ) + hass.config.recovery_mode = True + assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() await hass.async_block_till_done() @@ -344,28 +448,36 @@ async def test_emergency_ssl_certificate_when_invalid( " certificate was not usable" in caplog.text ) - assert hass.http.site is not None + assert hass.http._server is not None async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: - """Test an emergency cert is only used in recovery mode.""" + """Test an emergency cert is only used in recovery mode. + + A broken SSL config in the stable slot fails setup (activating recovery + mode on a real boot); only recovery mode uses the emergency certificate. + """ cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - - assert ( - await async_setup_component( - hass, DOMAIN, {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}} - ) - is False + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} ) + assert await async_setup_component(hass, DOMAIN, {}) is False + async def test_emergency_ssl_certificate_when_invalid_get_url_fails( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test http falls back to no ssl when emergency cert creation fails. @@ -374,21 +486,15 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} + ) hass.config.recovery_mode = True with patch( "homeassistant.components.http.get_url", side_effect=NoURLAvailableError ) as mock_get_url: - assert ( - await async_setup_component( - hass, - DOMAIN, - { - "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, - }, - ) - is True - ) + assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() await hass.async_block_till_done() @@ -399,42 +505,42 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( " certificate was not usable" in caplog.text ) - assert hass.http.site is not None + assert hass.http._server is not None async def test_invalid_ssl_and_cannot_create_emergency_cert( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test http falls back to no ssl on emergency cert creation failure.""" cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) + hass_storage[DOMAIN] = _stable_http_storage( + {"ssl_certificate": str(cert_path), "ssl_key": str(key_path)} + ) hass.config.recovery_mode = True with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError ) as mock_builder: - assert ( - await async_setup_component( - hass, - DOMAIN, - { - "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, - }, - ) - is True - ) + assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() await hass.async_block_till_done() assert "Could not create an emergency self signed ssl certificate" in caplog.text assert len(mock_builder.mock_calls) == 1 - assert hass.http.site is not None + assert hass.http._server is not None async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], ) -> None: """Test no-ssl fallback with peer cert when emergency cert fails. @@ -442,36 +548,95 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( an emergency cert (probably will never happen since this means the system is very broken), we do not want to startup http as it would allow connections that are not verified by the cert. + This intentionally overrides the recovery-mode fallback to the default + config: connections must never be accepted without client certificate + verification once it is configured. """ cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) + hass_storage[DOMAIN] = _stable_http_storage( + { + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + "ssl_peer_certificate": str(cert_path), + } + ) hass.config.recovery_mode = True with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError ) as mock_builder: - assert ( - await async_setup_component( - hass, - DOMAIN, - { - "http": { - "ssl_certificate": cert_path, - "ssl_key": key_path, - "ssl_peer_certificate": cert_path, - }, - }, - ) - is False - ) + assert await async_setup_component(hass, DOMAIN, {}) is False await hass.async_start() await hass.async_block_till_done() assert "Could not create an emergency self signed ssl certificate" in caplog.text assert len(mock_builder.mock_calls) == 1 +async def test_emergency_ssl_certificate_enforces_peer_certificate( + hass: HomeAssistant, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + hass_storage: dict[str, Any], +) -> None: + """Test the emergency cert still enforces client certificate verification. + + When the configured SSL certificate is broken and recovery mode falls + back to the emergency self-signed certificate, a configured peer + certificate must still be applied - connections must never be accepted + without client certificate verification once it is configured. + """ + cert_path, key_path = await hass.async_add_executor_job( + _setup_broken_ssl_pem_files, tmp_path + ) + hass_storage[DOMAIN] = _stable_http_storage( + { + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + "ssl_peer_certificate": str(cert_path), + } + ) + hass.config.recovery_mode = True + + with patch("ssl.SSLContext.load_verify_locations") as mock_load_verify: + assert await async_setup_component(hass, DOMAIN, {}) is True + + assert "emergency self signed ssl certificate" in caplog.text + mock_load_verify.assert_called_once_with(str(cert_path)) + assert hass.http.context is not None + assert hass.http.context.verify_mode is ssl.CERT_REQUIRED + + +async def test_create_server_passes_configuration(hass: HomeAssistant) -> None: + """The real server factory passes the configured values to asyncio.""" + server = http.HomeAssistantHTTP( + hass, + server_host=["127.0.0.1", "::1"], + server_port=1234, + ssl_certificate=None, + ssl_peer_certificate=None, + ssl_key=None, + trusted_proxies=[], + ssl_profile=http.SSL_MODERN, + ) + + with patch.object( + hass.loop, "create_server", new=AsyncMock(return_value=Mock()) + ) as mock_create: + await _REAL_CREATE_SERVER(server) + + mock_create.assert_called_once_with( + server._make_protocol, + ["127.0.0.1", "::1"], + 1234, + ssl=None, + backlog=128, + start_serving=False, + ) + + async def test_cors_defaults(hass: HomeAssistant) -> None: """Test the CORS default settings.""" with patch("homeassistant.components.http.setup_cors") as mock_setup: @@ -481,31 +646,6 @@ async def test_cors_defaults(hass: HomeAssistant) -> None: assert mock_setup.mock_calls[0][1][1] == ["https://cast.home-assistant.io"] -async def test_storing_config( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - unused_tcp_port_factory: Callable[[], int], -) -> None: - """Test that we store last working config.""" - config = { - http.CONF_SERVER_PORT: unused_tcp_port_factory(), - "use_x_forwarded_for": True, - "trusted_proxies": ["192.168.1.100"], - } - - assert await async_setup_component(hass, http.DOMAIN, {http.DOMAIN: config}) - - await hass.async_start() - - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) - await hass.async_block_till_done() - - restored = await http.async_get_last_config(hass) - restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0]) - - assert restored == http.HTTP_SCHEMA(config) - - async def test_logging( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -691,16 +831,24 @@ async def test_ssl_issue_urls_configured( "expected_issues", ), [ - (False, {}, ["0.0.0.0", "::"], set()), - (False, {"server_host": "0.0.0.0"}, ["0.0.0.0"], set()), - (True, {}, ["0.0.0.0", "::"], set()), + (False, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}), + ( + False, + {"server_host": "0.0.0.0"}, + ["0.0.0.0"], + {("http", "deprecated_yaml")}, + ), + (True, {}, ["0.0.0.0", "::"], {("http", "deprecated_yaml")}), ( True, {"server_host": "0.0.0.0"}, [ "0.0.0.0", ], - {("http", "server_host_deprecated_hassio")}, + { + ("http", "server_host_deprecated_hassio"), + ("http", "deprecated_yaml"), + }, ), ], ) @@ -712,15 +860,10 @@ async def test_server_host( expected_serverhost: list, expected_issues: set[tuple[str, str]], caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, ) -> None: """Test server_host behavior.""" - mock_server = Mock() - with ( - patch("homeassistant.components.http.is_hassio", return_value=hassio), - patch( - "asyncio.BaseEventLoop.create_server", return_value=mock_server - ) as mock_create_server, - ): + with patch("homeassistant.components.http.is_hassio", return_value=hassio): assert await async_setup_component( hass, DOMAIN, @@ -729,15 +872,9 @@ async def test_server_host( await hass.async_start() await hass.async_block_till_done() - mock_create_server.assert_called_once_with( - ANY, - expected_serverhost, - 8123, - ssl=None, - backlog=128, - reuse_address=None, - reuse_port=None, - ) + mock_create_server.assert_called_once() + assert hass.http.server_host == expected_serverhost + assert hass.http.server_port == 8123 assert set(issue_registry.issues) == expected_issues @@ -757,7 +894,6 @@ async def test_unix_socket_started_with_supervisor( patch.dict( os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False ), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), patch( "homeassistant.components.http.web_runner.HomeAssistantUnixSite" "._create_unix_socket", @@ -782,7 +918,6 @@ async def test_unix_socket_not_started_without_supervisor( """Test unix socket is not started when not running under Supervisor.""" with ( patch.dict(os.environ, {}, clear=False), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None) assert await async_setup_component(hass, DOMAIN, {"http": {}}) @@ -803,7 +938,6 @@ async def test_unix_socket_rejected_relative_path( {"SUPERVISOR_CORE_API_SOCKET": "relative/path.sock"}, clear=False, ), - patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): assert await async_setup_component(hass, DOMAIN, {"http": {}}) await hass.async_start() @@ -811,3 +945,951 @@ async def test_unix_socket_rejected_relative_path( assert hass.http.supervisor_site is None assert "path must be absolute" in caplog.text + + +async def test_yaml_migration_to_storage( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """Test YAML config is migrated to the HTTP config store with a deprecation issue. + + With no prior store, the migration stages YAML in ``pending`` (stable stays + on the schema defaults). The pending slot is what HA boots from until the + user confirms / promotes it via the UI. + """ + yaml_conf = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + "ip_ban_enabled": False, + } + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue is not None + assert issue.severity is ir.IssueSeverity.WARNING + + assert ( + issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error") is None + ) + + stored = hass_storage[DOMAIN]["data"] + assert stored["yaml_migration_done"] is True + assert stored["stable"]["server_port"] == 8123 # untouched defaults + pending = stored["pending"] + assert pending is not None + assert pending["server_port"] == 9123 + assert pending["cors_allowed_origins"] == ["https://example.com"] + assert pending["trusted_proxies"] == ["127.0.0.0/8"] + assert pending["ip_ban_enabled"] is False + + +async def test_yaml_migration_matches_stable_no_pending( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """If the YAML matches the existing stable config, no pending should be created.""" + existing_stable = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + "ip_ban_enabled": False, + "login_attempts_threshold": -1, + "ssl_profile": "modern", + "use_x_frame_options": True, + } + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": { + "stable": existing_stable, + "pending": None, + "yaml_migration_done": False, + }, + } + + yaml_conf = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + "ip_ban_enabled": False, + } + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + stored = hass_storage[DOMAIN]["data"] + assert stored["pending"] is None + assert stored["stable"] == existing_stable + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue is not None + + +async def test_yaml_migration_differs_from_stable_creates_pending( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """If the YAML differs from the existing stable config, it must be stored as pending.""" + existing_stable = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "login_attempts_threshold": -1, + "ip_ban_enabled": True, + "ssl_profile": "modern", + "use_x_frame_options": True, + } + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": { + "stable": existing_stable, + "pending": None, + "yaml_migration_done": False, + }, + } + + yaml_conf = {"server_port": 8765, "ip_ban_enabled": False} + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + stored = hass_storage[DOMAIN]["data"] + assert stored["stable"] == existing_stable + assert stored["pending"] == { + "server_port": 8765, + "cors_allowed_origins": ["https://cast.home-assistant.io"], + "login_attempts_threshold": -1, + "ip_ban_enabled": False, + "ssl_profile": "modern", + "use_x_frame_options": True, + } + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") + assert issue is not None + + +async def test_yaml_migration_failure_creates_error_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that an error during YAML migration creates an error issue.""" + yaml_conf = {"server_port": 9123} + + with ( + patch( + "homeassistant.components.http.config.HTTPConfigStore.async_migrate_yaml", + side_effect=RuntimeError("boom"), + ), + ): + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error") + assert issue is not None + assert issue.severity is ir.IssueSeverity.ERROR + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None + + +async def test_yaml_still_present_after_migration_creates_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """When YAML lingers after migration, a repair issue is surfaced and YAML is ignored.""" + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, yaml_migration_done=True + ) + + yaml_conf = {"server_port": 1234} + assert await async_setup_component(hass, DOMAIN, {"http": yaml_conf}) + await hass.async_start() + await hass.async_block_till_done() + + # YAML must be ignored once migration is done; stable wins. + assert hass.config.api.port == 9876 + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration") + assert issue is not None + assert issue.severity is ir.IssueSeverity.WARNING + + +async def test_yaml_still_present_issue_cleared_when_yaml_removed( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """A previously created leftover-YAML issue is cleared once YAML is removed.""" + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, yaml_migration_done=True + ) + ir.async_create_issue( + hass, + DOMAIN, + "yaml_still_present_after_migration", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="yaml_still_present_after_migration", + ) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue(DOMAIN, "yaml_still_present_after_migration") + is None + ) + + +async def test_setup_uses_stable_config_when_no_yaml( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """Test HTTP config is loaded from the stable slot when no YAML or pending is set.""" + hass_storage[DOMAIN] = _stable_http_storage( + { + "server_port": 9876, + "cors_allowed_origins": ["https://stored.example.com"], + } + ) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + assert hass.config.api.port == 9876 + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None + assert ( + issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_import_error") is None + ) + + +async def test_setup_prefers_pending_over_stable_in_normal_mode( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Pending overrides stable on a normal boot so the new config gets tested.""" + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 9999} + ) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + assert hass.config.api.port == 9999 + + +async def test_recovery_mode_falls_back_to_stable( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """In recovery mode the pending config is ignored to keep HA reachable.""" + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 9999} + ) + hass.config.recovery_mode = True + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + assert hass.config.api.port == 9876 + + +async def test_recovery_mode_with_no_storage( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """Recovery mode with no prior storage starts HTTP on the schema defaults. + + This covers the first-ever-boot-into-recovery-mode case: bootstrap fell + through to recovery before HTTP ever had a chance to migrate YAML, so the + store is empty and we must come up cleanly on defaults. + """ + assert "http" not in hass_storage + hass.config.recovery_mode = True + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + assert hass.config.api.port == 8123 + # Recovery mode must not trigger YAML migration side effects. + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None + + +async def test_recovery_mode_ignores_yaml( + hass: HomeAssistant, + hass_storage: dict[str, Any], + issue_registry: ir.IssueRegistry, +) -> None: + """YAML config must not be applied or migrated while in recovery mode. + + The whole point of recovery mode is to ignore the user's (possibly bad) + config and fall back to the last known good ``stable`` slot. Migrating + YAML here would defeat that and could re-introduce the broken config. + """ + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 5555}, yaml_migration_done=False + ) + hass.config.recovery_mode = True + + assert await async_setup_component(hass, DOMAIN, {"http": {"server_port": 1234}}) + await hass.async_start() + await hass.async_block_till_done() + + # YAML's port must NOT win: stable is the only source of truth in recovery. + assert hass.config.api.port == 5555 + # The migration must not run in recovery mode, so its flag stays untouched + # and no deprecation issue is created on this boot. + assert hass_storage[DOMAIN]["data"]["yaml_migration_done"] is False + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") is None + + +async def test_setup_migrates_v1_storage_to_v2( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """An existing v1 store is migrated into the stable slot.""" + hass_storage[DOMAIN] = { + "version": 1, + "key": "http", + "data": {"server_port": 9876}, + } + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_start() + await hass.async_block_till_done() + + # The migrated v1 store config is only used in recovery mode. Since this + # test isn't running in recovery mode, the YAML migration runs on first + # boot after store migration. With no YAML http config, the default config is migrated to the pending slot and used. Therefore we assert below the default port (8123) + assert hass.config.api.port == 8123 + assert hass_storage[DOMAIN]["version"] == 2 + data = hass_storage[DOMAIN]["data"] + # The v1→v2 migration normalises the payload through the storage schema, + # so the v2 stable slot is well-formed (all keys present) on disk. + assert data["stable"]["server_port"] == 9876 + assert data["stable"]["ip_ban_enabled"] is True + assert data["pending"] == _DEFAULT_CONFIG + assert data["yaml_migration_done"] is True + + +@pytest.mark.parametrize( + ("env", "expected_port"), + [ + pytest.param({}, 8123, id="unset"), + pytest.param({ENV_SETUP_PORT: "80"}, 80, id="valid"), + pytest.param({ENV_SETUP_PORT: "0"}, 8123, id="out-of-range"), + pytest.param({ENV_SETUP_PORT: "notaport"}, 8123, id="not-a-number"), + pytest.param({ENV_SETUP_PORT: ""}, 8123, id="empty"), + ], +) +def test_default_server_port( + env: dict[str, str], + expected_port: int, +) -> None: + """Test SETUP_PORT overrides the default port and invalid values fall back.""" + with patch.dict(os.environ, env, clear=True): + assert default_server_port() == expected_port + + +async def test_setup_port_env_var_used_as_default( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test SETUP_PORT is used as the default server port without YAML config.""" + with ( + patch.dict(os.environ, {ENV_SETUP_PORT: "80"}), + ): + assert await async_setup_component(hass, "http", {}) + await hass.async_start() + await hass.async_block_till_done() + + assert hass.config.api.port == 80 + assert hass_storage["http"]["data"]["pending"]["server_port"] == 80 + + +async def test_websocket_http_config( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test the http/config, configure and promote websocket commands.""" + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + # Staging a new config triggers a restart so the pending config is applied. + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + # On a fresh setup the stable slot is seeded with the schema defaults and + # there is no pending config. + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "stable": _DEFAULT_CONFIG, + "pending": None, + "revert_at": None, + } + + new_config = { + "server_port": 9123, + "cors_allowed_origins": ["https://example.com"], + "use_x_forwarded_for": True, + "trusted_proxies": ["127.0.0.0/8"], + "ip_ban_enabled": False, + "login_attempts_threshold": 5, + "ssl_profile": "modern", + "use_x_frame_options": True, + } + await ws_client.send_json_auto_id( + {"type": "http/config/configure", "config": new_config} + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"restart": True} + pending = hass_storage["http"]["data"]["pending"] + assert pending["server_port"] == 9123 + assert pending["trusted_proxies"] == ["127.0.0.0/8"] + await hass.async_block_till_done() + assert len(restart_calls) == 1 + + # Stable is unchanged until the user promotes, but the pending config is + # now returned alongside it. + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "stable": _DEFAULT_CONFIG, + "pending": new_config, + "revert_at": None, + } + + # Promote: pending becomes stable, pending is cleared. + await ws_client.send_json_auto_id({"type": "http/config/promote"}) + response = await ws_client.receive_json() + assert response["success"] + assert hass_storage["http"]["data"]["pending"] is None + assert hass_storage["http"]["data"]["stable"]["server_port"] == 9123 + + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "stable": new_config, + "pending": None, + "revert_at": None, + } + + # Promoting again with no pending is rejected. + await ws_client.send_json_auto_id({"type": "http/config/promote"}) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "not_allowed" + + # Staging a different config again changes the pending slot -> restart. + await ws_client.send_json_auto_id( + {"type": "http/config/configure", "config": {"server_port": 7000}} + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"restart": True} + assert hass_storage["http"]["data"]["pending"]["server_port"] == 7000 + await hass.async_block_till_done() + assert len(restart_calls) == 2 + + # Clearing a previously staged config also changes the active config back + # to stable, so it must trigger a restart too. + await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"restart": True} + assert hass_storage["http"]["data"]["pending"] is None + assert hass_storage["http"]["data"]["stable"]["server_port"] == 9123 + await hass.async_block_till_done() + assert len(restart_calls) == 3 + + # Clearing again when there is no pending config is a no-op -> no restart. + await ws_client.send_json_auto_id({"type": "http/config/configure", "config": None}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"restart": False} + await hass.async_block_till_done() + assert len(restart_calls) == 3 + + +async def test_pending_config_auto_reverts_to_stable( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """A loaded pending config reverts to stable if it is not confirmed in time.""" + hass_storage["http"] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 9999} + ) + + # A revert clears the pending config and restarts to apply stable. + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + # The revert deadline is anchored to the (frozen) load time. + revert_at = dt_util.utcnow() + AUTO_REVERT_DELAY + + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + # While the unconfirmed pending config is active, a revert deadline is + # returned alongside it. + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}), + "pending": HTTP_STORAGE_SCHEMA({"server_port": 9999}), + "revert_at": revert_at.isoformat(), + } + + # After the delay elapses without a promotion, pending is dropped and a + # restart is requested so the stable config is applied. + freezer.tick(AUTO_REVERT_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}), + "pending": None, + "yaml_migration_done": True, + } + assert len(restart_calls) == 1 + + +@pytest.mark.parametrize( + "bind_error", + [ + OSError(errno.EADDRINUSE, "Address already in use"), + PermissionError(errno.EACCES, "Permission denied"), + socket.gaierror(socket.EAI_NONAME, "Name or service not known"), + ], + ids=["address-in-use", "permission-denied", "unresolvable-host"], +) +async def test_pending_config_reverted_in_place_on_bind_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, + bind_error: OSError, +) -> None: + """A pending config that cannot be bound is reverted within the same start. + + The trial fails while the config is realized during setup, so the stable + config is applied in place - no restart, no waiting out the trial window. + """ + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 80} + ) + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + stable_server = await _ephemeral_server(hass) + mock_create_server.side_effect = [bind_error, stable_server] + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # The pending config is dropped and this same start continues on stable. + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9876}), + "pending": None, + "yaml_migration_done": True, + } + assert hass.config.api is not None + assert hass.config.api.port == 9876 + # The second bind attempt was for the stable config. + assert mock_create_server.call_args_list[1].args[0].server_port == 9876 + # No restart is involved and no revert stays scheduled. + assert len(restart_calls) == 0 + store = await async_get_and_load_store(hass) + assert store.revert_deadline is None + assert "could not be applied, reverting" in caplog.text + assert "previous HTTP configuration has been restored (server port 9876)" in ( + caplog.text + ) + stable_server.close() + await stable_server.wait_closed() + + +async def test_pending_config_reverted_in_place_on_ssl_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """A pending config whose SSL certificate is unusable reverts in place.""" + stable = dict(HTTP_STORAGE_SCHEMA({"server_port": 9876})) + # Craft the raw storage payload: the schema validates that the SSL files + # exist when the config is set, but they can vanish before the next start. + pending = dict(HTTP_STORAGE_SCHEMA({"server_port": 9999})) + pending["ssl_certificate"] = "/nonexistent/cert.pem" + pending["ssl_key"] = "/nonexistent/key.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": pending, "yaml_migration_done": True}, + } + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"]["pending"] is None + assert hass.config.api is not None + assert hass.config.api.port == 9876 + assert hass.config.api.use_ssl is False + assert len(restart_calls) == 0 + + +async def test_pending_config_reverted_in_place_on_ssl_peer_cert_failure( + hass: HomeAssistant, + hass_storage: dict[str, Any], + tmp_path: Path, +) -> None: + """A pending config whose SSL peer certificate is unusable reverts in place.""" + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + stable = dict(HTTP_STORAGE_SCHEMA({"server_port": 9876})) + pending = dict( + HTTP_STORAGE_SCHEMA( + { + "server_port": 9999, + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + } + ) + ) + # The peer certificate vanished after the config was stored. + pending["ssl_peer_certificate"] = "/nonexistent/peer.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": pending, "yaml_migration_done": True}, + } + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + with patch("ssl.SSLContext.load_cert_chain"): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"]["pending"] is None + assert hass.config.api is not None + assert hass.config.api.port == 9876 + assert hass.config.api.use_ssl is False + assert len(restart_calls) == 0 + + +async def test_stable_config_ssl_peer_cert_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + tmp_path: Path, +) -> None: + """A stable config whose SSL peer certificate is unusable fails setup. + + An unusable stable SSL configuration must fail setup, activating recovery + mode on a real boot. + """ + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + stable = dict( + HTTP_STORAGE_SCHEMA( + { + "server_port": 9876, + "ssl_certificate": str(cert_path), + "ssl_key": str(key_path), + } + ) + ) + stable["ssl_peer_certificate"] = "/nonexistent/peer.pem" + hass_storage[DOMAIN] = { + "version": 2, + "key": DOMAIN, + "data": {"stable": stable, "pending": None, "yaml_migration_done": True}, + } + + with patch("ssl.SSLContext.load_cert_chain"): + assert await async_setup_component(hass, DOMAIN, {}) is False + + +async def test_bound_server_closed_on_stop_before_start( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """A bound server is closed on stop even if it never started serving. + + If setup fails after binding (or recovery mode tears Home Assistant down + before serving starts), the stop event must close the server so a + follow-up boot in the same process can bind the address again. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 9876}) + + server = await _ephemeral_server(hass) + mock_create_server.side_effect = [server] + + with patch.object( + http.HomeAssistantHTTP, + "async_initialize", + side_effect=HomeAssistantError("Setup failed after binding"), + ): + assert not await async_setup_component(hass, DOMAIN, {}) + + assert server.sockets + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert not server.sockets + + +async def test_stable_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """A stable config that cannot be bound fails setup. + + Failing setup activates recovery mode on a real boot, which retries with + the stable config and falls back to the default config, so Home Assistant + stays reachable. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert len(restart_calls) == 0 + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 80}), + "pending": None, + "yaml_migration_done": True, + } + + +async def test_pending_and_stable_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_create_server: Mock, +) -> None: + """Setup fails when the trialed pending and the stable config cannot bind. + + The pending config must already be cleared and persisted, so the recovery + boot and future normal starts use stable instead of re-trialing it. + """ + hass_storage[DOMAIN] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 80} + ) + + mock_create_server.side_effect = [ + OSError(errno.EADDRINUSE, "Address already in use"), + OSError(errno.EADDRINUSE, "Address already in use"), + ] + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert hass_storage["http"]["data"]["pending"] is None + + +async def test_create_server_normalizes_unencodable_host( + hass: HomeAssistant, +) -> None: + """A host name the IDNA codec cannot encode raises OSError. + + create_server() raises UnicodeError (a ValueError) for such host names, + e.g. a label longer than 63 characters; it must be normalized to OSError + so the config fallback chain handles it like any other bind failure. + """ + server = http.HomeAssistantHTTP( + hass, + server_host=[f"{'x' * 64}.example"], + server_port=8123, + ssl_certificate=None, + ssl_peer_certificate=None, + ssl_key=None, + trusted_proxies=[], + ssl_profile=http.SSL_MODERN, + ) + with ( + patch.object( + hass.loop, + "create_server", + side_effect=UnicodeError( + "encoding with 'idna' codec failed (UnicodeError: label too long)" + ), + ), + pytest.raises(OSError, match="error while resolving host"), + ): + await _REAL_CREATE_SERVER(server) + + +async def test_recovery_mode_bind_failure_falls_back_to_default_config( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, +) -> None: + """In recovery mode an unbindable stable config falls back to defaults. + + Recovery mode is the last resort and must not fail setup again, so the + default config is applied in place to keep the recovery UI reachable. + The stable config is left untouched. + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + hass.config.recovery_mode = True + + default_server = await _ephemeral_server(hass) + mock_create_server.side_effect = [ + OSError(errno.EADDRINUSE, "Address already in use"), + default_server, + ] + + assert await async_setup_component(hass, DOMAIN, {}) + + assert "falling back to the default configuration" in caplog.text + assert hass.config.api is not None + assert hass.config.api.port == default_server_port() + # The second bind attempt was for the default config. + assert mock_create_server.call_args_list[1].args[0].server_port == ( + default_server_port() + ) + assert hass_storage["http"]["data"]["stable"] == HTTP_STORAGE_SCHEMA( + {"server_port": 80} + ) + default_server.close() + await default_server.wait_closed() + + +async def test_recovery_mode_default_config_bind_failure_fails_setup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, + mock_create_server: Mock, +) -> None: + """Setup fails in recovery mode when even the default config cannot bind. + + The fallback chain is exhausted; failing setup makes the failure visible + to the outside (e.g. the Supervisor rolls back a Core update whose API + does not come up). + """ + hass_storage[DOMAIN] = _stable_http_storage({"server_port": 80}) + hass.config.recovery_mode = True + + mock_create_server.side_effect = OSError(errno.EADDRINUSE, "Address already in use") + + assert not await async_setup_component(hass, DOMAIN, {}) + + assert f"Failed to create HTTP server at port {default_server_port()}" in ( + caplog.text + ) + + +async def test_pending_config_promote_cancels_revert( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Promoting a pending config cancels the scheduled revert.""" + hass_storage["http"] = _stable_http_storage( + {"server_port": 9876}, pending={"server_port": 9999} + ) + + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + # Confirm the pending config before the revert fires. + await ws_client.send_json_auto_id({"type": "http/config/promote"}) + response = await ws_client.receive_json() + assert response["success"] + + # The deadline is cleared once the config is confirmed. + await ws_client.send_json_auto_id({"type": "http/config"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9999}), + "pending": None, + "revert_at": None, + } + + # The cancelled revert must not fire after the delay. + freezer.tick(AUTO_REVERT_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass_storage["http"]["data"] == { + "stable": HTTP_STORAGE_SCHEMA({"server_port": 9999}), + "pending": None, + "yaml_migration_done": True, + } + assert len(restart_calls) == 0 + + +@pytest.mark.parametrize( + "config", + [ + {"server_port": "not-a-port"}, + { + "use_x_forwarded_for": True, + "trusted_proxies": ["not-an-ip-network"], + }, + ], +) +async def test_websocket_http_config_invalid( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + config: dict, +) -> None: + """Test that an invalid HTTP config is rejected.""" + assert await async_setup_component(hass, "http", {}) + await async_setup_component(hass, "websocket_api", {}) + await hass.async_start() + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + {"type": "http/config/configure", "config": config} + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_format" diff --git a/tests/components/humidifier/test_llm.py b/tests/components/humidifier/test_llm.py new file mode 100644 index 000000000000..e83d932bedbd --- /dev/null +++ b/tests/components/humidifier/test_llm.py @@ -0,0 +1,59 @@ +"""Tests for the humidifier LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.humidifier import llm as humidifier_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "humidifier.test" +INTENTS = {"HassHumidifierMode", "HassHumidifierSetpoint"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a humidifier entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "humidifier", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test humidifier"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the humidifier platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed humidifier entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no humidifier entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) + assert humidifier_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert humidifier_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr index 475a5969e791..4d3dace8d92d 100644 --- a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : 35.5402913, - : -82.5527055, + : 35.5402913, + : -82.5527055, : , : , }), diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index 7e1759bb9347..53577e4734f0 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': 'garden', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': '450XH', 'name': 'Test Mower 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr index 2e7369e8a6d4..c63142b9e13e 100644 --- a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup DeviceRegistryEntrySnapshot({ 'area_id': 'garden', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': '305', 'name': 'Husqvarna AutoMower', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/huum/snapshots/test_init.ambr b/tests/components/huum/snapshots/test_init.ambr index eed66315bc3e..64b5ad5bcf09 100644 --- a/tests/components/huum/snapshots/test_init.ambr +++ b/tests/components/huum/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Huum sauna', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ialarm/snapshots/test_init.ambr b/tests/components/ialarm/snapshots/test_init.ambr index f778c3e63300..18a007de83bc 100644 --- a/tests/components/ialarm/snapshots/test_init.ambr +++ b/tests/components/ialarm/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'iAlarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/infrared/common.py b/tests/components/infrared/common.py index 793dcc681d07..a5a8be790fbb 100644 --- a/tests/components/infrared/common.py +++ b/tests/components/infrared/common.py @@ -33,11 +33,12 @@ class MockInfraredEmitterEntity(InfraredEmitterEntity): """Mock infrared emitter entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR emitter" - def __init__(self, unique_id: str) -> None: + def __init__(self, unique_id: str, name: str | None = "Test IR emitter") -> None: """Initialize mock entity.""" self._attr_unique_id = unique_id + if name is not None: + self._attr_name = name self.send_command_calls: list[InfraredCommand] = [] async def async_send_command(self, command: InfraredCommand) -> None: @@ -49,11 +50,12 @@ class MockInfraredReceiverEntity(InfraredReceiverEntity): """Mock infrared receiver entity for testing.""" _attr_has_entity_name = True - _attr_name = "Test IR receiver" - def __init__(self, unique_id: str) -> None: + def __init__(self, unique_id: str, name: str | None = "Test IR receiver") -> None: """Initialize mock receiver entity.""" self._attr_unique_id = unique_id + if name is not None: + self._attr_name = name async def init_infrared_fixture_helper(hass: HomeAssistant) -> None: diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index 71d7d96040d6..6f3823d71538 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -16,17 +16,29 @@ from homeassistant.components.infrared import ( async_send_command, async_subscribe_receiver, ) -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import MockInfraredEmitterEntity, MockInfraredReceiverEntity -from tests.common import mock_restore_cache +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) -TEST_COMMAND = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) +TEST_DOMAIN = "test" + +TEST_COMMAND = NECCommand(address=0x04FB, command=0xF7, modulation=38000) async def test_get_entities_component_not_loaded(hass: HomeAssistant) -> None: @@ -293,3 +305,91 @@ async def test_async_subscribe_receiver_component_not_loaded( """Test async_subscribe_receiver raises error when component not loaded.""" with pytest.raises(HomeAssistantError, match="component_not_loaded"): async_subscribe_receiver(hass, "infrared.some_entity", lambda _: None) + + +@pytest.mark.usefixtures("init_infrared") +async def test_name(hass: HomeAssistant) -> None: + """Test entity name / device class naming fallback.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.INFRARED] + ) + return True + + class MockFlow(ConfigFlow): + """Test flow.""" + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed emitter without has_entity_name -> no name + emitter1 = MockInfraredEmitterEntity("test_emitter1", name=None) + emitter1.entity_id = "infrared.test_emitter1" + emitter1._attr_has_entity_name = False + + # Unnamed emitter with has_entity_name True -> name set from device class + emitter2 = MockInfraredEmitterEntity("test_emitter2", name=None) + emitter2.entity_id = "infrared.test_emitter2" + emitter2._attr_has_entity_name = True + + # Unnamed receiver without has_entity_name -> no name + receiver1 = MockInfraredReceiverEntity("test_receiver1", name=None) + receiver1.entity_id = "infrared.test_receiver1" + receiver1._attr_has_entity_name = False + + # Unnamed receiver with has_entity_name True -> name set from device class + receiver2 = MockInfraredReceiverEntity("test_receiver2", name=None) + receiver2.entity_id = "infrared.test_receiver2" + receiver2._attr_has_entity_name = True + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test infrared platform via config entry.""" + async_add_entities([emitter1, emitter2, receiver1, receiver2]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + with mock_config_flow(TEST_DOMAIN, MockFlow): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state1 = hass.states.get("infrared.test_emitter1") + assert state1 is not None + assert state1.attributes == {"device_class": "emitter"} + + state2 = hass.states.get("infrared.test_emitter2") + assert state2 is not None + assert state2.attributes == { + "device_class": "emitter", + "friendly_name": "Infrared emitter", + } + + state3 = hass.states.get("infrared.test_receiver1") + assert state3 is not None + assert state3.attributes == {"device_class": "receiver"} + + state4 = hass.states.get("infrared.test_receiver2") + assert state4 is not None + assert state4.attributes == { + "device_class": "receiver", + "friendly_name": "Infrared receiver", + } diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 6b94a68035b8..f5cf05e797ad 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, ) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.exceptions import Unauthorized @@ -237,6 +238,28 @@ async def test_mode(hass: HomeAssistant) -> None: assert state.attributes["mode"] == "slider" +async def test_unit_of_measurement(hass: HomeAssistant) -> None: + """Test unit of measurement is exposed in the state attributes.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "with_unit": {"min": 0, "max": 100, "unit_of_measurement": "°C"}, + "without_unit": {"min": 0, "max": 100}, + } + }, + ) + + state = hass.states.get("input_number.with_unit") + assert state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + + state = hass.states.get("input_number.without_unit") + assert state + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + async def test_restore_state(hass: HomeAssistant) -> None: """Ensure states are restored on startup.""" mock_restore_cache( diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 393e275aa92e..ada8cd1f7936 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -297,7 +297,7 @@ async def test_reload( autospec=True, return_value={ DOMAIN: { - "test_2": {"initial": "test reloaded", ATTR_MIN: 12}, + "test_2": {"initial": "test reloaded", ATTR_MIN: 6}, "test_3": {"initial": "test 3", ATTR_MAX: 21}, } }, @@ -326,7 +326,7 @@ async def test_reload( assert state_1 is None assert state_2 is not None assert state_3 is not None - assert state_2.attributes[ATTR_MIN] == 12 + assert state_2.attributes[ATTR_MIN] == 6 assert state_3.attributes[ATTR_MAX] == 21 diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 2bc95fad38d9..5b6ea05f7464 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -266,18 +266,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, integration_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test config entry is removed when source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() @@ -289,15 +281,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.integration.async_unload_entry", wraps=integration.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -306,6 +295,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d integration_entity_entry = entity_registry.async_get("sensor.my_integration") assert integration_entity_entry.device_id is None + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -471,7 +463,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes integration config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" integration_config_entry = MockConfigEntry( data={}, @@ -491,22 +483,13 @@ async def test_migration_1_1( ) integration_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=integration_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(integration_config_entry.entry_id) await hass.async_block_till_done() assert integration_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries integration_entity_entry = entity_registry.async_get("sensor.my_integration") diff --git a/tests/components/intelliclima/snapshots/test_fan.ambr b/tests/components/intelliclima/snapshots/test_fan.ambr index 5719c5fa1be4..040aeccbe847 100644 --- a/tests/components/intelliclima/snapshots/test_fan.ambr +++ b/tests/components/intelliclima/snapshots/test_fan.ambr @@ -2,8 +2,8 @@ # name: test_all_fan_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/intelliclima/snapshots/test_select.ambr b/tests/components/intelliclima/snapshots/test_select.ambr index dd3924cad183..8d5ea5bf47ad 100644 --- a/tests/components/intelliclima/snapshots/test_select.ambr +++ b/tests/components/intelliclima/snapshots/test_select.ambr @@ -2,8 +2,8 @@ # name: test_all_select_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/intelliclima/snapshots/test_sensor.ambr b/tests/components/intelliclima/snapshots/test_sensor.ambr index 15e65167bb08..b858a934dcd8 100644 --- a/tests/components/intelliclima/snapshots/test_sensor.ambr +++ b/tests/components/intelliclima/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_all_sensor_entities.6 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'Test VMC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '11223344', 'sw_version': '0.6.8', 'via_device_id': None, diff --git a/tests/components/intent/test_llm.py b/tests/components/intent/test_llm.py new file mode 100644 index 000000000000..e3b5a79764b4 --- /dev/null +++ b/tests/components/intent/test_llm.py @@ -0,0 +1,100 @@ +"""Tests for the intent LLM tools platform (generic intents).""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.intent import llm as intent_llm +from homeassistant.components.intent.timers import async_register_timer_handler +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +COVER_ENTITY_ID = "cover.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a cover.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(COVER_ENTITY_ID, "open", {"friendly_name": "Test Cover"}) + async_expose_entity(hass, "conversation", COVER_ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context(device_id: str | None = None) -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=device_id, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the intent platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_generic_intents_exposed(hass: HomeAssistant) -> None: + """Test the always-on generic intents are exposed.""" + names = await _tool_names(hass) + assert "HassTurnOn" in names + assert "HassTurnOff" in names + + +async def test_timer_intents_require_timer_device(hass: HomeAssistant) -> None: + """Test timer intents are not exposed without a timer-capable device.""" + assert "HassStartTimer" not in await _tool_names(hass) + + +async def test_timer_intents_offered_for_timer_device(hass: HomeAssistant) -> None: + """Test timer intents are exposed for a timer-capable device.""" + + @callback + def handle_timer(*args: object) -> None: + pass + + async_register_timer_handler(hass, "test_device", handle_timer) + + result = await llm_component.async_get_tools( + hass, _llm_context(device_id="test_device"), "assist" + ) + names = {tool.name for tool in result.tools} + assert "HassStartTimer" in names + assert "HassTimerStatus" in names + + +async def test_set_position_requires_exposed_cover(hass: HomeAssistant) -> None: + """Test HassSetPosition is only exposed when a cover/valve is exposed.""" + assert "HassSetPosition" in await _tool_names(hass) + + async_expose_entity(hass, "conversation", COVER_ENTITY_ID, False) + assert "HassSetPosition" not in await _tool_names(hass) + + +async def test_prompt_includes_device_control(hass: HomeAssistant) -> None: + """Test the platform contributes device-control guidance when exposed.""" + result = intent_llm.async_get_tools(hass, _llm_context(), "assist") + assert result is not None + assert result.prompt is not None + assert intent_llm.DEVICE_CONTROL_TOOL_USAGE_PROMPT in result.prompt + assert "This device is not able to start timers." in result.prompt + + +async def test_no_prompt_without_exposed_entities(hass: HomeAssistant) -> None: + """Test the platform contributes no prompt when nothing is exposed.""" + async_expose_entity(hass, "conversation", COVER_ENTITY_ID, False) + result = intent_llm.async_get_tools(hass, _llm_context(), "assist") + assert result is not None + assert result.prompt is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert intent_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/intent_script/test_llm.py b/tests/components/intent_script/test_llm.py new file mode 100644 index 000000000000..c72d479cee38 --- /dev/null +++ b/tests/components/intent_script/test_llm.py @@ -0,0 +1,99 @@ +"""Tests for the intent_script LLM tools platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.intent_script import ( + ScriptIntentHandler, + llm as intent_script_llm, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent, llm +from homeassistant.setup import async_setup_component + +LIGHT_ENTITY_ID = "light.kitchen" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and configure intent scripts.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "Tell a joke": { + "description": "Tell a joke", + "speech": {"text": "Why did the chicken cross the road?"}, + }, + "LightAction": { + "description": "Do a light thing", + "platforms": ["light"], + "speech": {"text": "Done"}, + }, + } + }, + ) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(LIGHT_ENTITY_ID, "on", {"friendly_name": "Kitchen Light"}) + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the intent_script platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_scripts_exposed(hass: HomeAssistant) -> None: + """Test intent scripts are exposed as LLM tools with slugified names.""" + names = await _tool_names(hass) + # The user-provided "Tell a joke" name is slugified into a valid tool name. + assert "Tell_a_joke" in names + assert "LightAction" in names + + +async def test_intent_script_platform_filtered(hass: HomeAssistant) -> None: + """Test a platform-restricted intent script requires an exposed entity.""" + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, False) + names = await _tool_names(hass) + assert "LightAction" not in names + # Unrestricted intent scripts stay exposed. + assert "Tell_a_joke" in names + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert intent_script_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_no_tools_when_no_scripts_match(hass: HomeAssistant) -> None: + """Test None is returned when no intent scripts match the exposed domains.""" + async_expose_entity(hass, "conversation", LIGHT_ENTITY_ID, False) + restricted_handlers = [ + handler + for handler in intent.async_get(hass) + if isinstance(handler, ScriptIntentHandler) and handler.platforms + ] + with patch( + "homeassistant.components.intent_script.llm.intent.async_get", + return_value=restricted_handlers, + ): + assert intent_script_llm.async_get_tools(hass, _llm_context(), "assist") is None diff --git a/tests/components/iotawatt/test_init.py b/tests/components/iotawatt/test_init.py index af4bc64cc54d..75427ce889ef 100644 --- a/tests/components/iotawatt/test_init.py +++ b/tests/components/iotawatt/test_init.py @@ -42,3 +42,15 @@ async def test_setup_auth_failed( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_update_failed( + hass: HomeAssistant, mock_iotawatt: MagicMock, entry: MockConfigEntry +) -> None: + """Test error while fetching sensor data during startup.""" + mock_iotawatt.update.side_effect = httpx.HTTPStatusError( + "", request=MagicMock(), response=MagicMock() + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index 752ee97cef86..870e7e022357 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -14,8 +14,8 @@ # name: test_devices_creaction_ok[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -36,7 +36,6 @@ 'model_id': None, 'name': '[TEST] Light switch 0 (TEST_SERIAL_0)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr index 02076bf55970..7c6b1a4c1611 100644 --- a/tests/components/ista_ecotrend/snapshots/test_init.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Luxemburger Str. 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_registry.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Bahnhofsstr. 1A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr index b7eaca1a04d5..80aeff1966a1 100644 --- a/tests/components/ituran/snapshots/test_device_tracker.ambr +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : 25.0, - : -71.0, + : 25.0, + : -71.0, : , : , }), diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr index 5fb786029b4e..4ea9f6eeda74 100644 --- a/tests/components/ituran/snapshots/test_init.ambr +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'mock model', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12345678', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/izone/conftest.py b/tests/components/izone/conftest.py index f4eb2e0a5791..56340c887cc3 100644 --- a/tests/components/izone/conftest.py +++ b/tests/components/izone/conftest.py @@ -1,12 +1,17 @@ """Fixtures for iZone integration tests.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator, Iterable +from contextlib import contextmanager from unittest.mock import AsyncMock, Mock, patch from pizone import Controller, Zone import pytest -from homeassistant.components.izone.const import DOMAIN +from homeassistant.components.izone import discovery as izone_discovery +from homeassistant.components.izone.const import DATA_DISCOVERY_SERVICE, DOMAIN +from homeassistant.const import CONF_EXCLUDE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -19,6 +24,8 @@ def mock_config_entry() -> MockConfigEntry: title="iZone", data={}, entry_id="test_entry_id", + unique_id="000000001", + version=2, ) @@ -33,7 +40,8 @@ def mock_pizone_discovery_service() -> Mock: def create_mock_controller( - device_uid: str = "test_controller_123", + device_uid: str = "000000001", + device_ip: str = "192.0.2.1", sys_type: str = "iZone310", zones_total: int = 4, zone_ctrl: int = 1, @@ -43,6 +51,7 @@ def create_mock_controller( """Create a mock Controller with configurable parameters.""" controller = Mock(spec=Controller) controller.device_uid = device_uid + controller.device_ip = device_ip controller.sys_type = sys_type controller.zones_total = zones_total controller.zone_ctrl = zone_ctrl @@ -86,6 +95,87 @@ def create_mock_zone( return zone +def create_mock_discovery_service(*controllers: Mock) -> Mock: + """Create a mock discovery service with the given controllers.""" + service = Mock() + service.pi_disco = Mock() + service.pi_disco.controllers = {c.device_uid: c for c in controllers} + service.pi_disco.fetch_controller = AsyncMock( + side_effect=lambda uid, timeout=None: service.pi_disco.controllers.get(uid) + ) + service.pi_disco.fetch_controllers = AsyncMock( + side_effect=lambda timeout=None: dict(service.pi_disco.controllers) + ) + service.async_schedule_idle_stop = Mock() + return service + + +@contextmanager +def patch_discovered_controllers( + controllers: Mock | dict[str, Mock] | Iterable[Mock], +) -> Generator[Mock]: + """Patch discovery startup so async_discover_controllers uses these controllers.""" + if isinstance(controllers, dict): + ctrl_list = list(controllers.values()) + elif isinstance(controllers, Mock): + ctrl_list = [controllers] + else: + ctrl_list = list(controllers) + service = create_mock_discovery_service(*ctrl_list) + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + new_callable=AsyncMock, + return_value=service, + ): + yield service + + +async def async_load_yaml_exclude(hass: HomeAssistant, *uids: str) -> None: + """Load deprecated YAML exclude config through the integration setup path.""" + with ( + patch.object(hass, "async_create_task"), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=Mock(start_discovery=AsyncMock(), close=AsyncMock()), + ), + ): + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {CONF_EXCLUDE: list(uids)}} + ) + + +async def async_install_discovery_service( + hass: HomeAssistant, *controllers: Mock +) -> Mock: + """Start the discovery service with mocked pizone and optional controllers.""" + mock_pi_disco = create_mock_discovery_service(*controllers).pi_disco + mock_pi_disco.start_discovery = AsyncMock() + mock_pi_disco.close = AsyncMock() + with ( + patch( + "homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession", + return_value=Mock(), + ), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=mock_pi_disco, + ), + ): + service = await izone_discovery.async_start_discovery_service(hass) + assert DATA_DISCOVERY_SERVICE in hass.data + return service + + +@pytest.fixture +def mock_entry_setup() -> Generator[None]: + """Patch climate platform setup for entry-creating config flow tests.""" + with patch( + "homeassistant.components.izone.climate.async_setup_entry", + return_value=True, + ): + yield + + @pytest.fixture async def mock_discovery( mock_controller: AsyncMock, mock_zones: list[AsyncMock] @@ -96,10 +186,13 @@ async def mock_discovery( "homeassistant.components.izone.discovery.pizone.discovery", autospec=True ) as mock_disco: mock_disco.return_value.start_discovery = AsyncMock() + mock_disco.return_value.close = AsyncMock() + mock_disco.return_value.fetch_controller = AsyncMock( + return_value=mock_controller + ) mock_disco.return_value.fetch_controllers = AsyncMock( return_value={mock_controller.device_uid: mock_controller} ) - mock_disco.return_value.close = AsyncMock() yield mock_disco @@ -113,7 +206,8 @@ async def mock_zones() -> list[AsyncMock]: async def mock_controller(mock_zones: list[AsyncMock]) -> AsyncMock: """Create a mock controller.""" return create_mock_controller( - device_uid="test_controller_123", + device_uid="000000001", + device_ip="192.0.2.1", ras_mode="master", zone_ctrl=1, zones_total=1, diff --git a/tests/components/izone/snapshots/test_climate.ambr b/tests/components/izone/snapshots/test_climate.ambr index 5d320e93fb64..751093d19689 100644 --- a/tests/components/izone/snapshots/test_climate.ambr +++ b/tests/components/izone/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_basic_controller_properties[climate.izone_controller_test_controller_123-entry] +# name: test_basic_controller_properties[climate.izone_controller_000000001-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -31,7 +31,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.izone_controller_test_controller_123', + 'entity_id': 'climate.izone_controller_000000001', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -50,11 +50,11 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'test_controller_123', + 'unique_id': '000000001', 'unit_of_measurement': None, }) # --- -# name: test_basic_controller_properties[climate.izone_controller_test_controller_123-state] +# name: test_basic_controller_properties[climate.izone_controller_000000001-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'control_zone': 1, @@ -68,7 +68,7 @@ 'high', 'auto', ]), - : 'iZone Controller test_controller_123', + : 'iZone Controller 000000001', : list([ , , @@ -85,7 +85,7 @@ 'temp_setpoint': 24.0, }), 'context': , - 'entity_id': 'climate.izone_controller_test_controller_123', + 'entity_id': 'climate.izone_controller_000000001', 'last_changed': , 'last_reported': , 'last_updated': , @@ -134,7 +134,7 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'test_controller_123_z1', + 'unique_id': '000000001_z1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/izone/test_climate.py b/tests/components/izone/test_climate.py index bed507ff89b7..222712bd5657 100644 --- a/tests/components/izone/test_climate.py +++ b/tests/components/izone/test_climate.py @@ -1,12 +1,13 @@ """Tests for iZone climate platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ClimateEntityFeature from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er from . import setup_controller, setup_integration @@ -29,7 +30,7 @@ async def test_basic_controller_properties( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -57,7 +58,7 @@ async def test_target_temperature_feature_ras_mode( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -86,7 +87,7 @@ async def test_target_temperature_feature_master_mode_invalid_zone( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -123,7 +124,7 @@ async def test_target_temperature_feature_zone_without_sensor( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -158,7 +159,7 @@ async def test_target_temperature_feature_all_zones_with_sensors( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -194,7 +195,7 @@ async def test_target_temperature_feature_multiple_zones_one_without_sensor( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -222,7 +223,7 @@ async def test_target_temperature_feature_slave_mode( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -252,7 +253,7 @@ async def test_target_temperature_feature_master_mode_zone_13( await setup_integration(hass, mock_config_entry) await setup_controller(hass, mock_discovery, mock_controller) - entity_id = "climate.izone_controller_test_controller_123" + entity_id = "climate.izone_controller_000000001" entity = hass.states.get(entity_id) assert entity is not None @@ -260,3 +261,89 @@ async def test_target_temperature_feature_master_mode_zone_13( entity.attributes["supported_features"] & ClimateEntityFeature.TARGET_TEMPERATURE ) == ClimateEntityFeature.TARGET_TEMPERATURE + + +async def test_setup_entry_only_adds_entities_for_matching_config_entry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test a config entry only adds entities for its matching controller.""" + matching_controller = create_mock_controller( + device_uid="000000001", device_ip="192.0.2.1", zones_total=1 + ) + matching_controller.zones = [create_mock_zone(index=0, name="Living Room")] + + other_controller = create_mock_controller( + device_uid="000000002", device_ip="192.0.2.2", zones_total=1 + ) + other_controller.zones = [create_mock_zone(index=0, name="Bedroom")] + + entry = MockConfigEntry( + domain="izone", + title="iZone", + data={}, + unique_id="000000001", + entry_id="test_entry_id", + version=2, + ) + + with patch( + "homeassistant.components.izone.discovery.pizone.discovery", autospec=True + ) as mock_disco: + mock_disco.return_value.start_discovery = AsyncMock() + mock_disco.return_value.close = AsyncMock() + mock_disco.return_value.fetch_controller = AsyncMock( + return_value=matching_controller + ) + mock_disco.return_value.fetch_controllers = AsyncMock( + return_value={ + matching_controller.device_uid: matching_controller, + other_controller.device_uid: other_controller, + } + ) + + await setup_integration(hass, entry) + + entry_entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + unique_ids = {entity.unique_id for entity in entry_entities} + + assert unique_ids == {"000000001", "000000001_z1"} + + +@pytest.mark.parametrize("mock_zones", [[]]) +@pytest.mark.parametrize( + "mock_controller", + [ + create_mock_controller( + ras_mode="zones", + sys_type="0", + zones_total=0, + free_air_enabled=False, + ) + ], +) +async def test_controller_device_init_fault_bootstrap( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_discovery: AsyncMock, + mock_controller: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Controller climate entity is created with fault-shaped controller defaults.""" + await setup_integration(hass, mock_config_entry) + await setup_controller(hass, mock_discovery, mock_controller) + + entity_id = "climate.izone_controller_000000001" + assert hass.states.get(entity_id) is not None + + entry_entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert {entity.unique_id for entity in entry_entities} == {"000000001"} + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry is not None + assert device_entry.model == "0" diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 6465e7a18c43..2d5045edcc99 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -1,91 +1,1606 @@ -"""Tests for iZone.""" +"""Tests for iZone config flow.""" -from collections.abc import Callable -from typing import Any +from collections.abc import Generator +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components.izone.const import DISPATCH_CONTROLLER_DISCOVERED, DOMAIN +from homeassistant.components.izone import config_flow, discovery as izone_discovery +from homeassistant.components.izone.const import DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +from .conftest import ( + async_install_discovery_service, + async_load_yaml_exclude, + create_mock_controller, + patch_discovered_controllers, +) + +from tests.common import MockConfigEntry -@pytest.fixture -def mock_disco() -> Mock: - """Mock discovery service.""" - disco = Mock() - disco.pi_disco = Mock() - disco.pi_disco.fetch_controllers = AsyncMock(return_value={}) - return disco +def _make_homekit_info(md: str, host: str | None = None) -> SimpleNamespace: + """Return a minimal HomeKit discovery info object with attributes.""" + return SimpleNamespace(properties={"md": md}, host=host) -def _mock_start_discovery(hass: HomeAssistant, mock_disco: Mock) -> Callable[..., Mock]: - def do_disovered(*args: Any) -> Mock: - async_dispatcher_send(hass, DISPATCH_CONTROLLER_DISCOVERED, True) - return mock_disco - - return do_disovered - - -async def test_not_found(hass: HomeAssistant, mock_disco: Mock) -> None: - """Test not finding iZone controller.""" - +@pytest.fixture(autouse=True) +def mock_izone_timeouts() -> Generator[None]: + """Mock iZone timeout constants to speed up tests.""" with ( patch( - "homeassistant.components.izone.config_flow.async_start_discovery_service" - ) as start_disco, + "homeassistant.components.izone.discovery.TIMEOUT_DISCOVERY", + 0.01, + ), patch( - "homeassistant.components.izone.config_flow.async_stop_discovery_service", - return_value=None, - ) as stop_disco, + "homeassistant.components.izone.discovery.DISCOVERY_IDLE_SECONDS", + 0.04, + ), ): - start_disco.side_effect = _mock_start_discovery(hass, mock_disco) + yield + + +async def test_user_discovery_success( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test user flow confirms and creates an entry for a discovered controller.""" + controller = create_mock_controller("000000001", "192.0.2.55") + with patch_discovered_controllers(controller): + 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"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {} + assert result["result"].unique_id == "000000001" + + +async def test_user_discovery_default_selects_first_and_queues_other( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Default dropdown selection configures first UID and queues the other for confirm.""" + first = create_mock_controller("000000001", "192.0.2.1") + second = create_mock_controller("000000002", "192.0.2.2") + with patch_discovered_controllers([first, second]): + 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"] == "select_controller" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {} + assert result["result"].unique_id == "000000001" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + progress = [ + p + for p in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if p["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ] + assert len(progress) == 1 + assert progress[0]["step_id"] == "confirm" + assert progress[0]["context"]["unique_id"] == "000000002" + + +async def test_broadcast_skips_already_configured_controller( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test broadcast discovery skips configured controllers and sets up an unconfigured one.""" + configured_controller = create_mock_controller("000000001", "192.0.2.1") + unconfigured_controller = create_mock_controller("000000002", "192.0.2.2") + MockConfigEntry( + domain=DOMAIN, + unique_id=configured_controller.device_uid, + data={}, + version=2, + ).add_to_hass(hass) + + with patch_discovered_controllers([configured_controller, unconfigured_controller]): + 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"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["data"] == {} + assert result["result"].unique_id == "000000002" + + +async def test_user_discovery_skips_yaml_excluded_controllers( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """User flow should not offer controllers excluded by deprecated YAML config.""" + excluded_controller = create_mock_controller("000000001", "192.0.2.1") + allowed_controller = create_mock_controller("000000002", "192.0.2.2") + await async_load_yaml_exclude(hass, excluded_controller.device_uid) + + with patch_discovered_controllers([excluded_controller, allowed_controller]): + 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"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["data"] == {} + assert result["result"].unique_id == "000000002" + + +async def test_broadcast_multiple_unconfigured_shows_choice( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test broadcast discovery shows a controller choice when multiple unconfigured controllers are found.""" + first_controller = create_mock_controller("000000002", "192.0.2.1") + second_controller = create_mock_controller("000000001", "192.0.2.2") + + with patch_discovered_controllers([first_controller, second_controller]): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - # Confirmation form assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_controller" + schema_keys = list(result["data_schema"].schema.keys()) + assert len(schema_keys) == 1 + assert str(schema_keys[0].schema) == config_flow.SELECTED_CONTROLLER_UID + + # Choose one and queue the other as integration discovery (confirm step). + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {} + assert result["result"].unique_id == "000000001" + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].unique_id == "000000001" + + progress = [ + p + for p in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if p["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ] + assert len(progress) == 1 + assert progress[0]["step_id"] == "confirm" + assert progress[0]["context"]["unique_id"] == "000000002" + + +async def test_select_controller_aborts_when_choices_missing( + hass: HomeAssistant, +) -> None: + """Test controller selection aborts if the discovered controller choices are missing.""" + first_controller = create_mock_controller("000000001", "192.0.2.1") + second_controller = create_mock_controller("000000002", "192.0.2.2") + + with patch_discovered_controllers([first_controller, second_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + flow._user_discovered_controllers = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_select_controller_aborts_when_uid_not_in_choices( + hass: HomeAssistant, +) -> None: + """Test controller selection aborts if a submitted UID is not in the choices.""" + first_controller = create_mock_controller("000000001", "192.0.2.1") + second_controller = create_mock_controller("000000002", "192.0.2.2") + + with patch_discovered_controllers([first_controller, second_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + result = await flow.async_step_select_controller( + {config_flow.SELECTED_CONTROLLER_UID: "000000099"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_select_controller_creates_selected_uid_and_queues_others( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """A selected controller is configured and non-selected controllers are queued.""" + first_controller = create_mock_controller("000000002", "192.0.2.1") + second_controller = create_mock_controller("000000001", "192.0.2.2") + + with patch_discovered_controllers([first_controller, second_controller]): + 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"], + {config_flow.SELECTED_CONTROLLER_UID: "000000002"}, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["result"].unique_id == "000000002" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + skipped_flows = [ + p + for p in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if p["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ] + assert len(skipped_flows) == 1 + assert skipped_flows[0]["step_id"] == "confirm" + assert skipped_flows[0]["context"]["unique_id"] == "000000001" + + +async def test_broadcast_aborts_when_all_discovered_are_configured( + hass: HomeAssistant, +) -> None: + """Test broadcast discovery aborts when every discovered controller is configured.""" + configured_controller = create_mock_controller("000000001", "192.0.2.1") + MockConfigEntry( + domain=DOMAIN, + unique_id=configured_controller.device_uid, + data={}, + version=2, + ).add_to_hass(hass) + + with patch_discovered_controllers(configured_controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_aborts_when_all_discovered_are_ignored( + hass: HomeAssistant, +) -> None: + """User flow aborts when every discovered controller has been explicitly ignored. + + _async_get_unconfigured_controllers uses include_ignore=True so controllers + whose entries carry SOURCE_IGNORE are not re-offered as configurable, respecting + the user's earlier choice to dismiss them. + """ + ignored_controller = create_mock_controller("000000001", "192.0.2.1") + MockConfigEntry( + domain=DOMAIN, + unique_id=ignored_controller.device_uid, + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + + with patch_discovered_controllers(ignored_controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reuses_existing_discovery_service( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test config flow reuses the running discovery service without starting a new one.""" + controller = create_mock_controller("000000002", "192.0.2.2") + await async_install_discovery_service(hass, controller) + + with patch( + "homeassistant.components.izone.config_flow.pizone.discovery", + ) as mock_pizone_discovery: + 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"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["data"] == {} + assert result["result"].unique_id == "000000002" + mock_pizone_discovery.assert_not_called() + + +async def test_import_starts_discovery_service( + hass: HomeAssistant, +) -> None: + """Test YAML import starts discovery so runtime discovery can offer flows.""" + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + new=AsyncMock(), + ) as mock_start: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_started" + mock_start.assert_awaited_once_with(hass) + + +async def test_import_logs_and_aborts_when_discovery_service_cannot_start( + hass: HomeAssistant, +) -> None: + """Test YAML import aborts cleanly if discovery startup raises OSError.""" + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + new=AsyncMock(side_effect=OSError), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +async def test_import_aborts_when_another_izone_flow_in_progress( + hass: HomeAssistant, +) -> None: + """Test YAML import does not overlap discovery with an active user flow.""" + controller = create_mock_controller("000000001", "192.0.2.1") + with patch_discovered_controllers(controller): + user_flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert user_flow["type"] is FlowResultType.FORM + assert user_flow["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_homekit_confirm_uses_discovered_host( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Test HomeKit flow confirms and uses the discovered controller IP, not the HomeKit host.""" + controller = create_mock_controller(device_ip="192.0.2.3") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert flow["context"]["title_placeholders"] == {"name": "iZone 000000001"} + assert result["description_placeholders"] == { + "controller_uid": "000000001", + "host": "192.0.2.3", + } result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT await hass.async_block_till_done() - stop_disco.assert_called_once() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000001" + assert result["data"] == {} + assert result["result"].unique_id == "000000001" -async def test_found(hass: HomeAssistant, mock_disco: Mock) -> None: - """Test not finding iZone controller.""" - mock_disco.pi_disco.fetch_controllers = AsyncMock(return_value={"blah": object()}) +async def test_homekit_fans_out_other_discovered_controllers( + hass: HomeAssistant, +) -> None: + """Test HomeKit flow fans out additional discovered controllers.""" + matched_controller = create_mock_controller("000000001", "192.0.2.3") + other_controller = create_mock_controller("000000002", "192.0.2.4") + + with patch_discovered_controllers([matched_controller, other_controller]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + await hass.async_block_till_done() + + progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(progress) == 2 + + current_flow = next( + flow for flow in progress if flow["flow_id"] == result["flow_id"] + ) + assert current_flow["context"]["source"] == config_entries.SOURCE_HOMEKIT + + fanout_flow = next( + flow for flow in progress if flow["flow_id"] != result["flow_id"] + ) + assert fanout_flow["step_id"] == "confirm" + assert ( + fanout_flow["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ) + assert fanout_flow["context"]["unique_id"] == "000000002" + + +async def test_homekit_flow_sets_device_uid_once( + hass: HomeAssistant, +) -> None: + """HomeKit flow sets unique_id to the device UID exactly once (no lock-ID swap).""" + controller = create_mock_controller("000000001", "192.0.2.3") + set_unique_id_calls: list[str] = [] + original_set_unique_id = config_flow.IZoneConfigFlow.async_set_unique_id + + async def _recording_set_unique_id( + self: config_flow.IZoneConfigFlow, uid: str + ) -> None: + set_unique_id_calls.append(uid) + await original_set_unique_id(self, uid) with ( + patch_discovered_controllers(controller), + patch.object( + config_flow.IZoneConfigFlow, + "async_set_unique_id", + _recording_set_unique_id, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + assert set_unique_id_calls == ["000000001"] + + +async def test_homekit_aborts_while_user_confirm_is_open( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """HomeKit onboarding for same UID is blocked while a user flow is already active.""" + controller = create_mock_controller("000000001", "192.0.2.3") + with patch_discovered_controllers(controller): + user_flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert user_flow["type"] is FlowResultType.FORM + assert user_flow["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_user_broadcast_aborts_when_homekit_flow_in_progress( + hass: HomeAssistant, +) -> None: + """Test user broadcast discovery aborts when a HomeKit flow is already active.""" + controller = create_mock_controller("000000001", "192.0.2.3") + with patch_discovered_controllers(controller): + homekit_flow = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert homekit_flow["type"] is FlowResultType.FORM + assert homekit_flow["step_id"] == "confirm" + + user_flow = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = user_flow + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +async def test_homekit_aborts_when_uid_already_configured( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts immediately when the discovered UID is already configured.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.izone.discovery.async_discover_controllers", + ) as mock_discover_controllers: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_discover_controllers.assert_not_called() + + +async def test_homekit_aborts_when_uid_configured_during_discovery( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts if the discovered UID gets configured mid-resolution.""" + controller = create_mock_controller("000000001", "192.0.2.3") + + async def _fetch_with_midflight_config(timeout=None): + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + return {controller.device_uid: controller} + + with patch_discovered_controllers(controller) as service: + service.pi_disco.fetch_controllers = AsyncMock( + side_effect=_fetch_with_midflight_config + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_homekit_aborts_when_uid_not_found_in_discovery( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts when the discovered UID cannot be found via iZone discovery.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_aborts_when_controller_unavailable_during_discovery_wait( + hass: HomeAssistant, +) -> None: + """HomeKit aborts when the advertised UID is not found during iZone discovery.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_flow_aborts_when_no_controllers_found(hass: HomeAssistant) -> None: + """User flow aborts when broadcast discovery returns no controllers.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + await hass.async_block_till_done() + + +async def test_user_flow_abort_when_discovery_service_cannot_start( + hass: HomeAssistant, +) -> None: + """User flow aborts when discovery startup fails.""" + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +async def test_user_discovery_with_shared_service_without_matches_aborts( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """User flow aborts when discovery refresh returns no controllers.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_without_model_aborts( + hass: HomeAssistant, +) -> None: + """Test HomeKit flow with a non-iZone model string aborts immediately.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("Other Device", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_without_model_does_not_start_discovery( + hass: HomeAssistant, +) -> None: + """Test HomeKit flow does not trigger iZone discovery for a non-iZone model.""" + with patch( + "homeassistant.components.izone.discovery.async_discover_controllers", + ) as mock_discover_controllers: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("Other Device"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + mock_discover_controllers.assert_not_called() + + +async def test_homekit_aborts_when_matching_uid_not_discovered( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts when no discovered controller matches model UID.""" + controller = create_mock_controller("000000003", "192.0.2.33") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_aborts_when_nothing_found(hass: HomeAssistant) -> None: + """Test HomeKit aborts when iZone discovery finds no controllers.""" + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_aborts_when_discovered_uid_missing( + hass: HomeAssistant, +) -> None: + """Test HomeKit aborts when discovery returns controllers but not the advertised UID.""" + different_controller = create_mock_controller("000000002", "192.0.2.44") + + with patch_discovered_controllers(different_controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_flow_aborts_at_confirm_when_controller_disappears( + hass: HomeAssistant, +) -> None: + """HomeKit confirm aborts when the controller is gone by the time the user confirms.""" + controller = create_mock_controller("000000001", "192.0.2.3") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch_discovered_controllers([]): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_homekit_aborts_when_discovery_startup_fails( + hass: HomeAssistant, +) -> None: + """Test HomeKit flow aborts when discovery service cannot start.""" + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +async def test_integration_discovery_aborts_for_yaml_excluded_uid( + hass: HomeAssistant, +) -> None: + """Integration discovery should abort for UIDs excluded in YAML config.""" + await async_load_yaml_exclude(hass, "000000002") + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": "000000002", + }, + data={CONF_HOST: "192.0.2.2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_integration_discovery_aborts_for_ignored_uid( + hass: HomeAssistant, +) -> None: + """Integration discovery should abort for UIDs that have been ignored.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000002", + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": "000000002", + }, + data={"host": "192.0.2.2"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_runtime_integration_discovery_starts_confirm_flow( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """When the discovery service sees an unconfigured UID, offer setup.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + new_ctrl = create_mock_controller("000000002", "192.0.2.2") + + izone_discovery.async_note_integration_discovery(hass, new_ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(progress) == 1 + assert ( + progress[0]["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY + ) + assert progress[0]["step_id"] == "confirm" + + +async def test_integration_discovery_confirm_creates_entry( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Full path: integration-discovery flow confirmed by the user creates an entry.""" + controller = create_mock_controller("000000002", "192.0.2.2") + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, + "unique_id": controller.device_uid, + }, + data={CONF_HOST: controller.device_ip}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "iZone 000000002" + assert result["result"].unique_id == "000000002" + + +async def test_runtime_integration_discovery_skips_yaml_excluded_uid( + hass: HomeAssistant, +) -> None: + """Deprecated YAML exclude suppresses auto discovery flows.""" + await async_load_yaml_exclude(hass, "000000002") + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + excluded_ctrl = create_mock_controller("000000002", "192.0.2.2") + + with patch( + "homeassistant.helpers.discovery_flow.async_create_flow" + ) as mock_create_flow: + izone_discovery.async_note_integration_discovery(hass, excluded_ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_create_flow.assert_not_called() + + +async def test_runtime_integration_discovery_skips_when_uid_already_configured( + hass: HomeAssistant, +) -> None: + """No active flow remains when a config entry already exists for the UID.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000002", + data={}, + version=2, + ).add_to_hass(hass) + ctrl = create_mock_controller("000000002", "192.0.2.2") + + izone_discovery.async_note_integration_discovery(hass, ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + +async def test_runtime_integration_discovery_skips_for_ignored_unique_id( + hass: HomeAssistant, +) -> None: + """No active flow remains when the UID matches an ignored entry.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000002", + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + ctrl = create_mock_controller("000000002", "192.0.2.2") + + izone_discovery.async_note_integration_discovery(hass, ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + +async def test_runtime_integration_discovery_skips_during_user_select_controller_step( + hass: HomeAssistant, +) -> None: + """Do not stack auto discovery while the user is choosing discovered controllers.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + data={}, + version=2, + ).add_to_hass(hass) + first = create_mock_controller("000000002", "192.0.2.2") + second = create_mock_controller("000000003", "192.0.2.3") + with patch_discovered_controllers([first, second]): + user_flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert user_flow["type"] is FlowResultType.FORM + assert user_flow["step_id"] == "select_controller" + + new_ctrl = create_mock_controller("000000002", "192.0.2.2") + + with patch( + "homeassistant.helpers.discovery_flow.async_create_flow" + ) as mock_create_flow: + izone_discovery.async_note_integration_discovery(hass, new_ctrl) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_create_flow.assert_not_called() + + +async def test_runtime_integration_discovery_skips_during_user_confirm( + hass: HomeAssistant, mock_entry_setup: None +) -> None: + """Runtime discovery stays suppressed while an interactive user flow is active.""" + first = create_mock_controller("000000001", "192.0.2.1") + second = create_mock_controller("000000002", "192.0.2.2") + with patch_discovered_controllers(first): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "confirm" + + izone_discovery.async_note_integration_discovery(hass, second) + await hass.async_block_till_done(wait_background_tasks=True) + + progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(progress) == 1 + assert progress[0]["context"]["source"] == config_entries.SOURCE_USER + + +async def test_async_setup_starts_import_flow(hass: HomeAssistant) -> None: + """Test YAML config triggers an import flow.""" + with ( + patch.object(hass.config_entries.flow, "async_init") as mock_async_init, + patch.object( + hass, + "async_create_task", + side_effect=lambda target: target.close(), + ) as mock_create_task, + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"exclude": []}}) + + mock_async_init.assert_called_once_with( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + mock_create_task.assert_called_once() + + +def test_flow_uid_for_matching_returns_none_when_no_uid() -> None: + """Flow UID extraction returns None when context has no unique_id.""" + flow = SimpleNamespace(context={}, init_data=None) + + assert config_flow._flow_uid_for_matching(flow) is None + + +def test_is_matching_returns_false_when_either_flow_has_no_uid() -> None: + """Flow matching should fail when a stable UID cannot be derived.""" + first = SimpleNamespace(context={}, init_data=None) + second = SimpleNamespace(context={"unique_id": "000000222"}, init_data=None) + + assert config_flow.IZoneConfigFlow.is_matching(first, second) is False + + +def test_is_matching_returns_true_for_same_flow_uid() -> None: + """Flow matching should succeed when both flows resolve to the same UID.""" + first = SimpleNamespace(context={"unique_id": "000000111"}, init_data=None) + second = SimpleNamespace(context={"unique_id": "000000111"}, init_data=None) + + assert config_flow.IZoneConfigFlow.is_matching(first, second) is True + + +async def test_homekit_aborts_for_yaml_excluded_uid_without_discovery( + hass: HomeAssistant, +) -> None: + """HomeKit setup aborts immediately for YAML excluded UIDs.""" + await async_load_yaml_exclude(hass, "000000001") + + with patch( + "homeassistant.components.izone.discovery.async_discover_controllers" + ) as mock_discover_controllers: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + mock_discover_controllers.assert_not_called() + + +async def test_homekit_aborts_for_ignored_uid( + hass: HomeAssistant, +) -> None: + """HomeKit setup aborts for UIDs that have been ignored.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.izone.discovery.async_discover_controllers" + ) as mock_discover_controllers: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_discover_controllers.assert_not_called() + + +async def test_confirm_asserts_when_controller_data_is_missing( + hass: HomeAssistant, +) -> None: + """Confirm asserts when required controller data is unexpectedly missing.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + flow._discovered_controller_ip = None + with pytest.raises(AssertionError): + await flow.async_step_confirm() + + +async def test_confirm_aborts_when_refresh_discovers_no_controllers( + hass: HomeAssistant, +) -> None: + """Confirm aborts when a follow-up discovery refresh returns no controllers.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + + with patch_discovered_controllers([]): + result = await flow.async_step_confirm({}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_confirm_asserts_when_unique_id_is_not_string( + hass: HomeAssistant, +) -> None: + """Confirm asserts when flow unique_id is unexpectedly not a string.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + flow.context["unique_id"] = None + + with ( + patch_discovered_controllers(controller), + pytest.raises(AssertionError), + ): + await flow.async_step_confirm({}) + + +async def test_confirm_aborts_when_unique_id_controller_not_found( + hass: HomeAssistant, +) -> None: + """Confirm aborts when the flow UID is not returned by discovery.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + flow.context["unique_id"] = "000009999" + + with patch_discovered_controllers(controller): + result = await flow.async_step_confirm({}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_confirm_aborts_when_discovery_startup_fails( + hass: HomeAssistant, +) -> None: + """Test confirm step aborts when discovery service cannot start.""" + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + flow = hass.config_entries.flow._progress[result["flow_id"]] + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ): + result = await flow.async_step_confirm({}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +def test_filter_yaml_exclude_returns_original_when_no_exclusions( + hass: HomeAssistant, +) -> None: + """YAML exclusion helper returns unchanged mapping when no excludes are present.""" + controllers = {"000000001": create_mock_controller("000000001", "192.0.2.1")} + + assert ( + config_flow.IZoneConfigFlow._filter_yaml_exclude(hass, controllers) + is controllers + ) + + +async def test_filter_yaml_exclude_removes_excluded_controllers( + hass: HomeAssistant, +) -> None: + """YAML exclusion helper removes matching UIDs from discovered controllers.""" + first = create_mock_controller("000000001", "192.0.2.1") + second = create_mock_controller("000000002", "192.0.2.2") + await async_load_yaml_exclude(hass, "000000002") + + filtered = config_flow.IZoneConfigFlow._filter_yaml_exclude( + hass, + {first.device_uid: first, second.device_uid: second}, + ) + + assert filtered == {first.device_uid: first} + + +def test_async_fan_out_skips_uids_already_in_progress() -> None: + """Fan-out should skip scheduling flows for UIDs already in progress.""" + candidate = create_mock_controller("000000002", "192.0.2.2") + fake_flow = SimpleNamespace( + _async_current_ids=Mock(return_value=set()), + _async_in_progress=Mock(return_value=[{"context": {"unique_id": "000000002"}}]), + _async_schedule_integration_discovery_flow=Mock(), + ) + + config_flow.IZoneConfigFlow._async_fan_out_discovered_controllers( + fake_flow, + [candidate], + selected_uid="000000001", + ) + + fake_flow._async_schedule_integration_discovery_flow.assert_not_called() + + +async def test_async_migrate_entry_clears_legacy_data( + hass: HomeAssistant, +) -> None: + """v1→v2 migration clears legacy entry data; UID and title binding is deferred. + + ConfigEntryNotReady retry semantics only work inside async_setup_entry — raising + from async_migrate_entry permanently lands the entry in MIGRATION_ERROR with no + retry path. All network-dependent work is therefore intentionally deferred to + async_setup_entry. + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=DOMAIN, + title="iZone Aircon", + data={"host": "192.0.2.1"}, + ) + entry.add_to_hass(hass) + controller = create_mock_controller("000000001") + + with ( + patch_discovered_controllers(controller), patch( "homeassistant.components.izone.climate.async_setup_entry", return_value=True, - ) as mock_setup, - patch( - "homeassistant.components.izone.config_flow.async_start_discovery_service" - ) as start_disco, - patch( - "homeassistant.components.izone.async_start_discovery_service", - return_value=None, ), ): - start_disco.side_effect = _mock_start_discovery(hass, mock_disco) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - # 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 - + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - mock_setup.assert_called_once() + assert entry.version == 2 + assert entry.data == {} + assert entry.unique_id == "000000001" + assert entry.title == "iZone 000000001" + + +async def test_async_migrate_entry_does_not_raise_on_discovery_failure( + hass: HomeAssistant, +) -> None: + """Migration succeeds without network calls regardless of discovery state. + + The retry-on-not-ready path only works in async_setup_entry; migration never + makes network calls (see test_async_migrate_entry_clears_legacy_data). + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=DOMAIN, + title="iZone Aircon", + data={"host": "192.0.2.1"}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 2 + assert entry.data == {} + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_async_migrate_entry_does_not_raise_for_multiple_eligible( + hass: HomeAssistant, +) -> None: + """Migration does not raise for multiple eligible controllers. + + The multi-controller failure case is handled in async_setup_entry, not here. + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=DOMAIN, + title="iZone Aircon", + data={"host": "192.0.2.1"}, + ) + entry.add_to_hass(hass) + controller1 = create_mock_controller("000000001") + controller2 = create_mock_controller("000000002", "192.0.2.2") + + with patch_discovered_controllers([controller1, controller2]): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 2 + assert entry.data == {} + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_raises_not_ready_when_discovery_service_fails( + hass: HomeAssistant, +) -> None: + """async_setup_entry raises ConfigEntryNotReady when async_start_discovery_service raises OSError.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id="000000001", + data={CONF_HOST: "192.0.2.1"}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + new=AsyncMock(side_effect=OSError), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("initial_title", "expected_title"), + [ + pytest.param("iZone Aircon", "iZone 000000001", id="default_title_updated"), + pytest.param("My AC", "My AC", id="custom_title_preserved"), + ], +) +async def test_setup_entry_resolves_legacy_uid_and_updates_title( + hass: HomeAssistant, + initial_title: str, + expected_title: str, +) -> None: + """Legacy entry has its UID and title resolved at setup time, not migration time.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title=initial_title, + data={}, + ) + entry.add_to_hass(hass) + controller = create_mock_controller("000000001", "192.0.2.2") + + with ( + patch_discovered_controllers(controller), + patch.object( + hass.config_entries, + "async_forward_entry_setups", + new=AsyncMock(return_value=None), + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "000000001" + assert entry.title == expected_title + + +@pytest.mark.parametrize( + ("return_value", "side_effect"), + [ + pytest.param({}, None, id="no_controllers_found"), + pytest.param(None, OSError, id="discovery_oserror"), + ], +) +async def test_setup_entry_raises_not_ready_for_legacy_entry_on_discovery_failure( + hass: HomeAssistant, + return_value: dict | None, + side_effect: type[Exception] | None, +) -> None: + """Legacy entry raises ConfigEntryNotReady when discovery finds nothing or fails. + + Because this is raised from async_setup_entry (not async_migrate_entry), HA + will schedule a retry — unlike the old behaviour where the exception would + permanently land the entry in MIGRATION_ERROR. + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title="iZone Aircon", + data={}, + ) + entry.add_to_hass(hass) + + if side_effect is OSError: + patch_ctx = patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ) + else: + patch_ctx = patch_discovered_controllers([]) + + with patch_ctx: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_raises_config_error_for_legacy_entry_with_multiple_eligible( + hass: HomeAssistant, +) -> None: + """Legacy entry raises ConfigEntryError when multiple controllers are eligible. + + This is a permanent failure for the legacy entry. The controllers are not lost — + the discovery fan-out will surface them as individual flows once HA restarts. + This is not a breaking change: a v1 entry with multiple controllers was already + broken before this PR. + """ + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title="iZone Aircon", + data={}, + ) + entry.add_to_hass(hass) + controllers = { + "000000001": create_mock_controller("000000001", "192.0.2.1"), + "000000002": create_mock_controller("000000002", "192.0.2.2"), + } + + with patch_discovered_controllers( + [controllers["000000001"], controllers["000000002"]] + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + +@pytest.mark.parametrize( + ("excluded_uid", "already_configured_uid"), + [ + pytest.param("000000001", "999999999", id="filtered_by_exclude_list"), + pytest.param("999999999", "000000001", id="filtered_by_configured_entry"), + ], +) +async def test_setup_entry_picks_eligible_controller_after_filtering_for_legacy_entry( + hass: HomeAssistant, + excluded_uid: str, + already_configured_uid: str, +) -> None: + """Legacy entry picks the one controller not filtered out. + + In each case one of two discovered controllers is ineligible — either its + UID is in the exclude list or it is already owned by another config entry. + The dummy UID "999999999" is used for the filter that should have no effect. + """ + await async_load_yaml_exclude(hass, excluded_uid) + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title="iZone Aircon", + data={}, + ) + entry.add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, version=2, unique_id=already_configured_uid, data={} + ).add_to_hass(hass) + controllers = { + "000000001": create_mock_controller("000000001", "192.0.2.1"), + "000000002": create_mock_controller("000000002", "192.0.2.2"), + } + + with ( + patch_discovered_controllers( + [controllers["000000001"], controllers["000000002"]] + ), + patch.object( + hass.config_entries, + "async_forward_entry_setups", + new=AsyncMock(return_value=None), + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "000000002" + assert entry.data == {} + + +@pytest.mark.parametrize( + ("excluded_uid", "already_configured_uid"), + [ + pytest.param("000000001", "999999999", id="all_excluded"), + pytest.param("999999999", "000000001", id="all_already_configured"), + ], +) +async def test_setup_entry_raises_not_ready_for_legacy_entry_when_no_eligible_after_filter( + hass: HomeAssistant, + excluded_uid: str, + already_configured_uid: str, +) -> None: + """Legacy entry raises ConfigEntryNotReady when all controllers are filtered out. + + HA will retry async_setup_entry, giving the user time to resolve the filter + configuration. + """ + await async_load_yaml_exclude(hass, excluded_uid) + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id=DOMAIN, + title="iZone Aircon", + data={}, + ) + entry.add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, version=2, unique_id=already_configured_uid, data={} + ).add_to_hass(hass) + controller = create_mock_controller("000000001", "192.0.2.1") + + with patch_discovered_controllers(controller): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +def test_discovery_listener_methods_dispatch_expected_signals( + hass: HomeAssistant, +) -> None: + """Listener callbacks dispatch the expected Home Assistant signals.""" + service = izone_discovery.DiscoveryService(hass) + controller = create_mock_controller("000000001", "192.0.2.1") + zone = Mock() + err = RuntimeError("boom") + + with patch( + "homeassistant.components.izone.discovery.async_dispatcher_send" + ) as mock_send: + service.controller_disconnected(controller, err) + service.controller_reconnected(controller) + service.controller_update(controller) + service.zone_update(controller, zone) + + assert mock_send.call_args_list == [ + ((hass, izone_discovery.DISPATCH_CONTROLLER_DISCONNECTED, controller, err),), + ((hass, izone_discovery.DISPATCH_CONTROLLER_RECONNECTED, controller),), + ((hass, izone_discovery.DISPATCH_CONTROLLER_UPDATE, controller),), + ((hass, izone_discovery.DISPATCH_ZONE_UPDATE, controller, zone),), + ] + + +async def test_start_discovery_listener_forwards_discovered_controller_to_flow( + hass: HomeAssistant, + mock_pizone_discovery_service: Mock, +) -> None: + """Discovery dispatcher callback should forward discovered controllers to config flow.""" + captured_listener = None + + def _capture_listener(*args: object) -> Mock: + nonlocal captured_listener + captured_listener = args[2] + return Mock() + + controller = create_mock_controller("000000004", "192.0.2.4") + + with ( + patch( + "homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession", + return_value=Mock(), + ), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=mock_pizone_discovery_service, + ), + patch( + "homeassistant.components.izone.discovery.async_dispatcher_connect", + side_effect=_capture_listener, + ), + patch( + "homeassistant.components.izone.discovery.async_note_integration_discovery" + ) as mock_note, + ): + await izone_discovery.async_start_discovery_service(hass) + assert captured_listener is not None + captured_listener(controller) + + mock_note.assert_called_once_with(hass, controller) diff --git a/tests/components/izone/test_discovery.py b/tests/components/izone/test_discovery.py new file mode 100644 index 000000000000..0639ea92796e --- /dev/null +++ b/tests/components/izone/test_discovery.py @@ -0,0 +1,335 @@ +"""Tests for iZone discovery service.""" + +from unittest.mock import ANY, AsyncMock, Mock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.izone import discovery as izone_discovery +from homeassistant.components.izone.const import DATA_DISCOVERY_SERVICE, DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from .conftest import ( + async_load_yaml_exclude, + create_mock_controller, + create_mock_discovery_service, +) + +from tests.common import MockConfigEntry + + +async def test_async_start_discovery_service_stops_on_home_assistant_stop( + hass: HomeAssistant, + mock_pizone_discovery_service: Mock, +) -> None: + """Test discovery service is stopped on Home Assistant shutdown.""" + with ( + patch( + "homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession", + return_value=Mock(), + ), + patch( + "homeassistant.components.izone.discovery.pizone.discovery", + return_value=mock_pizone_discovery_service, + ), + ): + await izone_discovery.async_start_discovery_service(hass) + + assert DATA_DISCOVERY_SERVICE in hass.data + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_pizone_discovery_service.start_discovery.assert_awaited_once() + mock_pizone_discovery_service.close.assert_awaited_once() + assert DATA_DISCOVERY_SERVICE not in hass.data + + +async def test_async_maybe_stop_keeps_running_when_actionable_flow_exists( + hass: HomeAssistant, +) -> None: + """Discovery should stay running while an actionable iZone flow is in progress.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[{"context": {"source": config_entries.SOURCE_USER}}], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_maybe_stop_keeps_running_when_actionable_entry_exists( + hass: HomeAssistant, +) -> None: + """Discovery should stay running while an enabled, non-ignored entry exists.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_USER, + data={}, + ).add_to_hass(hass) + + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop: + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_maybe_stop_stops_when_only_disabled_entry_matches_controller( + hass: HomeAssistant, +) -> None: + """Discovery should stop when only disabled/ignored controllers remain.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_USER, + disabled_by=config_entries.ConfigEntryDisabler.USER, + data={}, + ).add_to_hass(hass) + + service = create_mock_discovery_service(create_mock_controller("000000001")) + hass.data[DATA_DISCOVERY_SERVICE] = service + + with patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop: + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_awaited_once_with(hass) + service.async_schedule_idle_stop.assert_not_called() + + +async def test_async_discover_controllers_starts_shared_service_when_missing( + hass: HomeAssistant, +) -> None: + """Starting discovery without refresh does not trigger extra wait/rescan work.""" + controller = create_mock_controller(device_ip="192.0.2.3") + service = create_mock_discovery_service(controller) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + return_value=service, + ) as mock_start: + controllers = await izone_discovery.async_discover_controllers(hass) + + assert list(controllers) == ["000000001"] + mock_start.assert_awaited_once() + service.pi_disco.fetch_controller.assert_not_awaited() + service.pi_disco.fetch_controllers.assert_awaited_once_with() + + +async def test_async_discover_controllers_refresh_after_start_calls_fetch_controllers( + hass: HomeAssistant, +) -> None: + """Refresh after starting discovery delegates to fetch_controllers with timeout.""" + controller = create_mock_controller(device_ip="192.0.2.3") + service = create_mock_discovery_service(controller) + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + return_value=service, + ) as mock_start: + controllers = await izone_discovery.async_discover_controllers( + hass, refresh=True + ) + + assert list(controllers) == ["000000001"] + mock_start.assert_awaited_once() + service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY) + + +async def test_async_discover_controllers_refresh_calls_fetch_controllers( + hass: HomeAssistant, +) -> None: + """Refresh without UID delegates to fetch_controllers with timeout.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + controllers = await izone_discovery.async_discover_controllers(hass, refresh=True) + + assert controllers == {} + service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY) + + +async def test_async_discover_controllers_waits_for_requested_uid( + hass: HomeAssistant, +) -> None: + """Refresh with wait_for_uid calls fetch_controller and returns all controllers.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + requested = create_mock_controller("000000777", "192.0.2.77") + + async def _fetch_and_add(uid: str, timeout: float | None = None) -> None: + service.pi_disco.controllers[uid] = requested + + service.pi_disco.fetch_controller.side_effect = _fetch_and_add + + controllers = await izone_discovery.async_discover_controllers( + hass, + refresh=True, + wait_for_uid="000000777", + ) + + assert controllers == {requested.device_uid: requested} + service.pi_disco.fetch_controller.assert_awaited_once_with("000000777", timeout=ANY) + + +async def test_async_discover_controllers_returns_empty_when_start_fails( + hass: HomeAssistant, +) -> None: + """Startup errors while creating discovery are propagated to the caller.""" + with ( + patch( + "homeassistant.components.izone.discovery.async_start_discovery_service", + side_effect=OSError, + ), + pytest.raises(OSError), + ): + await izone_discovery.async_discover_controllers(hass, refresh=True) + + +def test_controller_discovered_dispatches_signal_and_reschedules_idle_stop( + hass: HomeAssistant, +) -> None: + """Discovered controller should dispatch signal and cancel prior idle-stop handle.""" + service = izone_discovery.DiscoveryService(hass) + previous_handle = Mock() + new_handle = Mock() + service._idle_stop_handle = previous_handle + controller = create_mock_controller("000000001", "192.0.2.1") + + with ( + patch.object(hass.loop, "call_later", return_value=new_handle), + patch( + "homeassistant.components.izone.discovery.async_dispatcher_send" + ) as mock_send, + ): + service.controller_discovered(controller) + + previous_handle.cancel.assert_called_once() + assert service._idle_stop_handle is new_handle + mock_send.assert_called_once_with( + hass, + izone_discovery.DISPATCH_CONTROLLER_DISCOVERED, + controller, + ) + + +async def test_is_ignored_or_excluded_uid_returns_true_for_yaml_exclude( + hass: HomeAssistant, +) -> None: + """UIDs listed in YAML exclude are treated as ignored/excluded.""" + await async_load_yaml_exclude(hass, "000000009") + + assert izone_discovery._async_is_ignored_or_excluded_uid(hass, "000000009") is True + + +async def test_async_maybe_stop_returns_when_service_not_started( + hass: HomeAssistant, +) -> None: + """No-op when maybe-stop is called without a discovery service instance.""" + await izone_discovery.async_maybe_stop_discovery_service(hass) + + +async def test_async_start_discovery_service_returns_existing_instance( + hass: HomeAssistant, +) -> None: + """Starting discovery returns existing service when already running.""" + existing = Mock() + hass.data[DATA_DISCOVERY_SERVICE] = existing + + disco = await izone_discovery.async_start_discovery_service(hass) + + assert disco is existing + + +async def test_async_maybe_stop_stops_when_no_controllers_remain( + hass: HomeAssistant, +) -> None: + """Discovery stops when no controllers are tracked and nothing is actionable.""" + service = create_mock_discovery_service() + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_awaited_once_with(hass) + + +async def test_async_maybe_stop_keeps_running_when_controller_not_ignored( + hass: HomeAssistant, +) -> None: + """Discovery remains active if at least one discovered controller is still actionable.""" + service = create_mock_discovery_service(create_mock_controller("000000001")) + hass.data[DATA_DISCOVERY_SERVICE] = service + + with ( + patch.object( + hass.config_entries.flow, + "async_progress_by_handler", + return_value=[], + ), + patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + new=AsyncMock(), + ) as mock_stop, + ): + await izone_discovery.async_maybe_stop_discovery_service(hass) + + mock_stop.assert_not_awaited() + service.async_schedule_idle_stop.assert_called_once() + + +async def test_async_stop_discovery_service_returns_when_not_started( + hass: HomeAssistant, +) -> None: + """Stop is a no-op if discovery service was never started.""" + await izone_discovery.async_stop_discovery_service(hass) + + +async def test_async_stop_discovery_service_clears_stop_listener( + hass: HomeAssistant, +) -> None: + """Stop should remove the stop listener when it exists.""" + service = Mock() + stop_listener = Mock() + service.remove_stop_listener = stop_listener + service.remove_config_flow_listener = None + service.async_cancel_idle_stop = Mock() + service.pi_disco.close = AsyncMock() + hass.data[DATA_DISCOVERY_SERVICE] = service + + await izone_discovery.async_stop_discovery_service(hass) + + stop_listener.assert_called_once() + assert service.remove_stop_listener is None diff --git a/tests/components/jvc_projector/snapshots/test_init.ambr b/tests/components/jvc_projector/snapshots/test_init.ambr index 0842503ea258..c907e06a8a72 100644 --- a/tests/components/jvc_projector/snapshots/test_init.ambr +++ b/tests/components/jvc_projector/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'JVC Projector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/karakeep/__init__.py b/tests/components/karakeep/__init__.py new file mode 100644 index 000000000000..19620431a507 --- /dev/null +++ b/tests/components/karakeep/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Karakeep integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Karakeep integration.""" + 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/karakeep/conftest.py b/tests/components/karakeep/conftest.py new file mode 100644 index 000000000000..8d7f6a4ceb83 --- /dev/null +++ b/tests/components/karakeep/conftest.py @@ -0,0 +1,57 @@ +"""Karakeep tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.karakeep.const import DOMAIN +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL + +from .const import TEST_STATS, TEST_TOKEN, TEST_URL, TEST_VERSION + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.karakeep.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_karakeep_client() -> Generator[AsyncMock]: + """Mock a Karakeep client.""" + with ( + patch( + "homeassistant.components.karakeep.KarakeepClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.karakeep.config_flow.KarakeepClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.async_get_stats.return_value = TEST_STATS + client.async_get_version.return_value = TEST_VERSION + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Karakeep", + data={ + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + CONF_VERIFY_SSL: True, + }, + entry_id="01KVCW3ET6GZ025S7ARE8D5M8W", + ) diff --git a/tests/components/karakeep/const.py b/tests/components/karakeep/const.py new file mode 100644 index 000000000000..78566ff89232 --- /dev/null +++ b/tests/components/karakeep/const.py @@ -0,0 +1,16 @@ +"""Constants for Karakeep tests.""" + +from aiokarakeep import KarakeepStats + +TEST_STATS = KarakeepStats( + num_bookmarks=10, + num_favorites=2, + num_archived=3, + num_highlights=4, + num_lists=5, + num_tags=6, +) + +TEST_VERSION = "0.32.0" +TEST_TOKEN = "test-token" +TEST_URL = "https://karakeep.example.com" diff --git a/tests/components/karakeep/snapshots/test_sensor.ambr b/tests/components/karakeep/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..606ca7287550 --- /dev/null +++ b/tests/components/karakeep/snapshots/test_sensor.ambr @@ -0,0 +1,325 @@ +# serializer version: 1 +# name: test_sensors[sensor.karakeep_archived-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_archived', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Archived', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Archived', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'archived', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_archived', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_sensors[sensor.karakeep_archived-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Archived', + : , + : 'items', + }), + 'context': , + 'entity_id': 'sensor.karakeep_archived', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.karakeep_bookmarks-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_bookmarks', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bookmarks', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bookmarks', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bookmarks', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_bookmarks', + 'unit_of_measurement': 'bookmarks', + }) +# --- +# name: test_sensors[sensor.karakeep_bookmarks-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Bookmarks', + : , + : 'bookmarks', + }), + 'context': , + 'entity_id': 'sensor.karakeep_bookmarks', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.karakeep_favorites-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_favorites', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Favorites', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Favorites', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'favorites', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_favorites', + 'unit_of_measurement': 'favorites', + }) +# --- +# name: test_sensors[sensor.karakeep_favorites-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Favorites', + : , + : 'favorites', + }), + 'context': , + 'entity_id': 'sensor.karakeep_favorites', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[sensor.karakeep_highlights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_highlights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Highlights', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highlights', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'highlights', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_highlights', + 'unit_of_measurement': 'highlights', + }) +# --- +# name: test_sensors[sensor.karakeep_highlights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Highlights', + : , + : 'highlights', + }), + 'context': , + 'entity_id': 'sensor.karakeep_highlights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[sensor.karakeep_lists-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_lists', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lists', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lists', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lists', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_lists', + 'unit_of_measurement': 'lists', + }) +# --- +# name: test_sensors[sensor.karakeep_lists-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Lists', + : , + : 'lists', + }), + 'context': , + 'entity_id': 'sensor.karakeep_lists', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[sensor.karakeep_tags-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.karakeep_tags', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tags', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tags', + 'platform': 'karakeep', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tags', + 'unique_id': '01KVCW3ET6GZ025S7ARE8D5M8W_tags', + 'unit_of_measurement': 'tags', + }) +# --- +# name: test_sensors[sensor.karakeep_tags-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Karakeep Tags', + : , + : 'tags', + }), + 'context': , + 'entity_id': 'sensor.karakeep_tags', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- diff --git a/tests/components/karakeep/test_config_flow.py b/tests/components/karakeep/test_config_flow.py new file mode 100644 index 000000000000..89cfb333b28e --- /dev/null +++ b/tests/components/karakeep/test_config_flow.py @@ -0,0 +1,163 @@ +"""Tests for the Karakeep config flow.""" + +from unittest.mock import AsyncMock + +from aiokarakeep import KarakeepApiError, KarakeepAuthError, KarakeepConnectionError +import pytest + +from homeassistant.components.karakeep.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import TEST_TOKEN, TEST_URL + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_full_flow(hass: HomeAssistant, mock_karakeep_client: AsyncMock) -> None: + """Test the 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: f"{TEST_URL}/", + CONF_TOKEN: f" {TEST_TOKEN} ", + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Karakeep" + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + CONF_VERIFY_SSL: False, + } + assert result["result"].unique_id is None + mock_karakeep_client.async_get_stats.assert_awaited_once() + + +@pytest.mark.parametrize("invalid_url", ["karakeep.example.com", "http://["]) +@pytest.mark.usefixtures("mock_setup_entry", "mock_karakeep_client") +async def test_invalid_url(hass: HomeAssistant, invalid_url: str) -> None: + """Test invalid URL errors.""" + 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_URL: invalid_url, + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_url_format"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + CONF_VERIFY_SSL: True, + } + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (KarakeepAuthError("Invalid token", 401), "invalid_auth"), + (KarakeepConnectionError("Cannot connect"), "cannot_connect"), + (KarakeepApiError("API error", 500), "api_error"), + (Exception("Boom"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_flow_errors( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + side_effect: Exception, + error: str, +) -> None: + """Test config flow errors.""" + mock_karakeep_client.async_get_stats.side_effect = side_effect + + 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_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_karakeep_client.async_get_stats.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: TEST_URL, + CONF_TOKEN: TEST_TOKEN, + CONF_VERIFY_SSL: True, + } + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_duplicate( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate config flow aborts.""" + 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_URL: f"{TEST_URL}/", + CONF_TOKEN: TEST_TOKEN, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_karakeep_client.async_get_stats.assert_not_awaited() diff --git a/tests/components/karakeep/test_init.py b/tests/components/karakeep/test_init.py new file mode 100644 index 000000000000..c76941b9d69d --- /dev/null +++ b/tests/components/karakeep/test_init.py @@ -0,0 +1,108 @@ +"""Tests for the Karakeep integration setup.""" + +from unittest.mock import AsyncMock, patch + +from aiokarakeep import ( + KarakeepApiError, + KarakeepAuthError, + KarakeepConnectionError, + KarakeepInvalidResponseError, +) +import pytest + +from homeassistant.components.karakeep.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting up the integration.""" + with patch( + "homeassistant.components.karakeep.async_get_clientsession" + ) as mock_get_clientsession: + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_get_clientsession.assert_called_once_with(hass, True) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + mock_karakeep_client.async_get_stats.assert_awaited_once() + + +@pytest.mark.parametrize( + "side_effect", + [ + KarakeepAuthError("Invalid token", 401), + KarakeepConnectionError("Cannot connect"), + KarakeepApiError("API error", 500), + KarakeepInvalidResponseError("Invalid response"), + ], +) +async def test_setup_entry_update_failure( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test setup retries on update failures.""" + mock_karakeep_client.async_get_stats.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_version_failure( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setup retries when the version fetch fails.""" + mock_karakeep_client.async_get_version.side_effect = KarakeepConnectionError( + "Cannot connect" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_version_unavailable( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test setup succeeds without a version when the endpoint is unavailable.""" + mock_karakeep_client.async_get_version.return_value = None + + 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.entry_id)} + ) + assert device_entry is not None + assert device_entry.sw_version is None + + +async def test_unload_entry( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + 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/karakeep/test_sensor.py b/tests/components/karakeep/test_sensor.py new file mode 100644 index 000000000000..44daeb05ac09 --- /dev/null +++ b/tests/components/karakeep/test_sensor.py @@ -0,0 +1,48 @@ +"""Tests for the Karakeep 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 device_registry as dr, entity_registry as er + +from . import setup_integration +from .const import TEST_VERSION + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Karakeep sensors.""" + with patch("homeassistant.components.karakeep.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_device_info( + hass: HomeAssistant, + mock_karakeep_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry entry.""" + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={("karakeep", mock_config_entry.entry_id)} + ) + assert device_entry is not None + assert device_entry.name == "Karakeep" + assert device_entry.manufacturer == "Karakeep" + assert device_entry.sw_version == TEST_VERSION diff --git a/tests/components/kiosker/snapshots/test_init.ambr b/tests/components/kiosker/snapshots/test_init.ambr index 403237a6f51a..6421912a9068 100644 --- a/tests/components/kiosker/snapshots/test_init.ambr +++ b/tests/components/kiosker/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Kiosker A98BE1CE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'A98BE1CE-5FE7-4A8D-B2C3-123456789ABC', 'sw_version': 'Kiosker 25.1.1', 'via_device_id': None, diff --git a/tests/components/kiosker/test_config_flow.py b/tests/components/kiosker/test_config_flow.py index 4dd0a57ca205..72ab7740bebb 100644 --- a/tests/components/kiosker/test_config_flow.py +++ b/tests/components/kiosker/test_config_flow.py @@ -275,3 +275,75 @@ async def test_user_flow_no_device_id( ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_flow_success( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow updates the token and reloads.""" + 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" + + 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" + assert mock_config_entry.data[CONF_API_TOKEN] == "new-token" + + +async def test_reauth_flow_wrong_device( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow aborts when a different device is found at the host.""" + mock_config_entry.add_to_hass(hass) + mock_kiosker_api.status.return_value.device_id = "DIFFERENT-DEVICE-ID" + + result = await mock_config_entry.start_reauth_flow(hass) + 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"] == "wrong_device" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param(ConnectionError, "cannot_connect", id="connection_error"), + pytest.param(AuthenticationError, "invalid_auth", id="auth_error"), + pytest.param(IPAuthenticationError, "invalid_ip_auth", id="ip_auth_error"), + pytest.param(TLSVerificationError, "tls_error", id="tls_error"), + pytest.param(BadRequestError, "bad_request", id="bad_request"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_kiosker_api: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test reauth flow shows correct error for each API exception.""" + mock_config_entry.add_to_hass(hass) + mock_kiosker_api.status.side_effect = side_effect + + result = await mock_config_entry.start_reauth_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "bad-token"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": expected_error} diff --git a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr index d4e2620bbe64..b919d0f61957 100644 --- a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr +++ b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr @@ -1,25 +1,6 @@ # serializer version: 1 # name: test_states set({ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'editable': True, - : 'test home', - : 'mdi:home', - 'latitude': 32.87336, - 'longitude': -117.22743, - 'passive': False, - 'persons': list([ - ]), - 'radius': 100, - }), - 'context': , - 'entity_id': 'zone.home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }), StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Demo scanner', @@ -43,8 +24,8 @@ : list([ 'zone.home', ]), - : 32.87336, - : -117.22743, + : 32.87336, + : -117.22743, : , : , }), @@ -55,5 +36,24 @@ 'last_updated': , 'state': 'home', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'test home', + : 'mdi:home', + : 32.87336, + : -117.22743, + : False, + : list([ + ]), + : 100, + }), + 'context': , + 'entity_id': 'zone.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }), }) # --- diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index e91b88c2a551..7c54d4ddc62d 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -52,8 +52,8 @@ # name: test_state.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -74,7 +74,6 @@ 'model_id': None, 'name': 'Outlet 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -83,8 +82,8 @@ # name: test_state.3 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -105,7 +104,6 @@ 'model_id': None, 'name': 'Power strip with 2 sockets', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -164,8 +162,8 @@ # name: test_state.6 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -186,7 +184,6 @@ 'model_id': None, 'name': 'Outlet 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -195,8 +192,8 @@ # name: test_state.7 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -217,7 +214,6 @@ 'model_id': None, 'name': 'Power strip with 2 sockets', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 7d69cda3d788..5cfab33adf34 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -32,10 +32,12 @@ from homeassistant.components.knx.const import ( CONF_KNX_MCAST_PORT, CONF_KNX_RATE_LIMIT, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DEFAULT_ROUTING_IA, DOMAIN, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, ) @@ -364,6 +366,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, }, ) diff --git a/tests/components/knx/fixtures/config_store_button.json b/tests/components/knx/fixtures/config_store_button.json new file mode 100644 index 000000000000..41adf5d283d8 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_button.json @@ -0,0 +1,44 @@ +{ + "version": 2, + "minor_version": 3, + "key": "knx/config_store.json", + "data": { + "entities": { + "button": { + "knx_es_01KVFEGP54VJW94TR9GQW2XA4R": { + "entity": { + "name": "test raw", + "device_info": null, + "entity_category": null + }, + "knx": { + "data": { + "payload": "0x1", + "payload_length": 1 + }, + "ga_send": { + "write": "1/1/1" + } + } + }, + "knx_es_01KVFEHE937CQGWP81RZNQ6D8E": { + "entity": { + "name": "test typed", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_send": { + "write": "1/1/2", + "dpt": "1.001" + }, + "data": { + "value": "on" + } + } + } + } + }, + "time_server": {} + } +} diff --git a/tests/components/knx/snapshots/test_diagnostic.ambr b/tests/components/knx/snapshots/test_diagnostic.ambr index 314a856fe17f..1cc0d93c2382 100644 --- a/tests/components/knx/snapshots/test_diagnostic.ambr +++ b/tests/components/knx/snapshots/test_diagnostic.ambr @@ -10,6 +10,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), @@ -48,7 +49,9 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, + 'telegram_db_postgres_dsn': '**REDACTED**', 'telegram_db_retention_days': 10, }), 'config_store': dict({ @@ -79,6 +82,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), @@ -110,6 +114,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 072bfa46f0be..b355dcc3b51e 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -129,6 +129,39 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[button] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'name': 'ga_send', + 'options': dict({ + 'dptClasses': list([ + 'numeric', + 'enum', + 'complex', + 'string', + ]), + 'passive': False, + 'state': False, + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'ga_path': 'ga_send', + 'name': 'data', + 'required': True, + 'type': 'knx_payload', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[climate] dict({ 'id': 1, diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 38ccb36200b0..21d6e136e74b 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -2,22 +2,32 @@ from datetime import timedelta import logging +from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.knx.const import ( CONF_PAYLOAD_LENGTH, + CONF_VALUE, KNX_ADDRESS, KNX_MODULE_KEY, ) from homeassistant.components.knx.schema import ButtonSchema -from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_TYPE +from homeassistant.const import ( + CONF_NAME, + CONF_PAYLOAD, + CONF_TYPE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import async_capture_events, async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_button_simple( @@ -83,7 +93,7 @@ async def test_button_type(hass: HomeAssistant, knx: KNXTestKit) -> None: ButtonSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", - ButtonSchema.CONF_VALUE: 21.5, + CONF_VALUE: 21.5, CONF_TYPE: "2byte_float", } } @@ -125,7 +135,7 @@ async def test_button_invalid( ButtonSchema.PLATFORM: { CONF_NAME: "test", KNX_ADDRESS: "1/2/3", - ButtonSchema.CONF_VALUE: conf_value, + CONF_VALUE: conf_value, CONF_TYPE: conf_type, } } @@ -139,3 +149,128 @@ async def test_button_invalid( assert "Setup failed for 'knx': Invalid config." in record.message assert hass.states.get("button.test") is None assert hass.data.get(KNX_MODULE_KEY) is None + + +@pytest.mark.parametrize( + "knx_config", + [ + ( + { + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "1", "payload_length": 1}, # raw payload + } + ), + ( + { + "ga_send": {"write": "1/1/1", "dpt": "5"}, # generic 1byte uint + "data": {"payload": "0x01", "payload_length": 1}, # raw payload + } + ), + ( + { + "ga_send": {"write": "1/1/1", "dpt": "5"}, # generic 1byte uint + "data": {"value": 1}, # typed value + } + ), + ], +) +async def test_button_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_config: dict[str, Any], +) -> None: + """Test creating a button.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.BUTTON, + entity_data={"name": "test"}, + knx_data=knx_config, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test"}, blocking=True + ) + await knx.assert_write("1/1/1", (1,)) + + +async def test_button_ui_load(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test loading a button from storage.""" + await knx.setup_integration(config_store_fixture="config_store_button.json") + + # Raw button configuration + knx.assert_state( + "button.test_raw", + STATE_UNKNOWN, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_raw"}, blocking=True + ) + await knx.assert_write("1/1/1", (1,)) + + # Typed button configuration + knx.assert_state( + "button.test_typed", + STATE_UNKNOWN, + ) + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_typed"}, blocking=True + ) + await knx.assert_write("1/1/2", True) + + +@pytest.mark.parametrize( + "knx_config", + [ + { # missing data + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + }, + { # missing DPT + "ga_send": {"write": "1/1/1"}, + "data": {"value": 1}, + }, + { # invalid value for DPT + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + "data": {"value": "not_valid"}, + }, + { # invalid length for DPT + "ga_send": {"write": "1/1/1", "dpt": "9.001"}, + "data": {"payload": "0x1", "payload_length": 1}, + }, + { # out of bound value for DPT + "ga_send": {"write": "1/1/1", "dpt": "5.001"}, + "data": {"value": 101}, + }, + { # out of bound value for length + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "0x100", "payload_length": 1}, + }, + { # out of bound value for zero-length + "ga_send": {"write": "1/1/1"}, + "data": {"payload": "0x40", "payload_length": 0}, + }, + ], +) +async def test_button_ui_create_data_validation( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + knx_config: dict[str, Any], +) -> None: + """Test creating a button with invalid data.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "knx/create_entity", + "platform": Platform.BUTTON, + "data": { + "entity": {"name": "test"}, + "knx": knx_config, + }, + } + ) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["success"] is False + assert res["result"]["error_base"] + assert res["result"]["errors"][0]["path"] diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 982284db1803..27be1a6f5d4b 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1,8 +1,10 @@ """Test the KNX config flow.""" +import asyncio from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, Mock, patch +from knx_telegram_store.connection import ConnectionCheckResult, ConnectionErrorKind import pytest from xknx.exceptions import XKNXException from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration @@ -21,6 +23,8 @@ from homeassistant.components.knx.config_flow import ( DEFAULT_ENTRY_DATA, DEFAULT_ENTRY_OPTIONS, OPTION_MANUAL_TUNNEL, + _build_dsn, + _parse_dsn, ) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -41,13 +45,17 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DOMAIN, + KNX_TELEGRAM_BACKEND_POSTGRES, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, ) @@ -1065,6 +1073,7 @@ async def test_form_with_automatic_connection_handling( CONF_KNX_STATE_UPDATER: True, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } knx_setup.assert_called_once() @@ -1690,6 +1699,7 @@ async def test_options_communication_settings( CONF_KNX_TELEGRAM_STORE_SECTION: { CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, }, }, ) @@ -1699,6 +1709,7 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } assert mock_config_entry.data == initial_data assert mock_config_entry.options == { @@ -1706,5 +1717,313 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } assert len(knx_setup.mock_calls) == 2 + + +async def _advance_to_postgres_step( + hass: HomeAssistant, flow_id: str, *, retention_days: int = 14 +) -> config_entries.ConfigFlowResult: + """Select the PostgreSQL backend and land on its connection step.""" + result = await hass.config_entries.options.async_configure( + flow_id, + user_input={ + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_STORE_SECTION: { + CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: retention_days, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + }, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert not result["errors"] + return result + + +async def test_options_telegram_store_postgres( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow selecting the PostgreSQL telegram store backend.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.success(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND] + == KNX_TELEGRAM_BACKEND_POSTGRES + ) + assert mock_config_entry.options[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] == 14 + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN] + == "postgresql://knx:s3cret@db.local:5432/knx_telegrams?sslmode=require" + ) + assert len(knx_setup.mock_calls) == 2 + + +async def test_options_telegram_store_postgres_reuses_password( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the PostgreSQL store reuses the stored password when left blank.""" + existing_dsn = "postgresql://olduser:oldpass@old.host:6543/olddb?sslmode=require" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: existing_dsn, + }, + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"], retention_days=7) + + # Submit with an empty password - the existing one (parsed from the DSN) + # must be reused. + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.success(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "new.host", + "port": 5432, + "user": "newuser", + "password": "", + "database": "newdb", + "tls": False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN] + == "postgresql://newuser:oldpass@new.host:5432/newdb" + ) + assert len(knx_setup.mock_calls) == 2 + + +@pytest.mark.parametrize( + ("error_kind", "expected_error"), + [ + pytest.param(ConnectionErrorKind.AUTH, "invalid_auth", id="invalid_auth"), + pytest.param( + ConnectionErrorKind.HOST_UNREACHABLE, + "host_unreachable", + id="host_unreachable", + ), + ], +) +async def test_options_telegram_store_postgres_connection_failure( + hass: HomeAssistant, + knx_setup: AsyncMock, + mock_config_entry: MockConfigEntry, + error_kind: ConnectionErrorKind, + expected_error: str, +) -> None: + """Test the PostgreSQL step maps connection check failures to form errors.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.failure(error_kind, "check failed"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "wrong_password", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": expected_error} + + +async def test_options_telegram_store_postgres_timeout( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow surfaces a timeout when the connection check hangs.""" + + async def hanging_check(dsn: str) -> None: + await asyncio.Event().wait() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with ( + patch("homeassistant.components.knx.config_flow.DSN_CHECK_TIMEOUT", 0.05), + patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + side_effect=hanging_check, + ), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": "timeout"} + + +async def test_options_telegram_store_postgres_malformed_dsn( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the PostgreSQL step maps a DSN the driver rejects to a form error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + # An unterminated bracketed IPv6 address makes engine creation + # raise ValueError before any connection attempt. + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "[::1", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": False, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.parametrize( + ("dsn", "expected"), + [ + pytest.param("", {}, id="empty"), + # Invalid port makes urlparse.port raise ValueError -> {} + pytest.param("postgresql://host:notaport/db", {}, id="invalid_port"), + pytest.param( + "postgresql://u:p@h:5432/db?sslmode=require", + { + "user": "u", + "password": "p", + "host": "h", + "port": 5432, + "database": "db", + "tls": True, + }, + id="full", + ), + pytest.param( + "postgresql://user%40domain:p%40ss%25word@h:5432/db", + { + "user": "user@domain", + "password": "p@ss%word", + "host": "h", + "port": 5432, + "database": "db", + "tls": False, + }, + id="percent_encoded_credentials", + ), + pytest.param( + "postgresql://u:p@[2001:db8::1]:5432/db", + { + "user": "u", + "password": "p", + "host": "2001:db8::1", + "port": 5432, + "database": "db", + "tls": False, + }, + id="ipv6_host", + ), + pytest.param( + "postgresql://u:p@h:5432/db%3Fquery%23hash", + { + "user": "u", + "password": "p", + "host": "h", + "port": 5432, + "database": "db?query#hash", + "tls": False, + }, + id="percent_encoded_database", + ), + ], +) +def test_parse_dsn(dsn: str, expected: dict) -> None: + """Test PostgreSQL DSN parsing, including malformed input.""" + assert _parse_dsn(dsn) == expected + + +@pytest.mark.parametrize( + ("user", "password", "host", "database"), + [ + pytest.param("simple", "plain", "localhost", "knx", id="plain"), + pytest.param("user@domain", "p@ss", "localhost", "knx", id="at_sign"), + pytest.param("user", "p@ss%word", "localhost", "knx", id="percent_sign"), + pytest.param( + "us:er", "p/a:s@s", "localhost", "knx", id="multiple_special_chars" + ), + pytest.param("user", "pass", "2001:db8::1", "knx", id="ipv6_host"), + pytest.param( + "user", "pass", "localhost", "knx?query#hash", id="database_special_chars" + ), + ], +) +def test_dsn_round_trip(user: str, password: str, host: str, database: str) -> None: + """Test _build_dsn -> _parse_dsn -> _build_dsn produces identical DSNs. + + Catches double percent-encoding: urlparse returns percent-encoded values, + so _parse_dsn must decode them before they are fed back into _build_dsn. + IPv6 hosts must be bracketed in the netloc for the DSN to stay parseable. + Database names with URL delimiters are percent-encoded to prevent truncation. + """ + params = { + "user": user, + "password": password, + "host": host, + "port": 5432, + "database": database, + "tls": False, + } + dsn1 = _build_dsn(params) + parsed = _parse_dsn(dsn1) + dsn2 = _build_dsn(parsed) + assert dsn1 == dsn2 diff --git a/tests/components/knx/test_date.py b/tests/components/knx/test_date.py index 98e35d16db0f..5e25c7f1651c 100644 --- a/tests/components/knx/test_date.py +++ b/tests/components/knx/test_date.py @@ -5,7 +5,11 @@ from homeassistant.components.date import ( DOMAIN as DATE_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import DateSchema from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State @@ -92,6 +96,33 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "2024-02-24" +async def test_date_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX date with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("date.test", "2023-07-24") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("date.test") + assert state.state == "2023-07-24" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x18, 0x02, 0x18)) + state = hass.states.get("date.test") + assert state.state == "2024-02-24" + + async def test_date_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_datetime.py b/tests/components/knx/test_datetime.py index b79e8abe8a63..e8107b068ef8 100644 --- a/tests/components/knx/test_datetime.py +++ b/tests/components/knx/test_datetime.py @@ -5,7 +5,11 @@ from homeassistant.components.datetime import ( DOMAIN as DATETIME_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import DateTimeSchema from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, State @@ -96,6 +100,36 @@ async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "2020-01-01T18:04:05+00:00" +async def test_datetime_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX datetime with state_address restores state until bus read completes.""" + await hass.config.async_set_time_zone("Europe/Vienna") + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("datetime.test", "2022-03-03T03:04:05+00:00") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateTimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response( + test_state_address, (0x78, 0x01, 0x01, 0x73, 0x04, 0x05, 0x20, 0x80) + ) + state = hass.states.get("datetime.test") + assert state.state == "2020-01-01T18:04:05+00:00" + + async def test_datetime_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index f35bad74eb46..2f1aa1e8c0a0 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -20,6 +20,7 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, DEFAULT_ROUTING_IA, DOMAIN, ) @@ -100,6 +101,11 @@ async def test_diagnostic_redact( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: "device_authentication", CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", }, + options={ + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: ( + "postgresql://knx:supersecret@localhost:5432/knx_telegrams" + ), + }, ) knx: KNXTestKit = KNXTestKit(hass, mock_config_entry, hass_storage) await knx.setup_integration() diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 5a114762f649..87ddd2f8c048 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -38,12 +38,14 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DOMAIN, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, KNXConfigEntryData, @@ -437,3 +439,30 @@ async def test_async_migrate_entry_future_version(hass: HomeAssistant) -> None: with patch("homeassistant.components.knx.async_setup_entry", return_value=True): assert not await hass.config_entries.async_setup(config_entry.entry_id) + + +async def test_async_migrate_entry_v2_to_v2_2(hass: HomeAssistant) -> None: + """Test KNX config entry migration from v2.x to v2.2.""" + config_entry = MockConfigEntry( + title="KNX", + domain=DOMAIN, + version=2, + minor_version=1, + data={ + "other_setting": "some_value", + }, + options={ + "some_option": "value", + }, + ) + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.knx.async_setup_entry", return_value=True): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.version == 2 + assert config_entry.minor_version == 2 + assert ( + config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND] + == KNX_TELEGRAM_BACKEND_SQLITE + ) diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index f4b8856cabe4..e00e3dfe4c44 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -5,7 +5,11 @@ from typing import Any import pytest -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import NumberSchema from homeassistant.const import CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant, State @@ -112,6 +116,43 @@ async def test_number_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) assert state.state == "9000.96" +async def test_number_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX number with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + + RESTORE_DATA = { + "native_max_value": None, # Ignored by KNX number + "native_min_value": None, # Ignored by KNX number + "native_step": None, # Ignored by KNX number + "native_unit_of_measurement": None, # Ignored by KNX number + "native_value": 160.0, + } + mock_restore_cache_with_extra_data( + hass, ((State("number.test", "abc"), RESTORE_DATA),) + ) + + await knx.setup_integration( + { + NumberSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + CONF_TYPE: "illuminance", + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("number.test") + assert state.state == "160.0" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x4E, 0xDE)) + state = hass.states.get("number.test") + assert state.state == "9000.96" + + @pytest.mark.parametrize( "attribute_config", [ diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index b53dfae2658b..3bec54abed1b 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -125,6 +125,40 @@ async def test_select_dpt_2_restore(hass: HomeAssistant, knx: KNXTestKit) -> Non await knx.assert_no_telegram() +async def test_select_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX select with state_address restores state until bus read completes.""" + _options = [ + {CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, + {CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, + {CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, + ] + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("select.test", "Control - On") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + SelectSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + CONF_PAYLOAD_LENGTH: 0, + SelectSchema.CONF_OPTIONS: _options, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("select.test") + assert state.state == "Control - On" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, 0b10) + state = hass.states.get("select.test") + assert state.state == "Control - Off" + + async def test_select_dpt_20_103_all_options( hass: HomeAssistant, knx: KNXTestKit ) -> None: diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index 025c034cacf2..480a585abfa3 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -6,7 +6,7 @@ from homeassistant.components.knx.const import ( KNX_ADDRESS, ) from homeassistant.components.knx.schema import SwitchSchema -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State from . import KnxEntityGenerator @@ -70,6 +70,8 @@ async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit) -> None: # StateUpdater initialize state await knx.assert_read(_STATE_ADDRESS) + # state is unknown until the first GroupValueRead response is received + assert hass.states.get("switch.test").state is STATE_UNKNOWN await knx.receive_response(_STATE_ADDRESS, True) state = hass.states.get("switch.test") assert state.state is STATE_ON @@ -111,6 +113,39 @@ async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_telegram_count(0) +async def test_switch_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX switch with state_address restores last known state until bus read completes.""" + _ADDRESS = "1/1/1" + _STATE_ADDRESS = "2/2/2" + fake_state = State("switch.test", STATE_ON) + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + SwitchSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: _ADDRESS, + CONF_STATE_ADDRESS: _STATE_ADDRESS, + }, + } + ) + + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(_STATE_ADDRESS) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # bus confirms restored value - no additional state change expected + await knx.receive_response(_STATE_ADDRESS, True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # bus reports a different value than restored - state updates to the real value + await knx.receive_write(_STATE_ADDRESS, False) + state = hass.states.get("switch.test") + assert state.state is STATE_OFF + + async def test_switch_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test restoring KNX switch state and respond to read.""" _ADDRESS = "1/1/1" diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index add2fff644f8..5912938256c2 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from copy import copy from datetime import datetime from unittest.mock import AsyncMock, patch @@ -11,9 +12,12 @@ from knx_telegram_store import KnxTelegramStoreException, StoredTelegram, Telegr import pytest from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR, ) from homeassistant.components.knx.telegrams import TelegramDict @@ -156,6 +160,34 @@ async def test_store_telegram_history_error_handling( assert issue is not None +async def test_store_telegram_history_needs_migration_timeout( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test that store initialization is aborted when needs_migration times out.""" + + async def hanging_probe() -> bool: + await asyncio.Event().wait() + return False + + with ( + patch("homeassistant.components.knx.telegrams.STORE_INIT_TIMEOUT", 0.05), + patch( + "knx_telegram_store.BufferedSqliteStore.needs_migration", + side_effect=hanging_probe, + ), + ): + await knx.setup_integration() + + telegrams_module = hass.data[KNX_MODULE_KEY].telegrams + assert telegrams_module.store is None + + # Check that the repair issue was created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) + assert issue is not None + + async def test_migrate_telegrams_from_json( hass: HomeAssistant, knx: KNXTestKit, @@ -483,3 +515,39 @@ async def test_nightly_eviction_error_handling( assert "Database error evicting expired KNX telegrams" in caplog.text # Store remains operational after the failed eviction assert telegrams_module.store is not None + + +async def test_postgres_backend_init_error( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test PostgreSQL backend DSN handling and init failure path.""" + dsn = "postgresql://user:secret@db.local:5432/knx" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: dsn, + }, + ) + + # Mock the store to avoid constructing a real SQLAlchemy engine / connecting. + mock_store = AsyncMock() + mock_store.needs_migration.return_value = False + mock_store.initialize.side_effect = KnxTelegramStoreException("no server") + with patch( + "homeassistant.components.knx.telegrams.BufferedPostgresStore", + return_value=mock_store, + ): + await knx.setup_integration(add_entry_to_hass=False) + + telegrams_module = hass.data[KNX_MODULE_KEY].telegrams + assert telegrams_module.store is None + + issue_registry = ir.async_get(hass) + assert ( + issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) + is not None + ) diff --git a/tests/components/knx/test_text.py b/tests/components/knx/test_text.py index b2222ff025b8..7eb25399db5f 100644 --- a/tests/components/knx/test_text.py +++ b/tests/components/knx/test_text.py @@ -1,6 +1,10 @@ """Test KNX number.""" -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import TextSchema from homeassistant.components.text import TextMode from homeassistant.const import CONF_NAME, Platform @@ -103,6 +107,36 @@ async def test_text_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "hallo" +async def test_text_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX text with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("text.test", "test test") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + TextSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("text.test") + assert state.state == "test test" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response( + test_state_address, + (0x68, 0x61, 0x6C, 0x6C, 0x6F, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0), + ) + state = hass.states.get("text.test") + assert state.state == "hallo" + + async def test_text_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_time.py b/tests/components/knx/test_time.py index 08a4edff70f4..19e069706d3d 100644 --- a/tests/components/knx/test_time.py +++ b/tests/components/knx/test_time.py @@ -1,6 +1,10 @@ """Test KNX time.""" -from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) from homeassistant.components.knx.schema import TimeSchema from homeassistant.components.time import ( ATTR_TIME, @@ -92,6 +96,33 @@ async def test_time_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> assert state.state == "12:00:00" +async def test_time_state_restore(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX time with state_address restores state until bus read completes.""" + test_address = "1/1/1" + test_state_address = "2/2/2" + fake_state = State("time.test", "01:02:03") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + TimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_STATE_ADDRESS: test_state_address, + } + } + ) + # StateUpdater initialize state - restored value is used before response is received + await knx.assert_read(test_state_address) + state = hass.states.get("time.test") + assert state.state == "01:02:03" + + # bus reports a different value than restored - state updates to the real value + await knx.receive_response(test_state_address, (0x0C, 0x00, 0x00)) + state = hass.states.get("time.test") + assert state.state == "12:00:00" + + async def test_time_ui_create( hass: HomeAssistant, knx: KNXTestKit, diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 0f5f9af1c37f..124122d99117 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -10,8 +10,11 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, KNX_ADDRESS, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, SUPPORTED_PLATFORMS_UI, ) from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY @@ -37,10 +40,46 @@ async def test_knx_get_base_data_command( assert res["result"]["connection_info"]["version"] is not None assert res["result"]["connection_info"]["connected"] assert res["result"]["connection_info"]["current_address"] == "0.0.0" + assert res["result"]["connection_info"]["telegram_backend"] == "sqlite" assert res["result"]["project_info"] is None assert not SUPPORTED_PLATFORMS_UI.difference(res["result"]["supported_platforms"]) +async def test_knx_get_base_data_command_postgres( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +) -> None: + """Test knx/get_base_data reports the PostgreSQL telegram backend.""" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: "postgresql://user:pw@db.local:5432/knx", + }, + ) + # Patch methods on the real class so the isinstance check in the + # websocket handler still sees a BufferedPostgresStore instance. + with ( + patch( + "knx_telegram_store.BufferedPostgresStore.needs_migration", + return_value=False, + ), + patch("knx_telegram_store.BufferedPostgresStore.initialize"), + patch( + "knx_telegram_store.BufferedPostgresStore.get_last_unique_telegrams", + return_value=[], + ), + ): + await knx.setup_integration(add_entry_to_hass=False) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "knx/get_base_data"}) + res = await client.receive_json() + + assert res["success"], res + assert res["result"]["connection_info"]["telegram_backend"] == "postgres" + + @pytest.mark.usefixtures("load_knxproj") async def test_knx_get_base_data_command_with_project( hass: HomeAssistant, diff --git a/tests/components/lamarzocco/snapshots/test_bluetooth.ambr b/tests/components/lamarzocco/snapshots/test_bluetooth.ambr index 7749a94d7d98..177ebb7daa47 100644 --- a/tests/components/lamarzocco/snapshots/test_bluetooth.ambr +++ b/tests/components/lamarzocco/snapshots/test_bluetooth.ambr @@ -16,8 +16,8 @@ # name: test_setup_through_bluetooth_only[GS3 AV-entities1][device_bluetooth_GS012345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -42,7 +42,6 @@ 'model_id': 'GS3AV', 'name': 'GS012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GS012345', 'sw_version': None, 'via_device_id': None, @@ -161,8 +160,8 @@ # name: test_setup_through_bluetooth_only[Linea Micra-entities0][device_bluetooth_MR012345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -187,7 +186,6 @@ 'model_id': 'LINEAMICRA', 'name': 'MR012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'MR012345', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index bdebd35d6dda..6f9ff0084e3c 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': 'GS3AV', 'name': 'GS012345', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'GS012345', 'sw_version': 'v1.17', 'via_device_id': None, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 5106b6db6e99..5af778f31d3a 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -4,8 +4,9 @@ from collections.abc import Generator from copy import deepcopy from unittest.mock import AsyncMock, MagicMock, patch -from pylamarzocco.const import ModelName +from pylamarzocco.const import DeviceType, ModelName from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import Thing import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE @@ -35,7 +36,7 @@ from . import ( get_bluetooth_service_info, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture(autouse=True) @@ -197,6 +198,30 @@ async def test_form_no_machines( await __do_sucessful_machine_selection_step(hass, result) +async def test_grinders_not_configurable( + hass: HomeAssistant, + mock_cloud_client: MagicMock, +) -> None: + """Test that grinders are filtered out so only machines can be configured.""" + grinder = await async_load_json_object_fixture(hass, "thing.json", DOMAIN) + grinder["type"] = DeviceType.GRINDER + grinder["serialNumber"] = "GR012345" + grinder["name"] = "GR012345" + + mock_cloud_client.list_things.return_value = [ + *mock_cloud_client.list_things.return_value, + Thing.from_dict(grinder), + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await __do_successful_user_step(hass, result, mock_cloud_client) + + options = result["data_schema"].schema[CONF_MACHINE].config["options"] + assert [option["value"] for option in options] == ["GS012345"] + + async def test_reauth_flow( hass: HomeAssistant, mock_cloud_client: MagicMock, diff --git a/tests/components/lawn_mower/test_llm.py b/tests/components/lawn_mower/test_llm.py new file mode 100644 index 000000000000..204b07a4c51a --- /dev/null +++ b/tests/components/lawn_mower/test_llm.py @@ -0,0 +1,59 @@ +"""Tests for the lawn_mower LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.lawn_mower import llm as lawn_mower_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "lawn_mower.test" +INTENTS = {"HassLawnMowerDock", "HassLawnMowerStartMowing"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a lawn_mower entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "lawn_mower", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test lawn_mower"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the lawn_mower platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed lawn_mower entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no lawn_mower entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) + assert lawn_mower_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert lawn_mower_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/led_infrared/__init__.py b/tests/components/led_infrared/__init__.py new file mode 100644 index 000000000000..1d9171961d86 --- /dev/null +++ b/tests/components/led_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the LED Infrared integration.""" diff --git a/tests/components/led_infrared/conftest.py b/tests/components/led_infrared/conftest.py new file mode 100644 index 000000000000..b608b97a0007 --- /dev/null +++ b/tests/components/led_infrared/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the LED Infrared tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.led_infrared.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + + +@pytest.fixture(name="infrared_codes") +def mock_infrared_code_to_command() -> Generator[None]: + """Patch to_command to return the code directly. + + This allows tests to assert on the high-level code enum value + rather than the raw NEC timings. + """ + with ( + patch( + "infrared_protocols.codes.generic.led.Generic24KeyCode.to_command", + autospec=True, + side_effect=lambda self, **kwargs: self, + ) as mock_to_command, + patch( + "infrared_protocols.codes.generic.led.Generic13KeyCode.to_command", + new=mock_to_command, + ), + ): + yield diff --git a/tests/components/led_infrared/snapshots/test_diagnostics.ambr b/tests/components/led_infrared/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..f137fce72065 --- /dev/null +++ b/tests/components/led_infrared/snapshots/test_diagnostics.ambr @@ -0,0 +1,7 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device_type': 'generic_24_key', + 'infrared_entity_id': 'infrared.test_ir_emitter', + }) +# --- diff --git a/tests/components/led_infrared/snapshots/test_light.ambr b/tests/components/led_infrared/snapshots/test_light.ambr new file mode 100644 index 000000000000..ae797297dfa0 --- /dev/null +++ b/tests/components/led_infrared/snapshots/test_light.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_setup[light.led_infrared_via_test_ir_emitter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'tomato', + 'light_green', + 'sky_blue', + 'orange_red', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'led_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'light', + 'unique_id': '1234567890', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.led_infrared_via_test_ir_emitter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : None, + : None, + : list([ + 'flash', + 'strobe', + 'fade', + 'smooth', + 'red', + 'green', + 'blue', + 'white', + 'tomato', + 'light_green', + 'sky_blue', + 'orange_red', + 'cyan', + 'rebecca_purple', + 'orange', + 'turquoise', + 'purple', + 'yellow', + 'dark_cyan', + 'plum', + ]), + : 'LED Infrared via Test IR emitter', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.led_infrared_via_test_ir_emitter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/led_infrared/test_config_flow.py b/tests/components/led_infrared/test_config_flow.py new file mode 100644 index 000000000000..dcbdab4cd4ea --- /dev/null +++ b/tests/components/led_infrared/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the LED Infrared config flow.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry +from tests.components.infrared import EMITTER_ENTITY_ID + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> 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["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "LED light with 24-key remote via Test IR emitter" + assert result["data"] == { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry: MockConfigEntry +) -> None: + """Test we abort when already configured.""" + 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"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_user_flow_requires_emitter( + hass: HomeAssistant, +) -> None: + """Test user flow requires an infrared emitter.""" + 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_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_infrared_entity"} + + +@pytest.mark.usefixtures("init_infrared") +async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: + """Test user flow aborts when no infrared emitters exist.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_entities" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_flow_reconfigure(hass: HomeAssistant) -> None: + """Test reconfigure flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: None, + }, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_INFRARED_ENTITY_ID] == EMITTER_ENTITY_ID + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_reconfigure_flow_requires_emitter( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow requires an infrared emitter.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_infrared_entity"} + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="0987654321", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: None, + }, + ) + config_entry.add_to_hass(hass) + config_entry_2.add_to_hass(hass) + result = await config_entry_2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_reconfigure_flow_no_emitters( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow aborts when no infrared emitters exist.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_entities" diff --git a/tests/components/led_infrared/test_diagnostics.py b/tests/components/led_infrared/test_diagnostics.py new file mode 100644 index 000000000000..16f913428770 --- /dev/null +++ b/tests/components/led_infrared/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Test for diagnostics platform of the LED Infrared integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +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, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + 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 ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/led_infrared/test_init.py b/tests/components/led_infrared/test_init.py new file mode 100644 index 000000000000..4e8cf794c27a --- /dev/null +++ b/tests/components/led_infrared/test_init.py @@ -0,0 +1,22 @@ +"""Tests for the LED Infrared integration setup.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setting up and unloading a config entry.""" + 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.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/led_infrared/test_light.py b/tests/components/led_infrared/test_light.py new file mode 100644 index 000000000000..1e2083265ac5 --- /dev/null +++ b/tests/components/led_infrared/test_light.py @@ -0,0 +1,280 @@ +"""Tests for the LED Infrared light platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +from infrared_protocols.codes.generic.led import Generic13KeyCode, Generic24KeyCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.led_infrared.const import ( + CONF_DEVICE_TYPE, + CONF_INFRARED_ENTITY_ID, + DOMAIN, + LEDIrDeviceType, +) +from homeassistant.components.light import ( + ATTR_EFFECT, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + + +@pytest.fixture(autouse=True) +def light_only() -> Generator[None]: + """Enable only the light platform.""" + with patch( + "homeassistant.components.led_infrared.PLATFORMS", + [Platform.LIGHT], + ): + yield + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of light 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( + ("device_type", "service", "service_data", "expected_codes"), + [ + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {}, + [Generic24KeyCode.ON], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "flash"}, + [Generic24KeyCode.ON, Generic24KeyCode.FLASH], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "strobe"}, + [Generic24KeyCode.ON, Generic24KeyCode.STROBE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "fade"}, + [Generic24KeyCode.ON, Generic24KeyCode.FADE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "smooth"}, + [Generic24KeyCode.ON, Generic24KeyCode.SMOOTH], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "red"}, + [Generic24KeyCode.ON, Generic24KeyCode.RED], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "green"}, + [Generic24KeyCode.ON, Generic24KeyCode.GREEN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "blue"}, + [Generic24KeyCode.ON, Generic24KeyCode.BLUE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "white"}, + [Generic24KeyCode.ON, Generic24KeyCode.WHITE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange_red"}, + [Generic24KeyCode.ON, Generic24KeyCode.ORANGE_RED], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "tomato"}, + [Generic24KeyCode.ON, Generic24KeyCode.TOMATO], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "light_green"}, + [Generic24KeyCode.ON, Generic24KeyCode.LIGHT_GREEN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "sky_blue"}, + [Generic24KeyCode.ON, Generic24KeyCode.SKY_BLUE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "cyan"}, + [Generic24KeyCode.ON, Generic24KeyCode.CYAN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "rebecca_purple"}, + [Generic24KeyCode.ON, Generic24KeyCode.REBECCA_PURPLE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "orange"}, + [Generic24KeyCode.ON, Generic24KeyCode.ORANGE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "turquoise"}, + [Generic24KeyCode.ON, Generic24KeyCode.TURQUOISE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "purple"}, + [Generic24KeyCode.ON, Generic24KeyCode.PURPLE], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "yellow"}, + [Generic24KeyCode.ON, Generic24KeyCode.YELLOW], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "dark_cyan"}, + [Generic24KeyCode.ON, Generic24KeyCode.DARK_CYAN], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "plum"}, + [Generic24KeyCode.ON, Generic24KeyCode.PLUM], + ), + ( + LEDIrDeviceType.GENERIC_24_KEY, + SERVICE_TURN_OFF, + {}, + [Generic24KeyCode.OFF], + ), + (LEDIrDeviceType.GENERIC_13_KEY, SERVICE_TURN_ON, {}, [Generic13KeyCode.ON]), + (LEDIrDeviceType.GENERIC_13_KEY, SERVICE_TURN_OFF, {}, [Generic13KeyCode.OFF]), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_1"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_1], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_2"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_2], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_3"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_3], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_4"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_4], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_5"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_5], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_6"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_6], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_7"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_7], + ), + ( + LEDIrDeviceType.GENERIC_13_KEY, + SERVICE_TURN_ON, + {ATTR_EFFECT: "mode_8"}, + [Generic13KeyCode.ON, Generic13KeyCode.MODE_8], + ), + ], +) +@pytest.mark.usefixtures("infrared_codes") +async def test_light_actions( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + device_type: LEDIrDeviceType, + service: str, + service_data: dict[str, str], + expected_codes: list[Generic24KeyCode | Generic13KeyCode], +) -> None: + """Test light actions.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: device_type, + CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID, + }, + ) + 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( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.led_infrared_via_test_ir_emitter", **service_data}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == len(expected_codes) + assert mock_infrared_emitter_entity.send_command_calls == expected_codes diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr index e1b5a48fe27b..df0111f63531 100644 --- a/tests/components/lektrico/snapshots/test_init.ambr +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '1p7k_500006', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '500006', 'sw_version': '1.44', 'via_device_id': None, diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index fb8faf7d0697..c373c5dac5da 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -115,6 +115,8 @@ def mock_thinq_mqtt_client() -> Generator[None]: @pytest.fixture( params=[ "air_conditioner", + "air_conditioner1", + "air_conditioner2", "washer", "dehumidifier", "kimchi_refrigerator", diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json index 85ce95da0edd..a4c1ccac3ac3 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -18,8 +18,8 @@ "mode": ["r", "w"], "type": "enum", "value": { - "r": ["LOW", "HIGH", "MID"], - "w": ["LOW", "HIGH", "MID"] + "r": ["LOW", "MID", "HIGH", "AUTO"], + "w": ["LOW", "MID", "HIGH", "AUTO"] } } }, diff --git a/tests/components/lg_thinq/fixtures/air_conditioner1/device.json b/tests/components/lg_thinq/fixtures/air_conditioner1/device.json new file mode 100644 index 000000000000..54d8200a3bc8 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner1/device.json @@ -0,0 +1,9 @@ +{ + "deviceId": "MW2-5D62FF95-A977-46C7-B364-9A160C2A63A0", + "deviceInfo": { + "deviceType": "DEVICE_AIR_CONDITIONER", + "modelName": "RAC_056905_WW", + "alias": "Test air conditioner1", + "reportable": true + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner1/energy_profile.json b/tests/components/lg_thinq/fixtures/air_conditioner1/energy_profile.json new file mode 100644 index 000000000000..05dd9622432b --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner1/energy_profile.json @@ -0,0 +1,4 @@ +{ + "resultCode": "0000", + "result": {} +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner1/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner1/profile.json new file mode 100644 index 000000000000..f579b34c39f0 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner1/profile.json @@ -0,0 +1,128 @@ +{ + "property": { + "airConJobMode": { + "currentJobMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["FAN", "COOL", "AIR_DRY"], + "w": ["FAN", "COOL", "AIR_DRY"] + } + } + }, + "airFlow": { + "windStrength": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["SLOW", "LOW", "MID", "HIGH", "AUTO"], + "w": ["SLOW", "LOW", "MID", "HIGH", "AUTO"] + } + }, + "windStrengthDetail": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["BREEZE", "NATURE", "LOW", "MID", "HIGH"], + "w": ["BREEZE", "NATURE", "LOW", "MID", "HIGH"] + } + } + }, + "operation": { + "airConOperationMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["POWER_ON", "POWER_OFF"], + "w": ["POWER_ON", "POWER_OFF"] + } + } + }, + "temperature": { + "coolTargetTemperature": { + "mode": ["w"], + "type": "range", + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "currentTemperature": { + "mode": ["r"], + "type": "number" + }, + "targetTemperature": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 30, + "min": 18, + "step": 1 + }, + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": { + "mode": ["r"], + "type": "enum", + "value": { + "r": ["C", "F"] + } + } + }, + "temperatureInUnits": [ + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": "C" + }, + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 86, + "min": 64, + "step": 2 + } + } + }, + "unit": "F" + } + ] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner1/status.json b/tests/components/lg_thinq/fixtures/air_conditioner1/status.json new file mode 100644 index 000000000000..8fdfe8d9d3c2 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner1/status.json @@ -0,0 +1,29 @@ +{ + "airConJobMode": { + "currentJobMode": "COOL" + }, + "airFlow": { + "windStrength": "AUTO", + "windStrengthDetail": "NATURE" + }, + "operation": { + "airConOperationMode": "POWER_ON" + }, + "temperature": { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + "temperatureInUnits": [ + { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + { + "currentTemperature": 77, + "targetTemperature": 66, + "unit": "F" + } + ] +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner2/device.json b/tests/components/lg_thinq/fixtures/air_conditioner2/device.json new file mode 100644 index 000000000000..dee1a530f5b5 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner2/device.json @@ -0,0 +1,9 @@ +{ + "deviceId": "MW2-5E747ABC-6542-4DEF-AA89-4EC4353B2E89", + "deviceInfo": { + "deviceType": "DEVICE_AIR_CONDITIONER", + "modelName": "RAC_056905_WW", + "alias": "Test air conditioner2", + "reportable": true + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner2/energy_profile.json b/tests/components/lg_thinq/fixtures/air_conditioner2/energy_profile.json new file mode 100644 index 000000000000..05dd9622432b --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner2/energy_profile.json @@ -0,0 +1,4 @@ +{ + "resultCode": "0000", + "result": {} +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner2/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner2/profile.json new file mode 100644 index 000000000000..6fa876ba66a8 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner2/profile.json @@ -0,0 +1,164 @@ +{ + "property": { + "airConJobMode": { + "currentJobMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["FAN", "COOL", "AIR_DRY"], + "w": ["FAN", "COOL", "AIR_DRY"] + } + } + }, + "airFlow": { + "windStep": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 7, + "min": 1, + "step": 1 + }, + "w": { + "max": 7, + "min": 1, + "step": 1 + } + } + }, + "windStrength": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["SLOW", "LOW", "MID", "HIGH", "AUTO"], + "w": ["SLOW", "LOW", "MID", "HIGH", "AUTO"] + } + }, + "windStrengthDetail": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": [ + "BREEZE", + "NATURE", + "SLOW", + "SLOW_LOW", + "LOW", + "LOW_MID", + "MID", + "MID_HIGH", + "HIGH" + ], + "w": [ + "BREEZE", + "NATURE", + "SLOW", + "SLOW_LOW", + "LOW", + "LOW_MID", + "MID", + "MID_HIGH", + "HIGH" + ] + } + } + }, + "operation": { + "airConOperationMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["POWER_ON", "POWER_OFF"], + "w": ["POWER_ON", "POWER_OFF"] + } + } + }, + "temperature": { + "coolTargetTemperature": { + "mode": ["w"], + "type": "range", + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "currentTemperature": { + "mode": ["r"], + "type": "number" + }, + "targetTemperature": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 30, + "min": 18, + "step": 1 + }, + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": { + "mode": ["r"], + "type": "enum", + "value": { + "r": ["C", "F"] + } + } + }, + "temperatureInUnits": [ + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": "C" + }, + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 86, + "min": 64, + "step": 2 + } + } + }, + "unit": "F" + } + ] + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner2/status.json b/tests/components/lg_thinq/fixtures/air_conditioner2/status.json new file mode 100644 index 000000000000..6acf84d064ef --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner2/status.json @@ -0,0 +1,30 @@ +{ + "airConJobMode": { + "currentJobMode": "COOL" + }, + "airFlow": { + "windStep": 6, + "windStrength": "HIGH", + "windStrengthDetail": "MID_HIGH" + }, + "operation": { + "airConOperationMode": "POWER_ON" + }, + "temperature": { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + "temperatureInUnits": [ + { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + { + "currentTemperature": 77, + "targetTemperature": 66, + "unit": "F" + } + ] +} diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index 3493bf4e597b..24622fe0bdb9 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -1,4 +1,186 @@ # serializer version: 1 +# name: test_climate_entities[air_conditioner1][climate.test_air_conditioner1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'breeze', + 'auto', + 'low', + 'medium', + 'high', + ]), + : list([ + , + , + , + , + ]), + : 86, + : 64, + : 2, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_air_conditioner1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'MW2-5D62FF95-A977-46C7-B364-9A160C2A63A0_climate_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[air_conditioner1][climate.test_air_conditioner1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 77, + : 'auto', + : list([ + 'breeze', + 'auto', + 'low', + 'medium', + 'high', + ]), + : 'Test air conditioner1', + : list([ + , + , + , + , + ]), + : 86, + : 64, + : , + : 2, + : 66, + }), + 'context': , + 'entity_id': 'climate.test_air_conditioner1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_entities[air_conditioner2][climate.test_air_conditioner2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'breeze', + 'auto', + 'slow', + 'slow_low', + 'low', + 'low_mid', + 'medium', + 'mid_high', + 'high', + ]), + : list([ + , + , + , + , + ]), + : 86, + : 64, + : 2, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_air_conditioner2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'MW2-5E747ABC-6542-4DEF-AA89-4EC4353B2E89_climate_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities[air_conditioner2][climate.test_air_conditioner2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 77, + : 'mid_high', + : list([ + 'breeze', + 'auto', + 'slow', + 'slow_low', + 'low', + 'low_mid', + 'medium', + 'mid_high', + 'high', + ]), + : 'Test air conditioner2', + : list([ + , + , + , + , + ]), + : 86, + : 64, + : , + : 2, + : 66, + }), + 'context': , + 'entity_id': 'climate.test_air_conditioner2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_climate_entities[air_conditioner][climate.test_air_conditioner-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -8,8 +190,9 @@ 'capabilities': dict({ : list([ 'low', - 'high', 'medium', + 'high', + 'auto', ]), : list([ , @@ -70,8 +253,9 @@ : 'medium', : list([ 'low', - 'high', 'medium', + 'high', + 'auto', ]), : 'Test air conditioner', : list([ diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index 6b80151805c2..003a1240299c 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -5,8 +5,14 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, +) +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 homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -16,7 +22,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) +@pytest.mark.parametrize( + "device_fixture", ["air_conditioner", "air_conditioner1", "air_conditioner2"] +) async def test_climate_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -31,3 +39,73 @@ async def test_climate_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", "entity_id", "service", "service_data", "expected_value"), + [ + ( + "air_conditioner", + "climate.test_air_conditioner", + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: "auto"}, + "auto", + ), + ( + "air_conditioner1", + "climate.test_air_conditioner1", + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: "auto"}, + "nature", + ), + ], +) +async def test_fan_mode_service_calls( + hass: HomeAssistant, + devices: AsyncMock, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + entity_id: str, + service_data: dict, + expected_value: str, +) -> None: + """Test fan_mode service calls send the correct fan mode values.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + coordinator = next(iter(mock_config_entry.runtime_data.coordinators.values())) + coordinator.api.async_set_fan_mode = AsyncMock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + + coordinator.api.async_set_fan_mode.assert_awaited_once_with( + "climate_air_conditioner", expected_value + ) + + +@pytest.mark.parametrize("device_fixture", ["air_conditioner"]) +@pytest.mark.usefixtures("devices") +async def test_service_call_connection_error_raises_home_assistant_error( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a network error during a service call raises HomeAssistantError.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + mock_thinq_api.async_post_device_control.side_effect = TimeoutError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.test_air_conditioner", "fan_mode": "low"}, + blocking=True, + ) diff --git a/tests/components/lichess/snapshots/test_init.ambr b/tests/components/lichess/snapshots/test_init.ambr index 91ba6b5d91d5..9b2efcf1196a 100644 --- a/tests/components/lichess/snapshots/test_init.ambr +++ b/tests/components/lichess/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'DrNykterstein', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/liebherr/conftest.py b/tests/components/liebherr/conftest.py index f6ca8f45808d..7314f0ef7acc 100644 --- a/tests/components/liebherr/conftest.py +++ b/tests/components/liebherr/conftest.py @@ -6,11 +6,13 @@ from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch from pyliebherrhomeapi import ( + AutoDoorControl, BioFreshPlusControl, BioFreshPlusMode, Device, DeviceState, DeviceType, + DoorState, HydroBreezeControl, HydroBreezeMode, IceMakerControl, @@ -122,6 +124,13 @@ MOCK_DEVICE_STATE = DeviceState( value=3, max=5, ), + AutoDoorControl( + name="autodoor", + type="AutoDoorControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=DoorState.CLOSED, + ), ], ) @@ -183,6 +192,7 @@ def mock_liebherr_client() -> Generator[MagicMock]: client.set_hydro_breeze = AsyncMock() client.set_bio_fresh_plus = AsyncMock() client.set_presentation_light = AsyncMock() + client.trigger_auto_door = AsyncMock() yield client diff --git a/tests/components/liebherr/snapshots/test_cover.ambr b/tests/components/liebherr/snapshots/test_cover.ambr new file mode 100644 index 000000000000..05e71e9cf39c --- /dev/null +++ b/tests/components/liebherr/snapshots/test_cover.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_covers[cover.test_fridge_top_zone_autodoor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_fridge_top_zone_autodoor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Top zone AutoDoor', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Top zone AutoDoor', + 'platform': 'liebherr', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'auto_door_top_zone', + 'unique_id': 'test_device_id_auto_door_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_fridge_top_zone_autodoor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Test Fridge Top zone AutoDoor', + : True, + : , + }), + 'context': , + 'entity_id': 'cover.test_fridge_top_zone_autodoor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/liebherr/snapshots/test_diagnostics.ambr b/tests/components/liebherr/snapshots/test_diagnostics.ambr index d912f096e71b..63161baaec62 100644 --- a/tests/components/liebherr/snapshots/test_diagnostics.ambr +++ b/tests/components/liebherr/snapshots/test_diagnostics.ambr @@ -93,6 +93,13 @@ 'type': 'PresentationLightControl', 'value': 3, }), + dict({ + 'name': 'autodoor', + 'type': 'AutoDoorControl', + 'value': 'closed', + 'zone_id': 1, + 'zone_position': 'top', + }), ]), 'device': dict({ 'device_id': 'test_device_id', diff --git a/tests/components/liebherr/test_cover.py b/tests/components/liebherr/test_cover.py new file mode 100644 index 000000000000..06c2dc151c86 --- /dev/null +++ b/tests/components/liebherr/test_cover.py @@ -0,0 +1,399 @@ +"""Test the Liebherr cover platform.""" + +import copy +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyliebherrhomeapi import ( + AutoDoorControl, + Device, + DeviceState, + DeviceType, + DoorState, + TemperatureControl, + TemperatureUnit, + ZonePosition, +) +from pyliebherrhomeapi.exceptions import LiebherrConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "cover.test_fridge_top_zone_autodoor" + + +def _mock_door_state(door_state: DoorState | None) -> DeviceState: + """Create a DeviceState with a single AutoDoorControl.""" + return DeviceState( + device=MOCK_DEVICE, + controls=[ + AutoDoorControl( + name="autodoor", + type="AutoDoorControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=door_state, + ), + ], + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.COVER] + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("init_integration") +async def test_covers( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test all cover entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("door_state", "expected_state"), + [ + (DoorState.OPEN, STATE_OPEN), + (DoorState.MOVING, STATE_OPEN), + (None, "unknown"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_cover_state_after_poll( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + freezer: FrozenDateTimeFactory, + door_state: DoorState | None, + expected_state: str, +) -> None: + """Test cover state after polling different door states.""" + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: ( + _mock_door_state(door_state) + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("service", "door_state", "expected_state", "expected_value"), + [ + (SERVICE_OPEN_COVER, DoorState.OPEN, STATE_OPEN, True), + (SERVICE_CLOSE_COVER, DoorState.CLOSED, STATE_CLOSED, False), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_cover_service_calls( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + service: str, + door_state: DoorState, + expected_state: str, + expected_value: bool, +) -> None: + """Test cover open/close service calls settle to expected state.""" + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: ( + _mock_door_state(door_state) + ) + + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_liebherr_client.trigger_auto_door.assert_called_once_with( + device_id="test_device_id", + zone_id=1, + value=expected_value, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.usefixtures("init_integration") +async def test_cover_state_settles_after_poll( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test door state settles correctly across command and subsequent poll.""" + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: ( + _mock_door_state(DoorState.OPEN) + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OPEN + + # Door closes on next scheduled poll + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: ( + _mock_door_state(DoorState.CLOSED) + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_CLOSED + + +@pytest.mark.parametrize( + ("service", "message"), + [ + (SERVICE_OPEN_COVER, "An error occurred while opening the door"), + (SERVICE_CLOSE_COVER, "An error occurred while closing the door"), + ], + ids=["open", "close"], +) +@pytest.mark.usefixtures("init_integration") +async def test_cover_failure( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + service: str, + message: str, +) -> None: + """Test cover fails gracefully on connection error and resets optimistic state.""" + mock_liebherr_client.trigger_auto_door.side_effect = LiebherrConnectionError( + "Connection failed" + ) + + with pytest.raises(HomeAssistantError, match=message): + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + # Optimistic state should be cleared after failure + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_CLOSED + + +@pytest.mark.usefixtures("init_integration") +async def test_cover_when_control_missing( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test cover entity behavior when auto door control is removed.""" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_CLOSED + + # Device stops reporting auto door control + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( + device=MOCK_DEVICE, controls=[] + ) + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_no_cover_entity_without_control( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], +) -> None: + """Test no cover entity created when device has no auto door control.""" + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( + device=MOCK_DEVICE, controls=[] + ) + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.liebherr.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) is None + + +async def test_single_zone_cover( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], +) -> None: + """Test single zone device uses name without zone suffix.""" + device = Device( + device_id="single_zone_id", + nickname="Single Zone Fridge", + device_type=DeviceType.FRIDGE, + device_name="K2601", + ) + mock_liebherr_client.get_devices.return_value = [device] + mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: DeviceState( + device=device, + controls=[ + TemperatureControl( + zone_id=1, + zone_position=ZonePosition.TOP, + name="Fridge", + type="fridge", + value=5, + target=4, + min=2, + max=8, + unit=TemperatureUnit.CELSIUS, + ), + AutoDoorControl( + name="autodoor", + type="AutoDoorControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=DoorState.CLOSED, + ), + ], + ) + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.liebherr.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Single zone device should not have zone suffix + entity_id = "cover.single_zone_fridge_autodoor" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_CLOSED + + +async def test_dynamic_device_discovery( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices with auto door are automatically discovered.""" + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.liebherr.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID) is not None + assert hass.states.get("cover.new_fridge_autodoor") is None + + new_device = Device( + device_id="new_device_id", + nickname="New Fridge", + device_type=DeviceType.FRIDGE, + device_name="K2601", + ) + new_device_state = DeviceState( + device=new_device, + controls=[ + AutoDoorControl( + name="autodoor", + type="AutoDoorControl", + zone_id=1, + zone_position=ZonePosition.TOP, + value=DoorState.CLOSED, + ), + ], + ) + + mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, new_device] + mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: ( + copy.deepcopy( + new_device_state if device_id == "new_device_id" else MOCK_DEVICE_STATE + ) + ) + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("cover.new_fridge_autodoor") + assert state is not None + assert state.state == STATE_CLOSED + + +@pytest.mark.parametrize( + ("service", "expected_state"), + [ + (SERVICE_OPEN_COVER, STATE_OPENING), + (SERVICE_CLOSE_COVER, STATE_CLOSING), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_cover_optimistic_state( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + service: str, + expected_state: str, +) -> None: + """Test optimistic opening/closing state is set before command completes.""" + states: list[str] = [] + + # Capture the state while the API call is in flight to observe optimistic state + async def _observe_trigger(**kwargs: object) -> None: + state = hass.states.get(ENTITY_ID) + assert state is not None + states.append(state.state) + + mock_liebherr_client.trigger_auto_door.side_effect = _observe_trigger + + await hass.services.async_call( + COVER_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert expected_state in states diff --git a/tests/components/light/test_llm.py b/tests/components/light/test_llm.py new file mode 100644 index 000000000000..d346ce0a4ee4 --- /dev/null +++ b/tests/components/light/test_llm.py @@ -0,0 +1,58 @@ +"""Tests for the light LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.light import llm as light_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "light.test" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a light entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "light", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test light"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the light platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed light entity.""" + assert "HassLightSet" in await _tool_names(hass) + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no light entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert "HassLightSet" not in await _tool_names(hass) + assert light_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert light_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/linkplay/conftest.py b/tests/components/linkplay/conftest.py index b0b683908109..58399d853e8c 100644 --- a/tests/components/linkplay/conftest.py +++ b/tests/components/linkplay/conftest.py @@ -19,7 +19,7 @@ from tests.conftest import AiohttpClientMocker HOST = "10.0.0.150" HOST_REENTRY = "10.0.0.66" -UUID = "FF31F09E-5001-FBDE-0546-2DBFFF31F09E" +UUID = "FF31F09E5001FBDE05462DBFFF31F09E" NAME = "Smart Zone 1_54B9" diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index 17c3534f3429..3bdddff90fdf 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -8,7 +8,7 @@ from linkplay.manufacturers import MANUFACTURER_WIIM import pytest from homeassistant.components.linkplay.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -26,7 +26,7 @@ ZEROCONF_DISCOVERY = ZeroconfServiceInfo( port=59152, type="_linkplay._tcp.local.", properties={ - "uuid": f"uuid:{UUID}", + "uuid": "uuid:FF31F09E-5001-FBDE-0546-2DBFFF31F09E", "mac": "00:2F:69:01:84:3A", "security": "https 2.0", "upnp": "1.0.0", @@ -42,7 +42,7 @@ ZEROCONF_DISCOVERY_RE_ENTRY = ZeroconfServiceInfo( port=59152, type="_linkplay._tcp.local.", properties={ - "uuid": f"uuid:{UUID}", + "uuid": "uuid:FF31F09E-5001-FBDE-0546-2DBFFF31F09E", "mac": "00:2F:69:01:84:3A", "security": "https 2.0", "upnp": "1.0.0", @@ -134,6 +134,58 @@ async def test_zeroconf_flow( assert result["result"].unique_id == UUID +async def test_zeroconf_flow_ignored_entry( + hass: HomeAssistant, + mock_linkplay_factory_bridge: AsyncMock, +) -> None: + """Test Zeroconf discovery does not probe a device with an ignored entry.""" + + entry = MockConfigEntry( + data={}, + domain=DOMAIN, + title=NAME, + unique_id=UUID, + source=SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_linkplay_factory_bridge.assert_not_called() + + +async def test_zeroconf_flow_same_uuid_does_not_probe( + hass: HomeAssistant, + mock_linkplay_factory_bridge: AsyncMock, +) -> None: + """Test Zeroconf discovery aborts before probing when the UUID matches.""" + + entry = MockConfigEntry( + data={CONF_HOST: HOST}, + domain=DOMAIN, + title=NAME, + unique_id=UUID, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY_RE_ENTRY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == HOST_REENTRY + mock_linkplay_factory_bridge.assert_not_called() + + @pytest.mark.usefixtures("mock_linkplay_factory_bridge") async def test_zeroconf_flow_re_entry( hass: HomeAssistant, diff --git a/tests/components/litellm/__init__.py b/tests/components/litellm/__init__.py new file mode 100644 index 000000000000..27b3e0e89ba2 --- /dev/null +++ b/tests/components/litellm/__init__.py @@ -0,0 +1,25 @@ +"""Tests for the LiteLLM 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() + + +def get_subentry_id(mock_config_entry: MockConfigEntry, subentry_type: str) -> str: + """Get the subentry ID for a given type.""" + ids = [ + subentry_id + for subentry_id, subentry in mock_config_entry.subentries.items() + if subentry.subentry_type == subentry_type + ] + if not ids: + raise ValueError(f"No subentry found for type {subentry_type}") + return ids[0] diff --git a/tests/components/litellm/conftest.py b/tests/components/litellm/conftest.py new file mode 100644 index 000000000000..84ebbac639ed --- /dev/null +++ b/tests/components/litellm/conftest.py @@ -0,0 +1,133 @@ +"""Fixtures for LiteLLM integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from openai.types import CompletionUsage, Model +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest + +from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +TEST_URL = "http://localhost:4000/v1" + + +async def models_response(*model_ids: str) -> AsyncGenerator[Model]: + """Yield models as the OpenAI client's `models.list()` would.""" + for model_id in model_ids: + yield Model(id=model_id, created=0, object="model", owned_by="litellm") + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.litellm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def enable_assist() -> bool: + """Return whether the Assist LLM API is enabled for the conversation agent.""" + return False + + +@pytest.fixture +def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]: + """Mock conversation subentry data.""" + res: dict[str, Any] = { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, + conversation_subentry_data: dict[str, Any], +) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="localhost:4000", + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data=conversation_subentry_data, + subentry_id="ABCDEF", + subentry_type="conversation", + title="gpt-3.5-turbo", + unique_id=None, + ), + ], + ) + + +@pytest.fixture +async def mock_openai_client() -> AsyncGenerator[AsyncMock]: + """Mock the OpenAI client used for chat completions.""" + with patch( + "homeassistant.components.litellm.coordinator.AsyncOpenAI" + ) as mock_client: + client = mock_client.return_value + client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture +def mock_models() -> Generator[AsyncMock]: + """Mock the OpenAI client the config flow uses to list proxy models.""" + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + client = mock_client.return_value + client.with_options.return_value.models.list.side_effect = ( + lambda *args, **kwargs: models_response("gpt-3.5-turbo", "gpt-4") + ) + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/litellm/snapshots/test_conversation.ambr b/tests/components/litellm/snapshots/test_conversation.ambr new file mode 100644 index 000000000000..a905dbed6dce --- /dev/null +++ b/tests/components/litellm/snapshots/test_conversation.ambr @@ -0,0 +1,295 @@ +# serializer version: 1 +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'litellm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gpt-3.5-turbo', + : , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'litellm', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'gpt-3.5-turbo', + : , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'Hello, how can I help you?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'What time is it?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'mock_tool_call_id', + 'tool_args': dict({ + }), + 'tool_name': 'HassGetCurrentTime', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'mock_tool_call_id', + 'tool_name': 'HassGetCurrentTime', + 'tool_result': dict({ + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), + }), + }), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': '12:00 PM', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'I have successfully called the function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[True].1 + list([ + dict({ + 'content': ''' + You are a helpful assistant. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'role': 'system', + }), + dict({ + 'content': 'What time is it?', + 'role': 'user', + }), + dict({ + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'function': dict({ + 'arguments': '{}', + 'name': 'HassGetCurrentTime', + }), + 'id': 'mock_tool_call_id', + 'type': 'function', + }), + ]), + }), + dict({ + 'content': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"success":[],"failed":[]}}', + 'role': 'tool', + 'tool_call_id': 'mock_tool_call_id', + }), + dict({ + 'content': '12:00 PM', + 'role': 'assistant', + }), + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'function': dict({ + 'arguments': '{"param1":"call1"}', + 'name': 'test_tool', + }), + 'id': 'call_call_1', + 'type': 'function', + }), + ]), + }), + dict({ + 'content': '"value1"', + 'role': 'tool', + 'tool_call_id': 'call_call_1', + }), + dict({ + 'content': 'I have successfully called the function', + 'role': 'assistant', + }), + ]) +# --- diff --git a/tests/components/litellm/test_config_flow.py b/tests/components/litellm/test_config_flow.py new file mode 100644 index 000000000000..ac3a1675ab11 --- /dev/null +++ b/tests/components/litellm/test_config_flow.py @@ -0,0 +1,397 @@ +"""Test the LiteLLM config flow.""" + +from unittest.mock import AsyncMock, patch + +import httpx +from openai import ( + APIConnectionError, + APITimeoutError, + AuthenticationError, + PermissionDeniedError, +) +import pytest + +from homeassistant.components.litellm.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.litellm.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import get_subentry_id, setup_integration +from .conftest import TEST_URL, models_response + +from tests.common import MockConfigEntry + +CONVERSATION_MODEL_OPTIONS = [ + {"value": "gpt-3.5-turbo", "label": "gpt-3.5-turbo"}, + {"value": "gpt-4", "label": "gpt-4"}, +] + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_models") +@pytest.mark.parametrize( + "url_input", + ["http://localhost:4000", "http://localhost:4000/", TEST_URL, f"{TEST_URL}/"], +) +async def test_full_flow(hass: HomeAssistant, url_input: str) -> None: + """Test the full config flow normalizes the URL and stores the key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url_input, CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "localhost" + assert result["data"] == {CONF_URL: TEST_URL, CONF_API_KEY: "bla"} + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_models") +async def test_full_flow_without_api_key(hass: HomeAssistant) -> None: + """Test the config flow works without an API key.""" + 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_URL: "http://localhost:4000"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_URL: TEST_URL} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_errors( + hass: HomeAssistant, + exception: Exception, + error: str, +) -> None: + """Test we handle errors and can recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.litellm.config_flow._get_models", + new_callable=AsyncMock, + ) as mock_get_models: + mock_get_models.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_get_models.side_effect = None + mock_get_models.return_value = {"gpt-3.5-turbo": {}} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +def _status_error( + error: type[AuthenticationError | PermissionDeniedError], status_code: int +) -> AuthenticationError | PermissionDeniedError: + """Build an OpenAI status error backed by a real httpx response.""" + return error( + response=httpx.Response( + status_code=status_code, request=httpx.Request("GET", TEST_URL) + ), + body=None, + message="error", + ) + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (_status_error(AuthenticationError, 401), "invalid_auth"), + (_status_error(PermissionDeniedError, 403), "invalid_auth"), + (APIConnectionError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"), + (APITimeoutError(request=httpx.Request("GET", TEST_URL)), "cannot_connect"), + ], +) +async def test_user_step_proxy_errors( + hass: HomeAssistant, + side_effect: Exception, + error: str, +) -> None: + """Test the user step surfaces errors raised by the OpenAI client.""" + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + mock_client.return_value.with_options.return_value.models.list.side_effect = ( + side_effect + ) + + 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_URL: "http://localhost:4000", CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_duplicate_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry with the same URL already exists.""" + 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_URL: "http://localhost:4000", CONF_API_KEY: "other"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_models") +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert ( + result["data_schema"].schema["model"].config["options"] + == CONVERSATION_MODEL_OPTIONS + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "gpt-3.5-turbo" + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +@pytest.mark.usefixtures("mock_models") +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without control over the LLM API.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + } + + +async def test_conversation_agent_model_options( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the model dropdown is populated from the proxy's model list.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.litellm.config_flow.AsyncOpenAI" + ) as mock_client: + mock_client.return_value.with_options.return_value.models.list.side_effect = ( + lambda *args, **kwargs: models_response("gpt-4o", "gpt-5") + ) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-4o", "label": "gpt-4o"}, + {"value": "gpt-5", "label": "gpt-5"}, + ] + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_subentry_exceptions( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test subentry flow aborts on errors fetching models.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.litellm.config_flow._get_models", + new_callable=AsyncMock, + side_effect=exception, + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_agent( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring a conversation agent.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-4", + CONF_PROMPT: "updated prompt", + CONF_LLM_HASS_API: ["assist"], + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry.subentries[subentry_id] + assert subentry.title == "gpt-4" + assert subentry.data[CONF_MODEL] == "gpt-4" + assert subentry.data[CONF_PROMPT] == "updated prompt" + assert subentry.data[CONF_LLM_HASS_API] == ["assist"] + + +async def test_reconfigure_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring aborts when the main entry is not loaded.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +@pytest.mark.parametrize( + ("current_llm_apis", "suggested_llm_apis", "expected_options"), + [ + (["assist"], ["assist"], ["assist"]), + (["non-existent"], [], ["assist"]), + (["assist", "non-existent"], ["assist"], ["assist"]), + ], +) +@pytest.mark.usefixtures("mock_models") +async def test_reconfigure_conversation_subentry_llm_api_schema( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + current_llm_apis: list[str], + suggested_llm_apis: list[str], + expected_options: list[str], +) -> None: + """Test llm_hass_api field values when reconfiguring a conversation subentry.""" + await setup_integration(hass, mock_config_entry) + + subentry_id = get_subentry_id(mock_config_entry, "conversation") + subentry = mock_config_entry.subentries[subentry_id] + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={**subentry.data, CONF_LLM_HASS_API: current_llm_apis}, + ) + await hass.async_block_till_done() + + result = await mock_config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + schema = result["data_schema"].schema + key = next(k for k in schema if k == CONF_LLM_HASS_API) + assert key.default() == suggested_llm_apis + + field_schema = schema[key] + assert field_schema.config + assert [ + opt["value"] for opt in field_schema.config.get("options") + ] == expected_options diff --git a/tests/components/litellm/test_conversation.py b/tests/components/litellm/test_conversation.py new file mode 100644 index 000000000000..1f3660a3b98d --- /dev/null +++ b/tests/components/litellm/test_conversation.py @@ -0,0 +1,297 @@ +"""Tests for the LiteLLM conversation entity.""" + +import datetime +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import httpx +import openai +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageFunctionToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_function_tool_call_param import Function +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers.llm import ToolInput + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + +AGENT_ID = "conversation.gpt_3_5_turbo" + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.litellm.PLATFORMS", + [Platform.CONVERSATION], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + call = mock_openai_client.chat.completions.create.call_args_list[0][1] + assert call["model"] == "gpt-3.5-turbo" + assert "extra_headers" not in call + + +async def test_empty_api_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an empty choices response raises an error.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[], + created=1700000000, + model="gpt-3.5-turbo", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8), + ) + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + + +async def test_api_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that an error talking to the API is handled gracefully.""" + await setup_integration(hass, mock_config_entry) + + mock_openai_client.chat.completions.create = AsyncMock( + side_effect=openai.OpenAIError("boom") + ) + + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ERROR + + +async def test_connection_error_availability( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test a connection error marks the entity unavailable until it recovers.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE + + mock_openai_client.chat.completions.create = AsyncMock( + side_effect=openai.APIConnectionError( + request=httpx.Request("POST", "http://localhost") + ) + ) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + assert result.response.response_type is intent.IntentResponseType.ERROR + + await hass.async_block_till_done() + assert hass.states.get(AGENT_ID).state == STATE_UNAVAILABLE + + # A successful availability ping restores the entity. + await mock_config_entry.runtime_data.async_request_refresh() + await hass.async_block_till_done() + assert hass.states.get(AGENT_ID).state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test function call from the assistant.""" + await setup_integration(hass, mock_config_entry) + + mock_chat_log.async_add_user_content( + conversation.UserContent(content="What time is it?") + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=AGENT_ID, + tool_calls=[ + ToolInput( + tool_name="HassGetCurrentTime", + tool_args={}, + id="mock_tool_call_id", + external=True, + ) + ], + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.ToolResultContent( + agent_id=AGENT_ID, + tool_call_id="mock_tool_call_id", + tool_name="HassGetCurrentTime", + tool_result={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + }, + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=AGENT_ID, + content="12:00 PM", + ) + ) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) + + mock_openai_client.chat.completions.create.side_effect = ( + ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageFunctionToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ) + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id=AGENT_ID, + ) + + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot + assert mock_openai_client.chat.completions.create.call_count == 2 + assert ( + mock_openai_client.chat.completions.create.call_args.kwargs["messages"] + == snapshot + ) diff --git a/tests/components/litellm/test_init.py b/tests/components/litellm/test_init.py new file mode 100644 index 000000000000..151f783633dc --- /dev/null +++ b/tests/components/litellm/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the LiteLLM integration setup.""" + +from unittest.mock import AsyncMock + +import httpx +from openai import APIConnectionError, AuthenticationError +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_load_unload_entry( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + 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 + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + AuthenticationError( + response=httpx.Response( + status_code=401, request=httpx.Request("GET", "http://localhost") + ), + body=None, + message="invalid api key", + ), + ConfigEntryState.SETUP_ERROR, + ), + (APIConnectionError(request=None), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_error( + hass: HomeAssistant, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test that setup handles errors validating the connection.""" + mock_openai_client.with_options.return_value.models.list.side_effect = side_effect + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state diff --git a/tests/components/llama_cpp/__init__.py b/tests/components/llama_cpp/__init__.py new file mode 100644 index 000000000000..f201820fe927 --- /dev/null +++ b/tests/components/llama_cpp/__init__.py @@ -0,0 +1 @@ +"""Tests for the llama.cpp integration.""" diff --git a/tests/components/llama_cpp/conftest.py b/tests/components/llama_cpp/conftest.py new file mode 100644 index 000000000000..876a1e9bca9d --- /dev/null +++ b/tests/components/llama_cpp/conftest.py @@ -0,0 +1,167 @@ +"""Fixtures for llama.cpp integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass, field +import logging +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.llama_cpp.const import ( + CONF_BASE_URL, + DEFAULT_BASE_URL, + DEFAULT_CONVERSATION_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session, llm +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + +CONFIG_ENTRY_DATA = { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: DEFAULT_BASE_URL, +} +ASSIST_OPTIONS = {CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + + +@pytest.fixture(autouse=True) +async def setup_home_assistant(hass: HomeAssistant) -> None: + """Enable dependencies.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture(name="platforms") +def mock_platforms() -> list[Platform]: + """Fixture for platforms loaded by the integration.""" + return [] + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + platforms: list[Platform], +) -> AsyncGenerator[None]: + """Set up the integration.""" + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture() -> dict[str, Any]: + """Fixture to add data to the config entry.""" + return {} + + +@pytest.fixture(name="config_entry_options") +def config_entry_options_fixture() -> dict[str, Any]: + """Fixture to add options to the config entry.""" + return {} + + +@pytest.fixture(name="mock_config_entry") +def mock_config_entry_fixture( + hass: HomeAssistant, + config_entry_data: dict[str, Any], + config_entry_options: dict[str, Any], +) -> MockConfigEntry: + """Fixture to create a configuration entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="llama.cpp", + data={ + **CONFIG_ENTRY_DATA, + **config_entry_data, + }, + version=1, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data={**config_entry_options}, + subentry_type="conversation", + title=DEFAULT_CONVERSATION_NAME, + unique_id=None, + ), + ], + ) + config_entry.add_to_hass(hass) + return config_entry + + +@dataclass +class MockChatLog(conversation.ChatLog): + """Mock chat log.""" + + _mock_tool_results: dict[str, Any] = field(default_factory=dict) + + def mock_tool_results(self, results: dict[str, Any]) -> None: + """Set tool results.""" + self._mock_tool_results = results + + @property + def llm_api(self) -> llm.APIInstance | None: + """Return LLM API.""" + return self._llm_api + + @llm_api.setter + def llm_api(self, value: llm.APIInstance | None) -> None: + """Set LLM API.""" + self._llm_api = value + + if not value: + return + + async def async_call_tool(tool_input: llm.ToolInput) -> llm.ToolResult: + """Call tool.""" + if tool_input.id not in self._mock_tool_results: + raise ValueError( + f"Tool {tool_input.id} not found ({self._mock_tool_results})" + ) + return self._mock_tool_results[tool_input.id] + + self._llm_api.async_call_tool = async_call_tool + + +@pytest.fixture +def mock_chat_log(hass: HomeAssistant) -> Generator[conversation.ChatLog]: + """Return mock chat logs.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch( + "homeassistant.components.conversation.chat_log.ChatLog", + MockChatLog, + ), + chat_session.async_get_chat_session(hass, "mock-conversation-id") as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + yield chat_log + + +@pytest.fixture(autouse=True) +def mock_models_list() -> Generator[AsyncMock]: + """Initialize integration.""" + with patch( + "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ) as mock_list: + yield mock_list + + +@pytest.fixture(name="mock_completion", autouse=True) +def mock_openai_client_fixture() -> Generator[AsyncMock]: + """Fixture to mock the OpenAI client.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create: + yield mock_create diff --git a/tests/components/llama_cpp/snapshots/test_conversation.ambr b/tests/components/llama_cpp/snapshots/test_conversation.ambr new file mode 100644 index 000000000000..d5853b783cf7 --- /dev/null +++ b/tests/components/llama_cpp/snapshots/test_conversation.ambr @@ -0,0 +1,92 @@ +# serializer version: 1 +# name: test_conversation_entity + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': 'Hello, how can I help you?', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_call[config_entry_options0] + list([ + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': None, + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': False, + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.llama_cpp_conversation', + 'content': 'I have successfully called the function', + 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), + ]) +# --- +# name: test_function_exception[-config_entry_options0] + 'Unexpected tool argument response: Expecting value: line 1 column 1 (char 0)' +# --- +# name: test_function_exception[{"para-config_entry_options0] + 'Unexpected tool argument response: Unterminated string starting at: line 1 column 2 (char 1)' +# --- +# name: test_unknown_hass_api[config_entry_options0] + dict({ + 'continue_conversation': False, + 'conversation_id': , + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Error preparing LLM API', + }), + }), + }), + }) +# --- diff --git a/tests/components/llama_cpp/test_config_flow.py b/tests/components/llama_cpp/test_config_flow.py new file mode 100644 index 000000000000..dbd0a938ff20 --- /dev/null +++ b/tests/components/llama_cpp/test_config_flow.py @@ -0,0 +1,585 @@ +"""Tests for the llama.cpp config flow.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import openai +import pytest + +from homeassistant import config_entries +from homeassistant.components.llama_cpp.const import ( + CONF_BASE_URL, + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_RECOMMENDED, + CONF_STREAMING, + CONF_TEMPERATURE, + CONF_TOP_P, + DEFAULT_MODEL, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_PROMPT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import llm + +from tests.common import MockConfigEntry + +RECOMMENDED_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_CHAT_MODEL: DEFAULT_MODEL, +} + + +@pytest.fixture(name="mock_setup") +def mock_setup(hass: HomeAssistant) -> Generator[AsyncMock]: + """Mock the setup of the integration.""" + with patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_config_flow( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test selecting a model in the configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "http://localhost:8080/v1" + assert result.get("data") == { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + CONF_STREAMING: True, + } + assert result["options"] == {} + assert result["subentries"] == [ + { + "subentry_type": "conversation", + "data": { + **RECOMMENDED_OPTIONS, + CONF_CHAT_MODEL: "gpt-4", + }, + "title": "Gpt 4", + "unique_id": None, + }, + ] + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + openai.APIConnectionError(request=httpx.Request(method="POST", url="test")), + "cannot_connect", + ), + ( + openai.AuthenticationError( + message="Invalid key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="POST", url="test"), + ), + body=None, + ), + "invalid_auth", + ), + ( + openai.OpenAIError("Generic error"), + "api_error", + ), + ], +) +async def test_config_flow_fail_completion( + hass: HomeAssistant, + mock_setup: AsyncMock, + mock_completion: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test config flow where the API completion validation fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + mock_completion.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": expected_error} + + assert len(mock_setup.mock_calls) == 0 + + +async def test_config_flow_no_streaming( + hass: HomeAssistant, + mock_setup: AsyncMock, + mock_completion: AsyncMock, +) -> None: + """Test config flow where the API does not support streaming.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result.get("type") is FlowResultType.FORM + assert not result.get("errors") + + def fail_streaming(stream: bool | None = None, **kwargs: Any) -> None: + """Allow first check to succeed by fail streaming.""" + if stream: + raise openai.OpenAIError("Invalid request") + + mock_completion.side_effect = fail_streaming + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "http://localhost:8080/v1" + assert result.get("data") == { + CONF_API_KEY: "sk-0000000000000000000", + CONF_BASE_URL: "http://localhost:8080/v1", + CONF_STREAMING: False, + } + assert result["subentries"] == [ + { + "subentry_type": "conversation", + "data": { + **RECOMMENDED_OPTIONS, + CONF_CHAT_MODEL: "gpt-4", + }, + "title": "Gpt 4", + "unique_id": None, + }, + ] + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + RECOMMENDED_OPTIONS, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Gpt 3.5 Turbo" + + assert result2["data"] == RECOMMENDED_OPTIONS + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + return_value=[], + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry handles connection errors.""" + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError(request=None), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("setup_integration") +async def test_creating_conversation_subentry_advanced( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry with custom/advanced settings.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Toggle recommended to False to show advanced options + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "init" + + # Now configure the advanced options + result3 = await hass.config_entries.subentries.async_configure( + result2["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + CONF_MAX_TOKENS: 500, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Gpt 4" + assert result3["data"] == { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "Custom instructions", + CONF_MAX_TOKENS: 500, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + } + + +async def test_config_flow_model_selection_fallbacks( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test model selection fallback options through the config flow.""" + # 1. Test empty list fallback (should fallback to DEFAULT_MODEL) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + async def mock_empty_list(*args, **kwargs): + return + yield + + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=mock_empty_list, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "model" + schema = result2["data_schema"].schema + chat_model_key = next(k for k in schema if k == CONF_CHAT_MODEL) + assert chat_model_key.description["suggested_value"] == DEFAULT_MODEL + + # 2. Test no recommended models match fallback (should select first model in the list) + result_custom = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + model1 = MagicMock() + model1.id = "my-custom-model-1" + model2 = MagicMock() + model2.id = "my-custom-model-2" + + async def mock_custom_list(*args, **kwargs): + yield model1 + yield model2 + + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=mock_custom_list, + ): + result3 = await hass.config_entries.flow.async_configure( + result_custom["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "model" + schema = result3["data_schema"].schema + chat_model_key = next(k for k in schema if k == CONF_CHAT_MODEL) + assert chat_model_key.description["suggested_value"] == "my-custom-model-1" + + +async def test_config_flow_connection_errors( + hass: HomeAssistant, + mock_setup: AsyncMock, +) -> None: + """Test config flow handles connection validation errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # 1. Test AuthenticationError + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.AuthenticationError( + message="Invalid Key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="GET", url="test"), + ), + body=None, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # 2. Test APIConnectionError + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError( + request=httpx.Request(method="GET", url="test") + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result3["type"] is FlowResultType.FORM + assert result3["errors"] == {"base": "cannot_connect"} + + # 3. Test OpenAIError (Generic API errors) + with patch( + "homeassistant.components.llama_cpp.config_flow.openai.resources.models.AsyncModels.list", + side_effect=openai.OpenAIError("generic error"), + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result4["type"] is FlowResultType.FORM + assert result4["errors"] == {"base": "api_error"} + + +@pytest.mark.usefixtures("setup_integration") +async def test_reconfiguring_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring an existing conversation subentry.""" + subentry = list(mock_config_entry.subentries.values())[0] + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "New prompt", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + updated_subentry = list(mock_config_entry.subentries.values())[0] + assert updated_subentry.title == "Gpt 4" + assert updated_subentry.data[CONF_CHAT_MODEL] == "gpt-4" + assert updated_subentry.data[CONF_PROMPT] == "New prompt" + assert CONF_STREAMING not in updated_subentry.data + + +async def test_subentry_options_entry_not_loaded( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow aborts if config entry is not loaded.""" + subentry = list(mock_config_entry.subentries.values())[0] + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_reconfiguring_conversation_subentry_connection_error( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry aborts if model listing fails.""" + subentry = list(mock_config_entry.subentries.values())[0] + + with patch( + "openai.resources.models.AsyncModels.list", + side_effect=openai.APIConnectionError(request=None), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_reconfiguring_conversation_subentry_validation_error( + hass: HomeAssistant, + setup_integration: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry shows form with error if model validation fails.""" + subentry = list(mock_config_entry.subentries.values())[0] + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": "reconfigure", "subentry_id": subentry.subentry_id}, + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=openai.OpenAIError("generic error"), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4", + CONF_PROMPT: "New prompt", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "api_error"} + + +async def test_config_flow_unexpected_exception( + hass: HomeAssistant, +) -> None: + """Test user step handles unexpected exception by showing unknown error.""" + 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" + + with patch( + "homeassistant.components.llama_cpp.config_flow.async_create_client", + side_effect=RuntimeError("Unexpected error"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_BASE_URL: "http://localhost:8080/v1", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/llama_cpp/test_conversation.py b/tests/components/llama_cpp/test_conversation.py new file mode 100644 index 000000000000..e35445e50616 --- /dev/null +++ b/tests/components/llama_cpp/test_conversation.py @@ -0,0 +1,572 @@ +"""Tests for the llama.cpp conversation platform.""" + +from collections.abc import AsyncGenerator, Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import httpx +import openai +from openai.types.chat import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + ChatCompletionMessageToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice, ChoiceDelta +from openai.types.chat.chat_completion_message_tool_call import Function +from openai.types.completion_usage import CompletionUsage +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.components.llama_cpp.const import CONF_STREAMING +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + +from .conftest import ASSIST_OPTIONS, MockChatLog + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def freeze_the_time() -> Generator[None]: + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.fixture(autouse=True) +def mock_ulid() -> Generator[AsyncMock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.llm.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + +@pytest.fixture(autouse=True) +async def mock_setup_integration_fixture( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Setup the integration.""" + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_conversation_entity( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Verify the conversation entity is loaded.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test function call from the assistant.""" + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + } + ) + + def completion_result( + *args: Any, messages: list[dict[str, Any]] | list[Any], **kwargs: Any + ) -> ChatCompletion: + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.content[1:] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +@pytest.mark.parametrize( + ("tool_arguments"), + [ + (""), + ('{"para'), + ], +) +async def test_function_exception( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, + tool_arguments: str, + snapshot: SnapshotAssertion, +) -> None: + """Test function call with exception.""" + + def completion_result( + *args: Any, messages: list[dict[str, Any]] | list[Any], **kwargs: Any + ) -> ChatCompletion: + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="There was an error calling the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", + function=Function( + arguments=tool_arguments, + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.speech["plain"]["speech"] == snapshot + + +@pytest.mark.parametrize(("config_entry_options"), [ASSIST_OPTIONS]) +async def test_assist_api_tools_conversion( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that we are able to convert actual tools from Assist API.""" + for component in ( + "intent", + "todo", + "light", + "shopping_list", + "humidifier", + "climate", + "media_player", + "vacuum", + "cover", + "weather", + ): + assert await async_setup_component(hass, component, {}) + + agent_id = mock_config_entry.entry_id + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), + ) as mock_create: + await conversation.async_converse(hass, "hello", None, None, agent_id=agent_id) + + tools = mock_create.mock_calls[0][2]["tools"] + assert tools + + +@pytest.mark.parametrize(("config_entry_options"), [{CONF_STREAMING: True}]) +async def test_streaming_response( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, +) -> None: + """Test streaming response from the assistant.""" + + async def mock_stream() -> AsyncGenerator[ChatCompletionChunk]: + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content="Hello"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(content=" world"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(), + finish_reason="stop", + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=mock_stream(), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello world" + + content = mock_chat_log.content[1:] + assert len(content) == 2 + assert content[0].role == "user" + assert content[0].content == "hello" + assert content[1].role == "assistant" + assert content[1].content == "Hello world" + + +@pytest.mark.parametrize(("config_entry_options"), [{CONF_STREAMING: True}]) +async def test_streaming_response_redundant_role( + hass: HomeAssistant, + mock_chat_log: MockChatLog, + mock_config_entry: MockConfigEntry, +) -> None: + """Test streaming response where every chunk redundantly includes the role.""" + + async def mock_stream() -> AsyncGenerator[ChatCompletionChunk]: + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content="Hello"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk.model_construct( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice.model_construct( + index=0, + delta=ChoiceDelta(role="assistant", content=" world"), + finish_reason=None, + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + yield ChatCompletionChunk( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(role="assistant"), + finish_reason="stop", + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion.chunk", + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=mock_stream(), + ): + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello world" + + content = mock_chat_log.content[1:] + assert len(content) == 2 + assert content[0].role == "user" + assert content[0].content == "hello" + assert content[1].role == "assistant" + assert content[1].content == "Hello world" + + +@pytest.mark.parametrize( + ("config_entry_options"), [{CONF_LLM_HASS_API: ["non-existing"]}] +) +async def test_unknown_hass_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test when we reference an API that no longer exists.""" + result = await conversation.async_converse( + hass, "hello", "conversation-id", Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.as_dict() == snapshot + + +async def test_conversation_agent_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of OpenAI API connection errors in conversation entity.""" + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=openai.APIConnectionError( + request=httpx.Request(method="POST", url="test") + ), + ): + result = await conversation.async_converse( + hass, + "hello", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.speech["plain"]["speech"] + == "Cannot connect to the server: Connection error." + ) + + +async def test_conversation_agent_structured_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of OpenAI API structured errors in conversation entity.""" + response = httpx.Response( + status_code=402, + request=httpx.Request( + method="POST", url="https://api.openai.com/v1/chat/completions" + ), + json={ + "error": { + "message": "Insufficient Balance", + "type": "unknown_error", + "param": None, + "code": "invalid_request_error", + } + }, + ) + err = openai.APIStatusError( + message="Error code: 402 - {'error': {'message': 'Insufficient Balance'}}", + response=response, + body=response.json(), + ) + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=err, + ): + result = await conversation.async_converse( + hass, + "hello", + "conversation-id", + Context(), + agent_id="conversation.llama_cpp_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.speech["plain"]["speech"] + == "Your account or API key has insufficient credits: Insufficient Balance" + ) diff --git a/tests/components/llama_cpp/test_init.py b/tests/components/llama_cpp/test_init.py new file mode 100644 index 000000000000..91136cfa170b --- /dev/null +++ b/tests/components/llama_cpp/test_init.py @@ -0,0 +1,68 @@ +"""Tests for llama.cpp integration setup.""" + +from unittest.mock import AsyncMock, patch + +import httpx +import openai +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting up and unloading llama.cpp entry.""" + with patch( + "openai.resources.models.AsyncModels.list", + new_callable=AsyncMock, + ): + assert 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 + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + ( + openai.AuthenticationError( + message="Invalid API key", + response=httpx.Response( + status_code=401, + request=httpx.Request(method="GET", url="test"), + ), + body=None, + ), + ConfigEntryState.SETUP_ERROR, + ), + ( + openai.APIConnectionError(request=None), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry failure handling.""" + with patch( + "openai.resources.models.AsyncModels.list", + 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 diff --git a/tests/components/llm/test_init.py b/tests/components/llm/test_init.py index 88169df80d31..561c2cc04e13 100644 --- a/tests/components/llm/test_init.py +++ b/tests/components/llm/test_init.py @@ -44,8 +44,8 @@ def llm_context() -> llm.LLMContext: def _mock_tools_platform( - hass: HomeAssistant, domain: str, tools: LLMTools | Exception -) -> None: + hass: HomeAssistant, domain: str, tools: LLMTools | Exception | None +) -> Mock: """Register a mock /llm.py platform returning the given tools.""" if isinstance(tools, Exception): async_get_tools = Mock(side_effect=tools) @@ -53,6 +53,7 @@ def _mock_tools_platform( async_get_tools = Mock(return_value=tools) hass.config.components.add(domain) mock_platform(hass, f"{domain}.llm", Mock(async_get_tools=async_get_tools)) + return async_get_tools async def test_setup(hass: HomeAssistant) -> None: @@ -64,25 +65,27 @@ async def test_setup(hass: HomeAssistant) -> None: async def test_get_tools(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: """Test that tools from an integration platform are returned.""" tool = _StubTool("my_tool") - _mock_tools_platform( + platform_get_tools = _mock_tools_platform( hass, "test", LLMTools(tools=[tool], prompt="use my_tool wisely") ) assert await async_setup_component(hass, "llm", {}) - result = await async_get_tools(hass, llm_context) - assert result.tools == [tool] + result = await async_get_tools(hass, llm_context, "assist") + # The llm integration also exposes its own GetDateTime tool (domain "llm"). + assert [tool.name for tool in result.tools] == ["GetDateTime", "my_tool"] assert result.prompt == "use my_tool wisely" + platform_get_tools.assert_called_once_with(hass, llm_context, "assist") async def test_get_tools_empty( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: - """Test that no platforms yields no tools.""" + """Test that only the llm integration's own tools are returned by default.""" assert await async_setup_component(hass, "llm", {}) - result = await async_get_tools(hass, llm_context) - assert result.tools == [] + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime"] assert result.prompt is None @@ -98,11 +101,26 @@ async def test_get_tools_merges_sorted( assert await async_setup_component(hass, "llm", {}) - result = await async_get_tools(hass, llm_context) - assert result.tools == [tool_a, tool_b] + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime", "tool_a", "tool_b"] assert result.prompt == "prompt a\nprompt b" +async def test_get_tools_skips_none_platform( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test that a platform returning None for the API is skipped.""" + tool = _StubTool("good_tool") + _mock_tools_platform(hass, "test_none", None) + _mock_tools_platform(hass, "test_good", LLMTools(tools=[tool])) + + assert await async_setup_component(hass, "llm", {}) + + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] + assert result.prompt is None + + async def test_get_tools_isolates_failing_platform( hass: HomeAssistant, llm_context: llm.LLMContext, @@ -115,7 +133,7 @@ async def test_get_tools_isolates_failing_platform( assert await async_setup_component(hass, "llm", {}) - result = await async_get_tools(hass, llm_context) - assert result.tools == [tool] + result = await async_get_tools(hass, llm_context, "assist") + assert [tool.name for tool in result.tools] == ["GetDateTime", "good_tool"] assert result.prompt == "prompt" assert "Error getting tools from LLM platform test_bad" in caplog.text diff --git a/tests/components/llm/test_tools.py b/tests/components/llm/test_tools.py new file mode 100644 index 000000000000..cb0d31e31104 --- /dev/null +++ b/tests/components/llm/test_tools.py @@ -0,0 +1,52 @@ +"""Tests for the LLM integration's own tools platform.""" + +from freezegun import freeze_time +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations for the llm tools platform.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "llm", {}) + await hass.config.async_set_time_zone("UTC") + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_datetime_tool(hass: HomeAssistant) -> None: + """Test the GetDateTime tool is always offered and returns the current time.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next((tool for tool in result.tools if tool.name == "GetDateTime"), None) + assert tool is not None + + with freeze_time("2025-09-17 13:00:00"): + response = await tool.async_call( + hass, llm.ToolInput("GetDateTime", {}), llm_context + ) + + assert response == { + "success": True, + "result": { + "date": "2025-09-17", + "time": "13:00:00", + "timezone": "UTC", + "weekday": "Wednesday", + }, + } diff --git a/tests/components/lojack/snapshots/test_device_tracker.ambr b/tests/components/lojack/snapshots/test_device_tracker.ambr index 00885d4ddafd..91fd506cbdfb 100644 --- a/tests/components/lojack/snapshots/test_device_tracker.ambr +++ b/tests/components/lojack/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 10, : list([ ]), - : 37.7749, - : -122.4194, + : 37.7749, + : -122.4194, : , : , }), diff --git a/tests/components/lojack/snapshots/test_init.ambr b/tests/components/lojack/snapshots/test_init.ambr index b23664dd0329..8be5adc20beb 100644 --- a/tests/components/lojack/snapshots/test_init.ambr +++ b/tests/components/lojack/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '2021 Honda Accord', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1HGBH41JXMN109186', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/luci/__init__.py b/tests/components/luci/__init__.py new file mode 100644 index 000000000000..c1710d62777f --- /dev/null +++ b/tests/components/luci/__init__.py @@ -0,0 +1 @@ +"""Tests for the luci integration.""" diff --git a/tests/components/luci/conftest.py b/tests/components/luci/conftest.py new file mode 100644 index 000000000000..0e86c0070345 --- /dev/null +++ b/tests/components/luci/conftest.py @@ -0,0 +1,93 @@ +"""Fixtures for the luci integration tests.""" + +from collections.abc import Generator +from typing import NamedTuple +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.luci.const import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +from tests.common import MockConfigEntry + + +class MockDevice(NamedTuple): + """Mock device from OpenWrt.""" + + mac: str + hostname: str + ip: str + reachable: bool + host: str + + +MOCK_DEVICE_1 = MockDevice( + mac="AA:BB:CC:DD:EE:FF", + hostname="device1", + ip="192.168.1.100", + reachable=True, + host="192.168.1.1", +) +MOCK_DEVICE_2 = MockDevice( + mac="11:22:33:44:55:66", + hostname="device2", + ip="192.168.1.101", + reachable=True, + host="192.168.1.1", +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.luci.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id="01JBVVVJ87F6G5V0QJX6HBC94T", + data={ + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, + }, + ) + + +@pytest.fixture +def mock_luci_client() -> Generator[MagicMock]: + """Return a mock OpenWrtRpc client.""" + with ( + patch( + "homeassistant.components.luci.coordinator.OpenWrtRpc", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.luci.config_flow.OpenWrtRpc", + new=mock_client_class, + ), + patch( + "homeassistant.components.luci.OpenWrtRpc", + new=mock_client_class, + ), + ): + client = mock_client_class.return_value + client.is_logged_in.return_value = True + client.get_all_connected_devices.return_value = [MOCK_DEVICE_1, MOCK_DEVICE_2] + yield client diff --git a/tests/components/luci/snapshots/test_device_tracker.ambr b/tests/components/luci/snapshots/test_device_tracker.ambr new file mode 100644 index 000000000000..6730cdea7290 --- /dev/null +++ b/tests/components/luci/snapshots/test_device_tracker.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: test_device_tracker_setup[device_tracker.device1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.device1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'device1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'device1', + 'platform': 'luci', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_AA:BB:CC:DD:EE:FF', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_setup[device_tracker.device1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'device1', + : 'device1', + : list([ + 'zone.home', + ]), + : '192.168.1.100', + : 'AA:BB:CC:DD:EE:FF', + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.device1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- +# name: test_device_tracker_setup[device_tracker.device2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.device2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'device2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'device2', + 'platform': 'luci', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01JBVVVJ87F6G5V0QJX6HBC94T_11:22:33:44:55:66', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_setup[device_tracker.device2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'device2', + : 'device2', + : list([ + 'zone.home', + ]), + : '192.168.1.101', + : '11:22:33:44:55:66', + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.device2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- diff --git a/tests/components/luci/test_config_flow.py b/tests/components/luci/test_config_flow.py new file mode 100644 index 000000000000..d21be84c49ff --- /dev/null +++ b/tests/components/luci/test_config_flow.py @@ -0,0 +1,203 @@ +"""Tests for the luci config flow.""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.components.luci.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +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 + +REAUTH_INPUT = { + CONF_USERNAME: "root", + CONF_PASSWORD: "new-password", +} + +USER_INPUT = { + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_SSL: False, + CONF_VERIFY_SSL: True, +} + +# Client configurations that make ``_try_connect`` fail, mapped to the error +# the flow should report. ``cannot_connect`` is a connection error raised by +# the client, ``invalid_auth`` is the client reporting it is not logged in. +CONNECT_ERRORS = [ + ({"side_effect": RequestsConnectionError}, "cannot_connect"), + ({"return_value": False}, "invalid_auth"), +] + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow(hass: HomeAssistant, mock_luci_client: MagicMock) -> None: + """Test the happy path of the 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"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.1" + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_luci_client") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the user flow aborts when the host 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["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("client_config", "base_error"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_errors( + hass: HomeAssistant, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + base_error: str, +) -> None: + """Test the user flow shows errors and recovers on retry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_luci_client.is_logged_in.configure_mock(**client_config) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + mock_luci_client.is_logged_in.configure_mock(side_effect=None, return_value=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow(hass: HomeAssistant, mock_luci_client: MagicMock) -> None: + """Test the happy path of the import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.1" + assert result["data"] == USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_luci_client") +async def test_import_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the import flow aborts when the host is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("client_config", "reason"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_import_flow_errors( + hass: HomeAssistant, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + reason: str, +) -> None: + """Test the import flow aborts when connecting fails.""" + mock_luci_client.is_logged_in.configure_mock(**client_config) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test the happy path of the reauth flow.""" + 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"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize(("client_config", "base_error"), CONNECT_ERRORS) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, + client_config: dict[str, Any], + base_error: str, +) -> None: + """Test the reauth flow shows errors and recovers on retry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + mock_luci_client.is_logged_in.configure_mock(**client_config) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": base_error} + + mock_luci_client.is_logged_in.configure_mock(side_effect=None, return_value=True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=REAUTH_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/luci/test_device_tracker.py b/tests/components/luci/test_device_tracker.py new file mode 100644 index 000000000000..7340f8acefc1 --- /dev/null +++ b/tests/components/luci/test_device_tracker.py @@ -0,0 +1,60 @@ +"""Tests for the luci device tracker.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_DEVICE_2 + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_luci_client") +async def test_device_tracker_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device tracker entities are created.""" + 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.usefixtures("entity_registry_enabled_by_default") +async def test_device_tracker_disconnect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device goes not_home when disconnected.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.device1") + assert state is not None + assert state.state == STATE_HOME + + # Simulate device disconnecting + mock_luci_client.get_all_connected_devices.return_value = [MOCK_DEVICE_2] + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.device1") + assert state is not None + assert state.state == STATE_NOT_HOME diff --git a/tests/components/luci/test_init.py b/tests/components/luci/test_init.py new file mode 100644 index 000000000000..240126c9827b --- /dev/null +++ b/tests/components/luci/test_init.py @@ -0,0 +1,110 @@ +"""Tests for the luci integration.""" + +from unittest.mock import MagicMock + +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.luci.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +YAML_CONFIG = { + DEVICE_TRACKER_DOMAIN: { + CONF_PLATFORM: DOMAIN, + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + } +} + + +@pytest.mark.usefixtures("mock_luci_client") +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading a 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 await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_setup_entry_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test setup fails with ConfigEntryNotReady on connection error.""" + mock_luci_client.is_logged_in.side_effect = RequestsConnectionError + + 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 + + +async def test_setup_entry_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luci_client: MagicMock, +) -> None: + """Test setup fails with ConfigEntryAuthFailed and starts a reauth flow.""" + mock_luci_client.is_logged_in.return_value = 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_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + +@pytest.mark.usefixtures("mock_device_tracker_conf") +async def test_yaml_import_invalid_auth( + hass: HomeAssistant, + mock_luci_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test importing YAML config creates an issue on invalid auth.""" + mock_luci_client.is_logged_in.return_value = False + + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, YAML_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_import_invalid_auth") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"host": "192.168.1.1"} + + +@pytest.mark.usefixtures("mock_device_tracker_conf") +async def test_yaml_import_cannot_connect( + hass: HomeAssistant, + mock_luci_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test importing YAML config creates an issue on connection failure.""" + mock_luci_client.is_logged_in.side_effect = RequestsConnectionError + + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, YAML_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "yaml_import_cannot_connect") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"host": "192.168.1.1"} diff --git a/tests/components/lyngdorf/__init__.py b/tests/components/lyngdorf/__init__.py new file mode 100644 index 000000000000..d9323f618132 --- /dev/null +++ b/tests/components/lyngdorf/__init__.py @@ -0,0 +1 @@ +"""Tests for the Lyngdorf integration.""" diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py new file mode 100644 index 000000000000..1cb548a3d220 --- /dev/null +++ b/tests/components/lyngdorf/conftest.py @@ -0,0 +1,114 @@ +"""Fixtures for the Lyngdorf integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from lyngdorf.const import LyngdorfModel +from lyngdorf.device import Receiver +import pytest + +from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def ssdp_scanner_mock() -> Generator[Mock]: + """Mock the SSDP Scanner.""" + with patch("homeassistant.components.ssdp.Scanner", autospec=True) as mock_scanner: + reg_callback = mock_scanner.return_value.async_register_callback + reg_callback.return_value = Mock(return_value=None) + yield mock_scanner.return_value + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Mock Lyngdorf", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_MODEL: "MP-60", + CONF_SERIAL_NUMBER: "0050c27c76b2", + }, + unique_id="0050c27c76b2", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.lyngdorf.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_receiver() -> Generator[MagicMock]: + """Return a mocked Lyngdorf receiver.""" + with patch( + "homeassistant.components.lyngdorf.async_create_receiver" + ) as create_mock: + receiver = MagicMock(spec=Receiver) + receiver.name = "Mock Lyngdorf" + receiver.connected = True + + receiver.power_on = False + receiver.volume = -40.0 + receiver.mute_enabled = False + receiver.source = None + receiver.available_sources = [] + receiver.sound_mode = None + receiver.available_sound_modes = [] + + receiver.zone_b_power_on = False + receiver.zone_b_volume = -40.0 + receiver.zone_b_mute_enabled = False + receiver.zone_b_source = None + receiver.zone_b_available_sources = [] + + create_mock.return_value = receiver + yield receiver + + +@pytest.fixture +def mock_get_device_serial() -> Generator[AsyncMock]: + """Return a mocked async_get_device_serial function.""" + with patch( + "homeassistant.components.lyngdorf.config_flow.async_get_device_serial", + new=AsyncMock(return_value="0050c27c76b2"), + ) as serial_mock: + yield serial_mock + + +@pytest.fixture +def mock_find_receiver_model() -> Generator[AsyncMock]: + """Return a mocked async_find_receiver_model function.""" + with patch( + "homeassistant.components.lyngdorf.config_flow.async_find_receiver_model", + new=AsyncMock(return_value=LyngdorfModel.MP_60), + ) as find_mock: + yield find_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, +) -> MockConfigEntry: + """Set up the Lyngdorf integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.lyngdorf.lookup_receiver_model") as lookup: + lookup.return_value = LyngdorfModel.MP_60 + 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/lyngdorf/snapshots/test_media_player.ambr b/tests/components/lyngdorf/snapshots/test_media_player.ambr new file mode 100644 index 000000000000..fd9a7f661b94 --- /dev/null +++ b/tests/components/lyngdorf/snapshots/test_media_player.ambr @@ -0,0 +1,107 @@ +# serializer version: 1 +# name: test_entities[media_player.mock_lyngdorf_main_zone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.mock_lyngdorf_main_zone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Main zone', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main zone', + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'main_zone', + 'unique_id': '0050c27c76b2_main_zone', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.mock_lyngdorf_main_zone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'receiver', + : 'Mock Lyngdorf Main zone', + : , + }), + 'context': , + 'entity_id': 'media_player.mock_lyngdorf_main_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[media_player.mock_lyngdorf_zone_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.mock_lyngdorf_zone_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0050c27c76b2_zone_b', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.mock_lyngdorf_zone_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'receiver', + : 'Mock Lyngdorf Zone B', + : , + }), + 'context': , + 'entity_id': 'media_player.mock_lyngdorf_zone_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lyngdorf/test_config_flow.py b/tests/components/lyngdorf/test_config_flow.py new file mode 100644 index 000000000000..e60a79970344 --- /dev/null +++ b/tests/components/lyngdorf/test_config_flow.py @@ -0,0 +1,367 @@ +"""Configuration flow tests for the Lyngdorf integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from lyngdorf.const import LyngdorfModel +import pytest + +from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_SERIAL, + SsdpServiceInfo, +) + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_SERIAL = "0050c27c76b2" + + +@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial") +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user configuration flow with serial lookup.""" + 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_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.unique_id == MOCK_SERIAL + assert config_entry.data[CONF_HOST] == "192.168.1.100" + assert config_entry.data[CONF_SERIAL_NUMBER] == MOCK_SERIAL + assert config_entry.title == "mp-60" + + +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_user_flow_cannot_determine_id_recovers( + hass: HomeAssistant, + mock_get_device_serial: AsyncMock, +) -> None: + """Test user flow shows error when serial cannot be determined, then recovers.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_get_device_serial.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_determine_id"} + + mock_get_device_serial.return_value = MOCK_SERIAL + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_SERIAL + + +@pytest.mark.usefixtures("mock_find_receiver_model", "mock_get_device_serial") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user flow when 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"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +@pytest.mark.usefixtures("mock_get_device_serial") +async def test_user_flow_unsupported_model_recovers( + hass: HomeAssistant, + mock_find_receiver_model: AsyncMock, +) -> None: + """Test user flow shows unsupported_model error, then recovers.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_find_receiver_model.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_model"} + + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exc", "expected_error"), + [ + (ConnectionError("Unable to connect"), "cannot_connect"), + (TimeoutError("Connection timeout"), "timeout_connect"), + (Exception("Unexpected error"), "unknown"), + ], + ids=["cannot_connect", "timeout", "unknown"], +) +@pytest.mark.usefixtures("mock_get_device_serial") +async def test_user_flow_connection_errors_recover( + hass: HomeAssistant, + mock_find_receiver_model: AsyncMock, + exc: Exception, + expected_error: str, +) -> None: + """Test user flow surfaces connection errors and then recovers.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_find_receiver_model.side_effect = exc + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_find_receiver_model.side_effect = None + mock_find_receiver_model.return_value = LyngdorfModel.MP_60 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("friendly_name", "expected_title"), + [ + pytest.param("Living Room", "Living Room", id="custom_name"), + pytest.param("mp-60", "mp-60", id="name_matches_model"), + ], +) +@pytest.mark.usefixtures("mock_find_receiver_model") +async def test_ssdp_discovery( + hass: HomeAssistant, friendly_name: str, expected_title: str +) -> None: + """Test successful SSDP discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: friendly_name, + ATTR_UPNP_MODEL_NAME: "MP-60", + ATTR_UPNP_SERIAL: MOCK_SERIAL, + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.unique_id == MOCK_SERIAL + assert config_entry.title == expected_title + assert config_entry.data[CONF_HOST] == "192.168.1.100" + assert config_entry.data[CONF_SERIAL_NUMBER] == MOCK_SERIAL + + +async def test_ssdp_discovery_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SSDP discovery aborts when device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "MP-60", + ATTR_UPNP_SERIAL: MOCK_SERIAL, + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "192.168.1.100" + + +async def test_ssdp_discovery_no_serial(hass: HomeAssistant) -> None: + """Test SSDP discovery aborts when no serial number is available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "MP-60", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_determine_id" + + +async def test_ssdp_discovery_unsupported_model(hass: HomeAssistant) -> None: + """Test SSDP discovery aborts when model is not supported.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "UNKNOWN-MODEL", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_model" + + +async def test_ssdp_discovery_missing_model(hass: HomeAssistant) -> None: + """Test SSDP discovery aborts when model name is missing.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_model" + + +@pytest.mark.parametrize( + "ssdp_location", + [ + pytest.param(None, id="no_location"), + pytest.param("http:///desc.xml", id="no_hostname"), + ], +) +async def test_ssdp_discovery_no_host( + hass: HomeAssistant, ssdp_location: str | None +) -> None: + """Test SSDP discovery aborts when no hostname can be determined.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=ssdp_location, + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "MP-60", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("side_effect", "expected_reason"), + [ + pytest.param(OSError("Connection refused"), "cannot_connect", id="os_error"), + pytest.param( + TimeoutError("Connection timeout"), "cannot_connect", id="timeout" + ), + pytest.param(None, "unsupported_model", id="model_not_found"), + ], +) +async def test_ssdp_discovery_connectivity_check_aborts( + hass: HomeAssistant, + mock_find_receiver_model: AsyncMock, + side_effect: Exception | None, + expected_reason: str, +) -> None: + """Test SSDP discovery aborts when the connectivity check fails.""" + mock_find_receiver_model.side_effect = side_effect + mock_find_receiver_model.return_value = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://192.168.1.100/desc.xml", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "Living Room", + ATTR_UPNP_MODEL_NAME: "MP-60", + ATTR_UPNP_SERIAL: MOCK_SERIAL, + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason diff --git a/tests/components/lyngdorf/test_init.py b/tests/components/lyngdorf/test_init.py new file mode 100644 index 000000000000..c1fe914b75f2 --- /dev/null +++ b/tests/components/lyngdorf/test_init.py @@ -0,0 +1,112 @@ +"""Tests for the Lyngdorf integration.""" + +from unittest.mock import MagicMock, patch + +from lyngdorf.const import LyngdorfModel +import pytest + +from homeassistant.components.lyngdorf.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_MODEL, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "exc", + [ + ConnectionError("Connection failed"), + OSError("Network unreachable"), + TimeoutError("Connection timeout"), + ], + ids=["connection_error", "os_error", "timeout"], +) +async def test_setup_entry_connection_failures( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + exc: Exception, +) -> None: + """Test setup retries when connecting to the receiver fails.""" + mock_config_entry.add_to_hass(hass) + mock_receiver.async_connect.side_effect = exc + + with patch( + "homeassistant.components.lyngdorf.lookup_receiver_model", + return_value=LyngdorfModel.MP_60, + ): + 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 + + +async def test_receiver_disconnects_on_hass_stop( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test the receiver is disconnected when Home Assistant stops.""" + assert init_integration.state is ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_receiver.async_disconnect.assert_awaited_once() + + +async def test_unload_entry( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test unloading the config entry.""" + assert init_integration.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("serial", "expected_mac_connections"), + [ + ("0050c27c76b2", {"00:50:c2:7c:76:b2"}), + ("NOT-A-MAC", set()), + ], + ids=["valid_mac", "non_mac_serial"], +) +@pytest.mark.usefixtures("mock_receiver") +async def test_mac_connection_registered_when_serial_is_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + serial: str, + expected_mac_connections: set[str], +) -> None: + """Test that the device gets a MAC connection only when serial parses as one.""" + entry = MockConfigEntry( + title="Mock Lyngdorf", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_MODEL: "MP-60", + CONF_SERIAL_NUMBER: serial, + }, + unique_id=serial.lower(), + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.lyngdorf.lookup_receiver_model", + return_value=LyngdorfModel.MP_60, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, serial.lower())}) + assert device is not None + mac_connections = { + value for kind, value in device.connections if kind == dr.CONNECTION_NETWORK_MAC + } + assert mac_connections == expected_mac_connections diff --git a/tests/components/lyngdorf/test_media_player.py b/tests/components/lyngdorf/test_media_player.py new file mode 100644 index 000000000000..8d9bcd84092b --- /dev/null +++ b/tests/components/lyngdorf/test_media_player.py @@ -0,0 +1,301 @@ +"""Tests for the Lyngdorf media player platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, + MediaPlayerState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +MAIN_ZONE = "media_player.mock_lyngdorf_main_zone" +ZONE_B = "media_player.mock_lyngdorf_zone_b" + + +async def test_entities( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the media player entities.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "service", "attr", "expected"), + [ + (MAIN_ZONE, SERVICE_TURN_ON, "power_on", True), + (MAIN_ZONE, SERVICE_TURN_OFF, "power_on", False), + (ZONE_B, SERVICE_TURN_ON, "zone_b_power_on", True), + (ZONE_B, SERVICE_TURN_OFF, "zone_b_power_on", False), + ], +) +async def test_power( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + service: str, + attr: str, + expected: bool, +) -> None: + """Test turning power on/off for both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert getattr(mock_receiver, attr) is expected + + +@pytest.mark.parametrize( + ("entity_id", "service", "method"), + [ + (MAIN_ZONE, SERVICE_VOLUME_UP, "volume_up"), + (MAIN_ZONE, SERVICE_VOLUME_DOWN, "volume_down"), + (ZONE_B, SERVICE_VOLUME_UP, "zone_b_volume_up"), + (ZONE_B, SERVICE_VOLUME_DOWN, "zone_b_volume_down"), + ], +) +async def test_volume_step( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + service: str, + method: str, +) -> None: + """Test volume up/down for both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(mock_receiver, method).assert_called_once() + + +@pytest.mark.parametrize( + ("entity_id", "level", "attr", "expected_db"), + [ + (MAIN_ZONE, 0.5, "volume", -31.0), + (MAIN_ZONE, 1.0, "volume", 18.0), + (ZONE_B, 0.3, "zone_b_volume", -50.6), + ], +) +async def test_volume_set( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + level: float, + attr: str, + expected_db: float, +) -> None: + """Test setting and clamping volume on both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: level}, + blocking=True, + ) + assert getattr(mock_receiver, attr) == pytest.approx(expected_db) + + +@pytest.mark.parametrize( + ("entity_id", "attr"), + [ + (MAIN_ZONE, "mute_enabled"), + (ZONE_B, "zone_b_mute_enabled"), + ], +) +async def test_mute( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + attr: str, +) -> None: + """Test muting both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + assert getattr(mock_receiver, attr) is True + + +@pytest.mark.parametrize( + ("entity_id", "attr"), + [ + (MAIN_ZONE, "source"), + (ZONE_B, "zone_b_source"), + ], +) +async def test_select_source( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, + entity_id: str, + attr: str, +) -> None: + """Test selecting source on both zones.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: "HDMI"}, + blocking=True, + ) + assert getattr(mock_receiver, attr) == "HDMI" + + +async def test_select_sound_mode( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test selecting sound mode on the main zone.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: MAIN_ZONE, ATTR_SOUND_MODE: "Movie"}, + blocking=True, + ) + assert mock_receiver.sound_mode == "Movie" + + +async def test_availability( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test availability when device disconnects and reconnects.""" + callbacks = [ + call.args[0] + for call in mock_receiver.register_notification_callback.call_args_list + ] + assert callbacks + + mock_receiver.connected = False + for cb in callbacks: + cb() + await hass.async_block_till_done() + + assert hass.states.get(MAIN_ZONE).state == STATE_UNAVAILABLE + assert hass.states.get(ZONE_B).state == STATE_UNAVAILABLE + + mock_receiver.connected = True + for cb in callbacks: + cb() + await hass.async_block_till_done() + + assert hass.states.get(MAIN_ZONE).state != STATE_UNAVAILABLE + assert hass.states.get(ZONE_B).state != STATE_UNAVAILABLE + + +async def test_main_zone_state_properties( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test main zone state properties are reported correctly.""" + callbacks = [ + call.args[0] + for call in mock_receiver.register_notification_callback.call_args_list + ] + + mock_receiver.power_on = True + mock_receiver.volume = -40.0 + mock_receiver.mute_enabled = False + mock_receiver.source = "HDMI" + mock_receiver.sound_mode = "Movie" + mock_receiver.available_sources = ["HDMI", "Optical"] + mock_receiver.available_sound_modes = ["Movie", "Stereo"] + for cb in callbacks: + cb() + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ZONE) + assert state.state == MediaPlayerState.ON + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.408, abs=0.01) + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False + assert state.attributes[ATTR_INPUT_SOURCE] == "HDMI" + assert state.attributes[ATTR_SOUND_MODE] == "Movie" + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"] + assert state.attributes[ATTR_SOUND_MODE_LIST] == ["Movie", "Stereo"] + + mock_receiver.volume = None + for cb in callbacks: + cb() + await hass.async_block_till_done() + state = hass.states.get(MAIN_ZONE) + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None + + mock_receiver.power_on = False + for cb in callbacks: + cb() + await hass.async_block_till_done() + state = hass.states.get(MAIN_ZONE) + assert state.state == MediaPlayerState.OFF + + +async def test_zone_b_state_properties( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_receiver: MagicMock, +) -> None: + """Test zone B state properties are reported correctly.""" + callbacks = [ + call.args[0] + for call in mock_receiver.register_notification_callback.call_args_list + ] + + mock_receiver.zone_b_power_on = True + mock_receiver.zone_b_volume = -30.0 + mock_receiver.zone_b_mute_enabled = True + mock_receiver.zone_b_source = "Optical" + mock_receiver.zone_b_available_sources = ["HDMI", "Optical"] + for cb in callbacks: + cb() + await hass.async_block_till_done() + + state = hass.states.get(ZONE_B) + assert state.state == MediaPlayerState.ON + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.510, abs=0.01) + assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is True + assert state.attributes[ATTR_INPUT_SOURCE] == "Optical" + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == ["HDMI", "Optical"] + + mock_receiver.zone_b_volume = "invalid" + for cb in callbacks: + cb() + await hass.async_block_till_done() + state = hass.states.get(ZONE_B) + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) is None diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index bd272c2a6423..038b5d3f3ef4 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from mastodon.Mastodon import Account, InstanceV2 +from mastodon.Mastodon import Account, InstanceV2, Status import pytest from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN @@ -45,7 +45,9 @@ def mock_mastodon_client() -> Generator[AsyncMock]: load_fixture("account.json", DOMAIN) ) client.mastodon_api_version = 2 - client.status_post.return_value = None + client.status_post.return_value = Status.from_json( + load_fixture("status_post.json", DOMAIN) + ) client.account_update_credentials.return_value = Account.from_json( load_fixture("account.json", DOMAIN) diff --git a/tests/components/mastodon/fixtures/status_post.json b/tests/components/mastodon/fixtures/status_post.json new file mode 100644 index 000000000000..5a38387d98e3 --- /dev/null +++ b/tests/components/mastodon/fixtures/status_post.json @@ -0,0 +1,84 @@ +{ + "_mastopy_version": "2.0.0", + "_mastopy_type": "Status", + "_mastopy_data": { + "id": "116388626899248381", + "created_at": "2026-04-11T23:18:27.129Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": true, + "spoiler_text": "", + "visibility": "unlisted", + "language": "en", + "uri": "http://localhost:3000/ap/users/116387031229467654/statuses/116388626899248381", + "url": "http://localhost:3000/@mastodonpy_test/116388626899248381", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "quotes_count": 0, + "edited_at": null, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "text": "Toot!", + "filtered": [], + "reblog": null, + "application": { + "name": "Mastodon.py test suite", + "website": null + }, + "account": { + "id": "116387031229467654", + "username": "mastodonpy_test", + "acct": "mastodonpy_test", + "display_name": "John Lennon", + "locked": true, + "bot": false, + "discoverable": null, + "indexable": true, + "group": false, + "created_at": "2026-04-11T00:00:00.000Z", + "note": "\u003cp\u003eI walk funny\u003c/p\u003e", + "url": "http://localhost:3000/@mastodonpy_test", + "uri": "http://localhost:3000/ap/users/116387031229467654", + "avatar": "http://localhost:3000/system/accounts/avatars/116/387/031/229/467/654/original/876440ecc8bd08c0.jpg", + "avatar_static": "http://localhost:3000/system/accounts/avatars/116/387/031/229/467/654/original/876440ecc8bd08c0.jpg", + "header": "http://localhost:3000/system/accounts/headers/116/387/031/229/467/654/original/82144469ad0970a5.jpg", + "header_static": "http://localhost:3000/system/accounts/headers/116/387/031/229/467/654/original/82144469ad0970a5.jpg", + "followers_count": 0, + "following_count": 0, + "statuses_count": 26, + "last_status_at": "2026-04-11", + "hide_collections": true, + "noindex": false, + "emojis": [], + "roles": [], + "fields": [ + { + "name": "bread", + "value": "toasty.", + "verified_at": null + }, + { + "name": "lasagna", + "value": "no!!!", + "verified_at": null + } + ] + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "quote": null, + "card": null, + "poll": null, + "quote_approval": { + "automatic": ["followers"], + "manual": [], + "current_user": "automatic" + } + } +} diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 662ffd51cb46..0159ced905e7 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mastodon @trwnh@mastodon.social', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '4.4.0-nightly.2025-02-07', 'via_device_id': None, diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index b44c97589eda..e868c3fcc94f 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -546,12 +546,14 @@ async def test_unmute_account_failure_api_error( ), ], ) +@pytest.mark.parametrize("return_response", [True, False]) async def test_service_post( hass: HomeAssistant, mock_mastodon_client: AsyncMock, mock_config_entry: MockConfigEntry, payload: dict[str, str], kwargs: dict[str, str | None], + return_response: bool, ) -> None: """Test the post service.""" @@ -563,7 +565,7 @@ async def test_service_post( mock_mastodon_client, "media_post", return_value=MediaAttachment(id="1") ), ): - await hass.services.async_call( + response = await hass.services.async_call( DOMAIN, SERVICE_POST, { @@ -571,12 +573,13 @@ async def test_service_post( } | payload, blocking=True, - return_response=False, + return_response=return_response, ) mock_mastodon_client.status_post.assert_called_with(**kwargs) mock_mastodon_client.status_post.reset_mock() + assert bool(response) is return_response @pytest.mark.parametrize( diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index d0b1385aa0ae..455ba54cf82c 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -57,6 +57,7 @@ FIXTURES = [ "mock_dimmable_plugin_unit", "mock_door_lock", "mock_door_lock_with_unbolt", + "mock_doorbell", "mock_extractor_hood", "mock_fan", "mock_flow_sensor", diff --git a/tests/components/matter/fixtures/nodes/mock_doorbell.json b/tests/components/matter/fixtures/nodes/mock_doorbell.json new file mode 100644 index 000000000000..539dc3720bd0 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/mock_doorbell.json @@ -0,0 +1,81 @@ +{ + "node_id": 63, + "date_commissioned": "2026-07-06T11:13:20.917394", + "last_interview": "2026-07-06T11:13:20.917401", + "interview_version": 2, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "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/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Doorbell", + "0/40/4": 32768, + "0/40/5": "Mock Doorbell", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "prerelease", + "0/40/11": "20260707", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-doorbell", + "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, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 328, + "1": 1 + } + ], + "1/29/1": [3, 29, 59], + "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/59/65529": [], + "1/59/0": 2, + "1/59/65533": 1, + "1/59/1": 0, + "1/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/59/65532": 14, + "1/59/65528": [] + }, + "available": true, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index 0ec4e27fff8a..fa19351158aa 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -1779,6 +1779,71 @@ 'state': 'unknown', }) # --- +# name: test_events[mock_doorbell][event.mock_doorbell_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'initial_press', + 'short_release', + 'long_press', + 'long_release', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.mock_doorbell_button', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Button', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '00000000000004D2-000000000000003F-MatterNodeDevice-1-GenericSwitch-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[mock_doorbell][event.mock_doorbell_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'button', + : None, + : list([ + 'initial_press', + 'short_release', + 'long_press', + 'long_release', + ]), + : 'Mock Doorbell Button', + }), + 'context': , + 'entity_id': 'event.mock_doorbell_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_events[mock_generic_switch][event.mock_generic_switch_button-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 536f054be32a..eca550430e28 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -18779,6 +18779,59 @@ 'state': '2025-01-01T13:59:35+00:00', }) # --- +# name: test_sensors[mock_doorbell][sensor.mock_doorbell_current_switch_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_doorbell_current_switch_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current switch position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current switch position', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-000000000000003F-MatterNodeDevice-1-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[mock_doorbell][sensor.mock_doorbell_current_switch_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Doorbell Current switch position', + : , + }), + 'context': , + 'entity_id': 'sensor.mock_doorbell_current_switch_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[mock_extractor_hood][sensor.mock_extractor_hood_activated_carbon_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 3dc43daaaf70..e25e0d82c9b2 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -9,6 +9,7 @@ import pytest import respx from homeassistant import config_entries +from homeassistant.components.mcp.auth import AuthenticateHeader from homeassistant.components.mcp.const import ( CONF_AUTHORIZATION_URL, CONF_SCOPE, @@ -892,3 +893,135 @@ async def test_reauth_flow( assert token == OAUTH_TOKEN_PAYLOAD assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow_upgrade_to_oauth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test reauth flow upgrading a no-auth entry to OAuth.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: MCP_SERVER_URL}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + auth_header = AuthenticateHeader( + resource_metadata_url="https://example.com/custom-discovery", + scopes=SCOPES_SUPPORTED, + ) + + # Start reauth flow passing auth_header + config_entry.async_start_reauth(hass, data={"auth_header": auth_header}) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # Mock discovery URLs (bypassing connection validation) + respx.get("https://example.com/custom-discovery").mock( + return_value=OAUTH_PROTECTED_RESOURCE_METADATA_RESPONSE + ) + respx.get(OAUTH_AUTHORIZATION_SERVER_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + # Click Submit on reauth_confirm + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Flow should proceed to credentials choice + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "next_step_id": "pick_implementation", + }, + ) + assert result["type"] is FlowResultType.EXTERNAL_STEP + result = await perform_oauth_flow( + hass, + aioclient_mock, + hass_client_no_auth, + result, + authorize_url=OAUTH_AUTHORIZE_URL, + token_url=OAUTH_TOKEN_URL, + scopes=SCOPES_SUPPORTED, + ) + + # Verify we can connect to the server now with the token + response = Mock() + response.serverInfo.name = TEST_API_NAME + # Return success for validation in async_oauth_create_entry + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.unique_id is None + assert config_entry.title == TEST_API_NAME + data = {**config_entry.data} + token = data.pop(CONF_TOKEN) + assert data == { + "auth_implementation": AUTH_DOMAIN, + CONF_URL: MCP_SERVER_URL, + CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, + CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_SCOPE: SCOPES_SUPPORTED, + } + assert token + token.pop("expires_at") + assert token == OAUTH_TOKEN_PAYLOAD + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +@respx.mock +async def test_reauth_flow_upgrade_to_oauth_no_auth_header( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + credential: None, + aioclient_mock: AiohttpClientMocker, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test reauth flow upgrading a no-auth entry to OAuth when no auth header is passed (fallback).""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: MCP_SERVER_URL}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + # Start reauth flow without passing auth_header + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # Mock discovery on the default server URL (since there is no auth_header) + respx.get(OAUTH_DISCOVERY_ENDPOINT).mock( + return_value=OAUTH_SERVER_METADATA_RESPONSE + ) + + # Click Submit on reauth_confirm + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + # Flow should proceed directly to credentials choice menu (without validate_input) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "credentials_choice" diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 0a1f67e13363..70bb2aa22d6c 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -9,9 +9,15 @@ from mcp.types import CallToolResult, ErrorData, ListToolsResult, TextContent, T import pytest import voluptuous as vol +from homeassistant.components.mcp.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import llm from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -84,7 +90,6 @@ async def test_init( [ (httpx.TimeoutException("Some timeout")), (httpx.HTTPStatusError("", request=None, response=httpx.Response(500))), - (httpx.HTTPStatusError("", request=None, response=httpx.Response(401))), (httpx.HTTPError("Some HTTP error")), ], ) @@ -104,6 +109,55 @@ async def test_mcp_server_failure( assert config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_mcp_server_setup_auth_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test setup auth failure triggers reauth.""" + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_mcp_server_setup_auth_failure_with_www_authenticate_header( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test setup auth failure with WWW-Authenticate header parses header and triggers reauth.""" + headers = { + "WWW-Authenticate": 'mcp resource_metadata="https://example.com/custom-discovery", scope="read write"' + } + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "Authentication required", + request=None, + response=httpx.Response(401, headers=headers), + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # Get the flow handler instance and verify it has the correct auth_header + flow_handler = hass.config_entries.flow._progress[flows[0]["flow_id"]] + assert flow_handler.auth_header is not None + assert ( + flow_handler.auth_header.resource_metadata_url + == "https://example.com/custom-discovery" + ) + + async def test_mcp_server_http_transport_failure( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -361,3 +415,293 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry_with_auth.state is ConfigEntryState.SETUP_RETRY + + +async def test_tool_call_no_auth_auth_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call auth failure when no auth was initially required.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call encountering a 401 response + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Authentication required", request=None, response=httpx.Response(401) + ) + + with pytest.raises(ConfigEntryAuthFailed): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_tool_call_no_auth_auth_failure_with_www_authenticate_header( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call 401 with WWW-Authenticate header triggers reauth and passes header.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call encountering a 401 response with WWW-Authenticate header + headers = { + "WWW-Authenticate": 'mcp resource_metadata="https://example.com/custom-discovery", scope="read write"' + } + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Authentication required", + request=None, + response=httpx.Response(401, headers=headers), + ) + + with pytest.raises(ConfigEntryAuthFailed): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # Get the flow handler instance and verify it has the correct auth_header + flow_handler = hass.config_entries.flow._progress[flows[0]["flow_id"]] + assert flow_handler.auth_header is not None + assert ( + flow_handler.auth_header.resource_metadata_url + == "https://example.com/custom-discovery" + ) + + +async def test_tool_call_expired_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call token refresh failure when OAuth is configured.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock token validation failure during tool call + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ), + pytest.raises(ConfigEntryAuthFailed), + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_mcp_server_setup_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, +) -> None: + """Test setup OAuth failure triggers reauth.""" + # Mock token validation failure (e.g. refresh token expired) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_list_tools_timeout( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test setup fails with SETUP_RETRY if list tools times out.""" + mock_mcp_client.return_value.list_tools.side_effect = TimeoutError( + "Listing tools timed out" + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_tool_call_timeout( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call timing out raises HomeAssistantError.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call timeout + mock_mcp_client.return_value.call_tool.side_effect = TimeoutError("Call timed out") + + with pytest.raises(HomeAssistantError, match="Timeout when calling tool"): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + +async def test_tool_call_transient_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call transient token refresh failure does not trigger reauth.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock transient token validation failure (e.g. 503 Service Unavailable) + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ), + pytest.raises(HomeAssistantError), + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + # Verify no reauth flow is initiated + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +async def test_mcp_server_setup_transient_oauth_failure( + hass: HomeAssistant, + credential: None, + config_entry_with_auth: MockConfigEntry, +) -> None: + """Test setup transient OAuth failure does not trigger reauth.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestError( + request_info=Mock(), history=(), domain=DOMAIN + ), + ): + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + assert config_entry_with_auth.state is ConfigEntryState.SETUP_RETRY + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + +async def test_tool_call_http_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_mcp_client: Mock, +) -> None: + """Test tool call HTTP error raises HomeAssistantError.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + api_instance = await api.async_get_api_instance(create_llm_context()) + tool = api_instance.tools[0] + + # Mock tool call raising HTTPError + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPError( + "Connection timed out or failed" + ) + + with pytest.raises( + HomeAssistantError, + match="Error communicating with MCP server when calling tool", + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index e0004a274e28..d05640414faa 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -26,7 +26,12 @@ from homeassistant.components.mcp_server.http import ( STREAMABLE_API, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON +from homeassistant.const import ( + CONF_LLM_HASS_API, + CONTENT_TYPE_JSON, + STATE_OFF, + STATE_ON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -633,3 +638,78 @@ async def test_mcp_tool_call_unicode( response_text = result.content[0].text assert "这是一个测试" in response_text assert "\\u" not in response_text + + +async def test_streamable_api_id_exposes_registered_api( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_supervisor_access_token: str, +) -> None: + """Test the keyed endpoint exposes any registered API, not just the configured one.""" + llm.async_register_api( + hass, MockLLMAPI(hass=hass, id=TEST_LLM_API_ID, name="Test API") + ) + + client = await hass_client() + mcp_url = str(client.make_url(f"{STREAMABLE_API}/{TEST_LLM_API_ID}")) + + async with mcp_streamable_session( + hass, mcp_url, hass_supervisor_access_token + ) as session: + result = await session.list_prompts() + + assert len(result.prompts) == 1 + assert result.prompts[0].name == "Test API" + + +async def test_streamable_api_id_requires_admin( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test a non-Assist keyed endpoint requires an admin user.""" + llm.async_register_api( + hass, MockLLMAPI(hass=hass, id=TEST_LLM_API_ID, name="Test API") + ) + + client = await hass_client(hass_read_only_access_token) + response = await client.post( + f"{STREAMABLE_API}/{TEST_LLM_API_ID}", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.UNAUTHORIZED + + +async def test_streamable_api_id_assist_allows_non_admin( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test the Assist keyed endpoint does not require an admin user.""" + client = await hass_client(hass_read_only_access_token) + response = await client.post( + f"{STREAMABLE_API}/{llm.LLM_API_ASSIST}", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.OK + + +async def test_streamable_api_id_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test the keyed endpoint returns 404 for an unknown API ID.""" + client = await hass_client() + response = await client.post( + f"{STREAMABLE_API}/does-not-exist", + json=INITIALIZE_MESSAGE, + headers={"accept": CONTENT_TYPE_JSON}, + ) + assert response.status == HTTPStatus.NOT_FOUND + assert "Unknown LLM API" in await response.text() diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index ce8035f289b0..5ce8158cedce 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mealie', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'v3.7.0', 'via_device_id': None, diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr index 654e631cdda7..a6ae48bbdba0 100644 --- a/tests/components/meater/snapshots/test_init.ambr +++ b/tests/components/meater/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Meater Probe 40a72384', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/media_player/test_llm.py b/tests/components/media_player/test_llm.py new file mode 100644 index 000000000000..7ca247a63af0 --- /dev/null +++ b/tests/components/media_player/test_llm.py @@ -0,0 +1,69 @@ +"""Tests for the media_player LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.media_player import llm as media_player_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "media_player.test" +INTENTS = { + "HassMediaNext", + "HassMediaPause", + "HassMediaPlayerMute", + "HassMediaPlayerUnmute", + "HassMediaPrevious", + "HassMediaSearchAndPlay", + "HassMediaUnpause", + "HassSetVolume", + "HassSetVolumeRelative", +} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a media_player entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "media_player", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test media_player"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the media_player platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed media_player entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no media_player entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) + assert media_player_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert media_player_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/media_source/test_helper.py b/tests/components/media_source/test_helper.py index 824a27f6efb1..0f5a236ac6d4 100644 --- a/tests/components/media_source/test_helper.py +++ b/tests/components/media_source/test_helper.py @@ -1,12 +1,17 @@ """Test media source helpers.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch import pytest from homeassistant.components import media_source -from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_player import ( + BrowseError, + SearchMedia, + SearchMediaQuery, +) from homeassistant.components.media_source import const, models +from homeassistant.components.media_source.const import DATA_MEDIA_SOURCE_PLATFORMS from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -120,10 +125,70 @@ async def test_async_unresolve_media(hass: HomeAssistant) -> None: ) -async def test_browse_resolve_without_setup() -> None: +async def test_browse_resolve_without_setup(hass: HomeAssistant) -> None: """Test browse and resolve work without being setup.""" with pytest.raises(BrowseError): - await media_source.async_browse_media(Mock(data={}), None) + await media_source.async_browse_media(hass, None) + + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, None, SearchMediaQuery(search_query="test") + ) with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(Mock(data={}), None, None) + await media_source.async_resolve_media(hass, None, None) + + +async def test_async_search_media(hass: HomeAssistant) -> None: + """Test search media helper.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + # Search the default media directory by file name + result = await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="test") + ) + assert isinstance(result, SearchMedia) + assert [item.title for item in result.result] == ["test.mp3"] + + # A query without matches returns an empty result + result = await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="no-such-file") + ) + assert result.result == [] + + # Invalid media content raises a BrowseError + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, "invalid", SearchMediaQuery(search_query="test") + ) + + +async def test_async_search_media_not_supported(hass: HomeAssistant) -> None: + """Test searching a source without search support raises a BrowseError.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platform = AsyncMock( + return_value=models.MediaSource("plain") + ) + + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}plain", + SearchMediaQuery(search_query="test"), + ) + + +async def test_async_search_media_root_not_supported(hass: HomeAssistant) -> None: + """Test searching the aggregate root of multiple sources is not supported.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + hass.data[DATA_MEDIA_SOURCE_PLATFORMS].async_get_platforms = AsyncMock( + return_value={"source_a": models.MediaSource("source_a")} + ) + + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, "", SearchMediaQuery(search_query="test") + ) diff --git a/tests/components/media_source/test_http.py b/tests/components/media_source/test_http.py index c5f487f27cfa..c826c7cdd8dd 100644 --- a/tests/components/media_source/test_http.py +++ b/tests/components/media_source/test_http.py @@ -6,7 +6,12 @@ import pytest import yarl from homeassistant.components import media_source -from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_player import ( + BrowseError, + MediaClass, + SearchMedia, + SearchMediaQuery, +) from homeassistant.components.media_source import const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -127,3 +132,72 @@ async def test_websocket_resolve_media( assert not msg["success"] assert msg["error"]["code"] == "resolve_media_failed" assert msg["error"]["message"] == "test" + + +async def test_websocket_search_media( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test search media websocket.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + search_media = SearchMedia( + result=[ + media_source.models.BrowseMediaSource( + domain=media_source.DOMAIN, + identifier="/media/test.mp3", + title="test.mp3", + media_class=MediaClass.MUSIC, + media_content_type="audio/mpeg", + can_play=True, + can_expand=False, + ) + ] + ) + + with patch( + "homeassistant.components.media_source.http.async_search_media", + return_value=search_media, + ) as mock_search: + await client.send_json( + { + "id": 1, + "type": "media_source/search_media", + "media_content_id": f"{const.URI_SCHEME}{media_source.DOMAIN}", + "search_query": "test", + "media_filter_classes": ["music"], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["id"] == 1 + assert msg["result"] == search_media.as_dict() + + # The query is built from the websocket message, coercing the filter classes + query = mock_search.call_args[0][2] + assert isinstance(query, SearchMediaQuery) + assert query.search_query == "test" + assert query.media_filter_classes == [MediaClass.MUSIC] + + with patch( + "homeassistant.components.media_source.http.async_search_media", + side_effect=BrowseError("test"), + ): + await client.send_json( + { + "id": 2, + "type": "media_source/search_media", + "media_content_id": "invalid", + "search_query": "test", + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "search_media_failed" + assert msg["error"]["message"] == "test" diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index cf63ad9d6884..47a7b030de79 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -10,8 +10,13 @@ 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_player import ( + BrowseError, + MediaClass, + SearchMediaQuery, +) from homeassistant.components.media_source import const +from homeassistant.components.media_source.local_source import MAX_SEARCH_RESULTS from homeassistant.core import HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component @@ -90,6 +95,123 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: assert media +async def test_async_search_media(hass: HomeAssistant) -> None: + """Test searching local media.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"local": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # Search within a single directory (contextual) + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="test"), + ) + assert [item.title for item in result.result] == ["test.mp3"] + assert ( + result.result[0].media_content_id + == f"{const.URI_SCHEME}{const.DOMAIN}/local/test.mp3" + ) + + # Search across all directories (global) finds the file in both dirs + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}", + SearchMediaQuery(search_query="sax"), + ) + assert {item.title for item in result.result} == {"Epic Sax Guy 10 Hours.mp4"} + assert len(result.result) == 2 + + # Non-media files are not returned + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="not_media"), + ) + assert result.result == [] + + # Searching a non-existent directory returns no results + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local/nonexistent", + SearchMediaQuery(search_query="test"), + ) + assert result.result == [] + + # Filter by media class + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery(search_query="", media_filter_classes=[MediaClass.MUSIC]), + ) + assert [item.title for item in result.result] == ["test.mp3"] + + # Invalid path raises a BrowseError + with pytest.raises(BrowseError): + await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local/../secret", + SearchMediaQuery(search_query="test"), + ) + + +async def test_async_search_media_limit_and_hidden( + hass: HomeAssistant, tmp_path: Path +) -> None: + """Test that search caps results and skips hidden files.""" + for i in range(MAX_SEARCH_RESULTS + 20): + (tmp_path / f"song_{i}.mp3").touch() + (tmp_path / ".hidden_song.mp3").touch() + + await async_process_ha_core_config( + hass, {"media_dirs": {"local": str(tmp_path), "recordings": str(tmp_path)}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # Global search across both dirs; the first dir already fills the limit + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}", + SearchMediaQuery(search_query="song"), + ) + assert len(result.result) == MAX_SEARCH_RESULTS + assert all(not item.title.startswith(".") for item in result.result) + + +async def test_browse_media_can_search(hass: HomeAssistant) -> None: + """Test that browsable directories advertise search support.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"local": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + # The root of multiple directories is searchable + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{const.DOMAIN}" + ) + assert media.can_search + + # A directory is searchable, but the files inside it are not + media = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/." + ) + assert media.can_search + file_child = next(child for child in media.children if child.can_play) + assert not file_child.can_search + + async def test_media_view( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: diff --git a/tests/components/media_source/test_models.py b/tests/components/media_source/test_models.py index 1ed03a839615..ad4662c4d2b5 100644 --- a/tests/components/media_source/test_models.py +++ b/tests/components/media_source/test_models.py @@ -1,6 +1,12 @@ """Test Media Source model methods.""" -from homeassistant.components.media_player import MediaClass, MediaType +import pytest + +from homeassistant.components.media_player import ( + MediaClass, + MediaType, + SearchMediaQuery, +) from homeassistant.components.media_source import const, models from homeassistant.core import HomeAssistant @@ -84,3 +90,11 @@ async def test_media_source_item_media_source_id(hass: HomeAssistant) -> None: # Test with no domain (root) item = models.MediaSourceItem(hass, None, "", None) assert item.media_source_id == "media-source://" + + +async def test_media_source_search_media_not_implemented(hass: HomeAssistant) -> None: + """Test the base MediaSource.async_search_media raises NotImplementedError.""" + source = models.MediaSource(const.DOMAIN) + item = models.MediaSourceItem(hass, const.DOMAIN, "", None) + with pytest.raises(NotImplementedError): + await source.async_search_media(item, SearchMediaQuery(search_query="test")) diff --git a/tests/components/melcloud_home/conftest.py b/tests/components/melcloud_home/conftest.py index 274319f87ad1..1eb04056809e 100644 --- a/tests/components/melcloud_home/conftest.py +++ b/tests/components/melcloud_home/conftest.py @@ -4,12 +4,17 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch from aiomelcloudhome import MELCloudHome, UserContext +from aiomelcloudhome.models.telemetry import TelemetryValue import pytest from homeassistant.components.melcloud_home.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from tests.common import MockConfigEntry, load_json_value_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) MOCK_USER_INPUT = { CONF_EMAIL: "user@example.com", @@ -36,8 +41,13 @@ def mock_melcloud_client() -> Generator[AsyncMock]: """Mock MELCloud Home client.""" client = AsyncMock(MELCloudHome) client.get_context.return_value = UserContext.model_validate( - load_json_value_fixture("context.json", DOMAIN) + load_json_object_fixture("context.json", DOMAIN) ) + client.get_energy_telemetry.return_value = [ + TelemetryValue.model_validate(value) + for value in load_json_array_fixture("energy.json", DOMAIN) + ] + with ( patch( "homeassistant.components.melcloud_home.MELCloudHome", diff --git a/tests/components/melcloud_home/fixtures/context.json b/tests/components/melcloud_home/fixtures/context.json index 55a555ffef96..db95462950fb 100644 --- a/tests/components/melcloud_home/fixtures/context.json +++ b/tests/components/melcloud_home/fixtures/context.json @@ -32,7 +32,7 @@ "hasAirDirection": true, "hasSwing": true, "hasExtendedTemperatureRange": true, - "hasEnergyConsumedMeter": false, + "hasEnergyConsumedMeter": true, "numberOfFanSpeeds": 5, "minTempCool": 16, "maxTempCool": 31, @@ -195,7 +195,7 @@ "rssi": -52, "frostProtection": { "active": false, - "enabled": false, + "enabled": true, "min": 5, "max": 8 }, diff --git a/tests/components/melcloud_home/fixtures/energy.json b/tests/components/melcloud_home/fixtures/energy.json new file mode 100644 index 000000000000..b0099eaed4bd --- /dev/null +++ b/tests/components/melcloud_home/fixtures/energy.json @@ -0,0 +1,5 @@ +[ + { "time": "2026-01-14 02:00:00.000000000", "value": "100.0" }, + { "time": "2026-01-14 03:00:00.000000000", "value": "150.5" }, + { "time": "2026-01-14 04:00:00.000000000", "value": "200.0" } +] diff --git a/tests/components/melcloud_home/snapshots/test_binary_sensor.ambr b/tests/components/melcloud_home/snapshots/test_binary_sensor.ambr index fc20fd067c7e..cec636627626 100644 --- a/tests/components/melcloud_home/snapshots/test_binary_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_binary_sensor.ambr @@ -100,6 +100,56 @@ 'state': 'off', }) # --- +# name: test_all_entities[binary_sensor.heat_pump_frost_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.heat_pump_frost_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Frost protection', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection', + 'unique_id': 'atw-unit-uuid-1_frost_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.heat_pump_frost_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Heat Pump Frost protection', + }), + 'context': , + 'entity_id': 'binary_sensor.heat_pump_frost_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[binary_sensor.heat_pump_holiday_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -150,6 +200,56 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[binary_sensor.heat_pump_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.heat_pump_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Overheat protection', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection', + 'unique_id': 'atw-unit-uuid-1_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.heat_pump_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Heat Pump Overheat protection', + }), + 'context': , + 'entity_id': 'binary_sensor.heat_pump_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[binary_sensor.heat_pump_standby-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/melcloud_home/snapshots/test_climate.ambr b/tests/components/melcloud_home/snapshots/test_climate.ambr index 3d55e0981f43..7c542c400d28 100644 --- a/tests/components/melcloud_home/snapshots/test_climate.ambr +++ b/tests/components/melcloud_home/snapshots/test_climate.ambr @@ -158,8 +158,8 @@ , , ]), - : 35, - : 7, + : 31.0, + : 10.0, : list([ 'auto', 'swing', @@ -231,8 +231,8 @@ , , ]), - : 35, - : 7, + : 31.0, + : 10.0, : , : 'centre', : list([ diff --git a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr index 0cf362c7bf11..e6e1255a61ff 100644 --- a/tests/components/melcloud_home/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud_home/snapshots/test_diagnostics.ambr @@ -33,7 +33,7 @@ 'has_auto_operation_mode': True, 'has_cool_operation_mode': True, 'has_dry_operation_mode': True, - 'has_energy_consumed_meter': False, + 'has_energy_consumed_meter': True, 'has_fan_operation_mode': None, 'has_half_degree_increments': True, 'has_outdoor_temperature_sensor': False, @@ -153,7 +153,7 @@ 'forced_hot_water_mode': False, 'frost_protection': dict({ 'active': False, - 'enabled': False, + 'enabled': True, 'max': 8.0, 'min': 5.0, }), diff --git a/tests/components/melcloud_home/snapshots/test_number.ambr b/tests/components/melcloud_home/snapshots/test_number.ambr new file mode 100644 index 000000000000..8a757dd27310 --- /dev/null +++ b/tests/components/melcloud_home/snapshots/test_number.ambr @@ -0,0 +1,489 @@ +# serializer version: 1 +# name: test_all_entities[number.heat_pump_frost_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_frost_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_max_temp', + 'unique_id': 'atw-unit-uuid-1_frost_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Frost protection maximum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_frost_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_frost_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_min_temp', + 'unique_id': 'atw-unit-uuid-1_frost_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_frost_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Frost protection minimum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_frost_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60.0, + : 20.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_overheat_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_max_temp', + 'unique_id': 'atw-unit-uuid-1_overheat_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Overheat protection maximum temperature', + : 60.0, + : 20.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_overheat_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42.0', + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60.0, + : 20.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.heat_pump_overheat_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_min_temp', + 'unique_id': 'atw-unit-uuid-1_overheat_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.heat_pump_overheat_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Heat Pump Overheat protection minimum temperature', + : 60.0, + : 20.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.heat_pump_overheat_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_frost_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_max_temp', + 'unique_id': 'ata-unit-uuid-1_frost_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Frost protection maximum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_frost_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30.0, + : 0.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_frost_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frost protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frost protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frost_protection_min_temp', + 'unique_id': 'ata-unit-uuid-1_frost_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_frost_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Frost protection minimum temperature', + : 30.0, + : 0.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_frost_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_maximum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 31.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_overheat_protection_maximum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection maximum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection maximum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_max_temp', + 'unique_id': 'ata-unit-uuid-1_overheat_protection_max_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_maximum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Overheat protection maximum temperature', + : 40.0, + : 31.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_overheat_protection_maximum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.0', + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_minimum_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 31.0, + : , + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_ac_overheat_protection_minimum_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Overheat protection minimum temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheat protection minimum temperature', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'overheat_protection_min_temp', + 'unique_id': 'ata-unit-uuid-1_overheat_protection_min_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.living_room_ac_overheat_protection_minimum_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Room AC Overheat protection minimum temperature', + : 40.0, + : 31.0, + : , + : 0.5, + : , + }), + 'context': , + 'entity_id': 'number.living_room_ac_overheat_protection_minimum_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.0', + }) +# --- diff --git a/tests/components/melcloud_home/snapshots/test_sensor.ambr b/tests/components/melcloud_home/snapshots/test_sensor.ambr index 85a3c4d31e27..755967d4fe20 100644 --- a/tests/components/melcloud_home/snapshots/test_sensor.ambr +++ b/tests/components/melcloud_home/snapshots/test_sensor.ambr @@ -1,4 +1,66 @@ # serializer version: 1 +# name: test_all_entities[sensor.heat_pump_energy_consumed_monthly-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_pump_energy_consumed_monthly', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumed (monthly)', + '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 (monthly)', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumed', + 'unique_id': 'atw-unit-uuid-1_energy_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.heat_pump_energy_consumed_monthly-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Heat Pump Energy consumed (monthly)', + : '2026-06-01T00:00:00+00:00', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.heat_pump_energy_consumed_monthly', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4505', + }) +# --- # name: test_all_entities[sensor.heat_pump_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -228,6 +290,68 @@ 'state': '21.0', }) # --- +# name: test_all_entities[sensor.living_room_ac_energy_consumed_monthly-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_ac_energy_consumed_monthly', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy consumed (monthly)', + '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 (monthly)', + 'platform': 'melcloud_home', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_consumed', + 'unique_id': 'ata-unit-uuid-1_energy_consumed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.living_room_ac_energy_consumed_monthly-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Living Room AC Energy consumed (monthly)', + : '2026-06-01T00:00:00+00:00', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_ac_energy_consumed_monthly', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4505', + }) +# --- # name: test_all_entities[sensor.living_room_ac_room_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/melcloud_home/snapshots/test_switch.ambr b/tests/components/melcloud_home/snapshots/test_switch.ambr index 4d3f0f73f2f2..a75a0b091d8c 100644 --- a/tests/components/melcloud_home/snapshots/test_switch.ambr +++ b/tests/components/melcloud_home/snapshots/test_switch.ambr @@ -47,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_all_entities[switch.heat_pump_overheat_protection-entry] diff --git a/tests/components/melcloud_home/test_climate.py b/tests/components/melcloud_home/test_climate.py index 4d0f3791166b..6da75604beac 100644 --- a/tests/components/melcloud_home/test_climate.py +++ b/tests/components/melcloud_home/test_climate.py @@ -1,5 +1,7 @@ """Test the MELCloud Home climate platform.""" +from collections.abc import Callable +from typing import Any from unittest.mock import AsyncMock, patch from aiomelcloudhome import ( @@ -8,6 +10,7 @@ from aiomelcloudhome import ( ATAVaneHorizontal, ATAVaneVertical, ATWZoneMode, + UserContext, ) import pytest @@ -24,6 +27,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.components.melcloud_home.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -36,7 +40,12 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform +from tests.common import ( + MockConfigEntry, + SnapshotAssertion, + async_load_json_object_fixture, + snapshot_platform, +) ATA_ENTITY_ID = "climate.living_room_ac" ATW_ZONE1_ENTITY_ID = "climate.heat_pump_zone_1" @@ -61,6 +70,68 @@ async def test_climate_platform( ) +@pytest.mark.parametrize( + ("apply_capabilities", "expected_hvac_modes"), + [ + pytest.param( + lambda _: None, + [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ], + id="no_capabilities", + ), + pytest.param( + lambda caps: caps, + [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + HVACMode.DRY, + HVACMode.FAN_ONLY, + ], + id="all_modes", + ), + pytest.param( + lambda caps: { + **caps, + "hasCoolOperationMode": False, + "hasAutoOperationMode": False, + "hasDryOperationMode": False, + "hasFanOperationMode": False, + }, + [HVACMode.OFF, HVACMode.HEAT], + id="heat_only", + ), + ], +) +async def test_ata_hvac_modes( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + apply_capabilities: Callable[[dict[str, Any]], dict[str, Any] | None], + expected_hvac_modes: list[HVACMode], +) -> None: + """Test ATA hvac_modes for varying unit capabilities.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + ata_unit = context["buildings"][0]["airToAirUnits"][0] + ata_unit["capabilities"] = apply_capabilities(ata_unit["capabilities"]) + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ATA_ENTITY_ID) + assert state is not None + assert state.attributes["hvac_modes"] == expected_hvac_modes + + @pytest.mark.parametrize( ("hvac_mode", "arguments"), [ @@ -367,3 +438,91 @@ async def test_atw_turn_on_off( mock_melcloud_client.control_atw_unit.assert_called_once_with( "atw-unit-uuid-1", **expected_kwargs ) + + +@pytest.mark.parametrize( + ("operation_mode", "expected_min", "expected_max"), + [ + pytest.param("Cool", 16.0, 31.0, id="cool"), + pytest.param("Automatic", 16.0, 31.0, id="auto"), + pytest.param("Fan", 7, 35, id="fan_only"), + ], +) +async def test_ata_temperature_range_by_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, + operation_mode: str, + expected_min: float, + expected_max: float, +) -> None: + """Test ATA min/max temperature for cool, auto, and fallback HVAC modes.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + next( + setting + for setting in context["buildings"][0]["airToAirUnits"][0]["settings"] + if setting["name"] == "OperationMode" + )["value"] = operation_mode + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ATA_ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == expected_min + assert state.attributes["max_temp"] == expected_max + + +async def test_ata_no_capabilities_temperature_range( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, +) -> None: + """Test fallback temperature range and hvac_modes when ATA unit has no capabilities.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + context["buildings"][0]["airToAirUnits"][0]["capabilities"] = None + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ATA_ENTITY_ID) + assert state is not None + assert state.attributes["min_temp"] == 7 + assert state.attributes["max_temp"] == 35 + assert HVACMode.FAN_ONLY in state.attributes["hvac_modes"] + + +async def test_atw_zone_temperature_range( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_melcloud_client: AsyncMock, +) -> None: + """Test ATW zone min/max temperature read from unit capabilities.""" + context: dict[str, Any] = await async_load_json_object_fixture( + hass, "context.json", DOMAIN + ) + context["buildings"][0]["airToWaterUnits"][0]["capabilities"].update( + { + "minSetTemperatureZone1": 10.0, + "maxSetTemperatureZone1": 30.0, + "minSetTemperatureZone2": 12.0, + "maxSetTemperatureZone2": 28.0, + } + ) + mock_melcloud_client.get_context.return_value = UserContext.model_validate(context) + + await setup_integration(hass, mock_config_entry) + + state1 = hass.states.get(ATW_ZONE1_ENTITY_ID) + assert state1 is not None + assert state1.attributes["min_temp"] == 10.0 + assert state1.attributes["max_temp"] == 30.0 + + state2 = hass.states.get(ATW_ZONE2_ENTITY_ID) + assert state2 is not None + assert state2.attributes["min_temp"] == 12.0 + assert state2.attributes["max_temp"] == 28.0 diff --git a/tests/components/melcloud_home/test_config_flow.py b/tests/components/melcloud_home/test_config_flow.py index 44fabb494bd8..a0c04b9e04d3 100644 --- a/tests/components/melcloud_home/test_config_flow.py +++ b/tests/components/melcloud_home/test_config_flow.py @@ -209,3 +209,109 @@ async def test_reauth_flow_exceptions( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_config_entry.data == MOCK_REAUTH_INPUT + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full 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 result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_REAUTH_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == MOCK_REAUTH_INPUT + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + pytest.param(MelCloudHomeAuthenticationError("bad creds"), "invalid_auth"), + pytest.param(MelCloudHomeConnectionError("offline"), "cannot_connect"), + pytest.param(MelCloudHomeTimeoutError("timed out"), "timeout_connect"), + pytest.param(Exception("unexpected"), "unknown"), + ], +) +async def test_reconfigure_flow_exceptions( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions in 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 result["step_id"] == "reconfigure" + + mock_melcloud_client.get_context.side_effect = MelCloudHomeConnectionError( + "offline" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_REAUTH_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_melcloud_client.get_context.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == MOCK_REAUTH_INPUT + + +async def test_reconfigure_flow_wrong_account( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reconfigure flow aborts when a different account is used.""" + 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"] == "reconfigure" + + mock_melcloud_client.get_context.return_value = ( + mock_melcloud_client.get_context.return_value.model_copy( + update={"id": "user-uuid-2"} + ) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_REAUTH_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data == MOCK_USER_INPUT + assert mock_config_entry.unique_id == "user-uuid-1" diff --git a/tests/components/melcloud_home/test_number.py b/tests/components/melcloud_home/test_number.py new file mode 100644 index 000000000000..d9ce0d16b1b4 --- /dev/null +++ b/tests/components/melcloud_home/test_number.py @@ -0,0 +1,310 @@ +"""Tests for the MELCloud Home number platform.""" + +from unittest.mock import AsyncMock, patch + +from aiomelcloudhome.exceptions import ( + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) +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 + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("mock_melcloud_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all number entities.""" + with patch( + "homeassistant.components.melcloud_home.PLATFORMS", + [Platform.NUMBER], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("entity_id", "method", "expected_kwargs", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 11.0, + "max_temp": 12.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 11.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 10.0, + "max_temp": 13.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 13.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 36.0, + "max_temp": 37.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 36.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 35.0, + "max_temp": 38.0, + "ata_unit_ids": ["ata-unit-uuid-1"], + }, + 38.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 6.0, + "max_temp": 8.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 6.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + "set_frost_protection", + { + "enabled": True, + "min_temp": 5.0, + "max_temp": 9.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 9.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 41.0, + "max_temp": 42.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 41.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + "set_overheat_protection", + { + "enabled": True, + "min_temp": 40.0, + "max_temp": 43.0, + "atw_unit_ids": ["atw-unit-uuid-1"], + }, + 43.0, + ), + ], +) +async def test_set_value( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, + expected_kwargs: dict, + value: float, +) -> None: + """Test number set_value calls the correct API method.""" + await setup_integration(hass, mock_config_entry) + + method_mock = getattr(mock_melcloud_client, method) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + method_mock.assert_called_once_with(**expected_kwargs) + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + 12.0, + ), + ( + "number.living_room_ac_frost_protection_minimum_temperature", + 13.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + 10.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + 9.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + 37.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + 35.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + 8.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + 5.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + 42.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + 40.0, + ), + ], +) +async def test_set_value_validation_error( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + value: float, +) -> None: + """Test that setting min >= max or max <= min raises HomeAssistantError.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + mock_melcloud_client.set_frost_protection.assert_not_called() + mock_melcloud_client.set_overheat_protection.assert_not_called() + + +@pytest.mark.parametrize( + ("entity_id", "method", "value"), + [ + ( + "number.living_room_ac_frost_protection_minimum_temperature", + "set_frost_protection", + 10.0, + ), + ( + "number.living_room_ac_frost_protection_maximum_temperature", + "set_frost_protection", + 12.0, + ), + ( + "number.living_room_ac_overheat_protection_minimum_temperature", + "set_overheat_protection", + 35.0, + ), + ( + "number.living_room_ac_overheat_protection_maximum_temperature", + "set_overheat_protection", + 37.0, + ), + ( + "number.heat_pump_frost_protection_minimum_temperature", + "set_frost_protection", + 5.0, + ), + ( + "number.heat_pump_frost_protection_maximum_temperature", + "set_frost_protection", + 8.0, + ), + ( + "number.heat_pump_overheat_protection_minimum_temperature", + "set_overheat_protection", + 40.0, + ), + ( + "number.heat_pump_overheat_protection_maximum_temperature", + "set_overheat_protection", + 42.0, + ), + ], +) +@pytest.mark.parametrize( + ("raise_exception", "expected_exception"), + [ + (MelCloudHomeAuthenticationError, HomeAssistantError), + (MelCloudHomeConnectionError, HomeAssistantError), + (MelCloudHomeTimeoutError, HomeAssistantError), + ], +) +async def test_set_value_exceptions( + hass: HomeAssistant, + mock_melcloud_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, + value: float, + raise_exception: type[Exception], + expected_exception: type[Exception], +) -> None: + """Test number actions raise HomeAssistantError on client errors.""" + await setup_integration(hass, mock_config_entry) + + method_mock = getattr(mock_melcloud_client, method) + method_mock.side_effect = raise_exception + + with pytest.raises(expected_exception): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) diff --git a/tests/components/melcloud_home/test_sensor.py b/tests/components/melcloud_home/test_sensor.py index 7afed42c9ec0..3b3e1372f944 100644 --- a/tests/components/melcloud_home/test_sensor.py +++ b/tests/components/melcloud_home/test_sensor.py @@ -20,6 +20,7 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None: @pytest.mark.usefixtures("mock_melcloud_client") +@pytest.mark.freeze_time("2026-06-08 12:00:00+00:00") async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/melnor/snapshots/test_init.ambr b/tests/components/melnor/snapshots/test_init.ambr index 575043cb8cdc..def342f7dea0 100644 --- a/tests/components/melnor/snapshots/test_init.ambr +++ b/tests/components/melnor/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'test_melnor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/miele/snapshots/test_init.ambr b/tests/components/miele/snapshots/test_init.ambr index b5b830f4e5cb..0071bfd10e81 100644 --- a/tests/components/miele/snapshots/test_init.ambr +++ b/tests/components/miele/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'FNS 28463 E ed/', 'name': 'Freezer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'Dummy_Appliance_1', 'sw_version': '31.17', 'via_device_id': None, diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index b700f021b0da..129137420693 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -1,183 +1,73 @@ -"""Tests for the Mikrotik component.""" +"""Tests for the Mikrotik integration.""" from typing import Any from unittest.mock import patch from homeassistant.components import mikrotik -from homeassistant.components.mikrotik.const import ( - CONF_ARP_PING, - CONF_DETECTION_TIME, - CONF_FORCE_DHCP, - DEFAULT_DETECTION_TIME, -) -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.components.mikrotik.const import DOMAIN from homeassistant.core import HomeAssistant +from .const import ( + ARP_DATA, + DHCP_DATA, + HEALTH_DATA, + MOCK_DATA, + SYSTEM_DATA, + TEST_FIRMWARE, + TEST_MODEL, + TEST_SERIAL_NUMBER, + WIFIWAVE2_DATA, + WIRELESS_DATA, +) + from tests.common import MockConfigEntry -MOCK_DATA = { - CONF_NAME: "Mikrotik", - CONF_HOST: "0.0.0.0", - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - CONF_PORT: 8278, - CONF_VERIFY_SSL: False, -} -MOCK_OPTIONS = { - CONF_ARP_PING: False, - CONF_FORCE_DHCP: False, - CONF_DETECTION_TIME: DEFAULT_DETECTION_TIME, -} - -DEVICE_1_DHCP = { - ".id": "*1A", - "address": "0.0.0.1", - "mac-address": "00:00:00:00:00:01", - "active-address": "0.0.0.1", - "host-name": "Device_1", - "comment": "Mobile", -} -DEVICE_2_DHCP = { - ".id": "*1B", - "address": "0.0.0.2", - "mac-address": "00:00:00:00:00:02", - "active-address": "0.0.0.2", - "host-name": "Device_2", - "comment": "PC", -} -DEVICE_3_DHCP_NUMERIC_NAME = { - ".id": "*1C", - "address": "0.0.0.3", - "mac-address": "00:00:00:00:00:03", - "active-address": "0.0.0.3", - "host-name": 123, - "comment": "Mobile", -} -DEVICE_4_DHCP = { - ".id": "*F7", - "address": "0.0.0.4", - "mac-address": "00:00:00:00:00:04", - "active-address": "0.0.0.4", - "host-name": "Device_4", - "comment": "Wifiwave2 device", -} -DEVICE_1_WIRELESS = { - ".id": "*264", - "interface": "wlan1", - "mac-address": "00:00:00:00:00:01", - "ap": False, - "wds": False, - "bridge": False, - "rx-rate": "72.2Mbps-20MHz/1S/SGI", - "tx-rate": "72.2Mbps-20MHz/1S/SGI", - "packets": "59542,17464", - "bytes": "17536671,2966351", - "frames": "59542,17472", - "frame-bytes": "17655785,2862445", - "hw-frames": "78935,38395", - "hw-frame-bytes": "25636019,4063445", - "tx-frames-timed-out": 0, - "uptime": "5h49m36s", - "last-activity": "170ms", - "signal-strength": "-62@1Mbps", - "signal-to-noise": 52, - "signal-strength-ch0": -63, - "signal-strength-ch1": -69, - "strength-at-rates": ( - "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms," - "-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms," - "-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms," - "-65@HT20-7 5s680ms" - ), - "tx-ccq": 93, - "p-throughput": 54928, - "last-ip": "0.0.0.1", - "802.1x-port-enabled": True, - "authentication-type": "wpa2-psk", - "encryption": "aes-ccm", - "group-encryption": "aes-ccm", - "management-protection": False, - "wmm-enabled": True, - "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", -} - -DEVICE_2_WIRELESS = { - **DEVICE_1_WIRELESS, - ".id": "*265", - "mac-address": "00:00:00:00:00:02", - "last-ip": "0.0.0.2", -} -DEVICE_3_WIRELESS = { - **DEVICE_1_WIRELESS, - ".id": "*266", - "mac-address": "00:00:00:00:00:03", - "last-ip": "0.0.0.3", -} - -DEVICE_4_WIFIWAVE2 = { - ".id": "*F7", - "interface": "wifi1", - "ssid": "test-ssid", - "mac-address": "00:00:00:00:00:04", - "uptime": "2d15h28m27s", - "signal": -47, - "tx-rate": 54000000, - "rx-rate": 54000000, - "packets": "17748,18516", - "bytes": "1851474,2037295", - "tx-bits-per-second": 0, - "rx-bits-per-second": 0, - "authorized": True, -} - -DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] - -WIRELESS_DATA = [DEVICE_1_WIRELESS] -WIFIWAVE2_DATA = [DEVICE_4_WIFIWAVE2] - -ARP_DATA = [ - { - ".id": "*1", - "address": "0.0.0.1", - "mac-address": "00:00:00:00:00:01", - "interface": "bridge", - "published": False, - "invalid": False, - "DHCP": True, - "dynamic": True, - "complete": True, - "disabled": False, - }, - { - ".id": "*2", - "address": "0.0.0.2", - "mac-address": "00:00:00:00:00:02", - "interface": "bridge", - "published": False, - "invalid": False, - "DHCP": True, - "dynamic": True, - "complete": True, - "disabled": False, - }, -] +def _build_command_responses( + *, + support_wireless: bool, + support_wifiwave2: bool, + dhcp_data: list[dict[str, Any]], + wireless_data: list[dict[str, Any]], + wifiwave2_data: list[dict[str, Any]], + health_data: list[dict[str, Any]], + system_data: list[dict[str, Any]], +) -> dict[str, Any]: + """Build mocked service responses for the Mikrotik coordinator.""" + return { + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IDENTITY]: [ + {"name": "Mikrotik"} + ], + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.INFO]: [ + { + "model": TEST_MODEL, + "current-firmware": TEST_FIRMWARE, + "serial-number": TEST_SERIAL_NUMBER, + } + ], + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_CAPSMAN]: [], + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: support_wireless, + mikrotik.const.MIKROTIK_SERVICES[ + mikrotik.const.IS_WIFIWAVE2 + ]: support_wifiwave2, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFI]: False, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: dhcp_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: wireless_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIFIWAVE2]: wifiwave2_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: ARP_DATA, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.HEALTH]: health_data, + mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.SYSTEM]: system_data, + } -async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: - """Set up Mikrotik integration successfully.""" - support_wireless: bool = kwargs.get("support_wireless", True) - support_wifiwave2: bool = kwargs.get("support_wifiwave2", False) - dhcp_data: list[dict[str, Any]] = kwargs.get("dhcp_data", DHCP_DATA) - wireless_data: list[dict[str, Any]] = kwargs.get("wireless_data", WIRELESS_DATA) - wifiwave2_data: list[dict[str, Any]] = kwargs.get("wifiwave2_data", WIFIWAVE2_DATA) +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + *, + command_responses: dict[str, Any], +) -> None: + """Set up the component with mocked Mikrotik command responses.""" + config_entry.add_to_hass(hass) def mock_command( self, @@ -185,35 +75,54 @@ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: params: dict[str, Any] | None = None, suppress_errors: bool = False, ) -> Any: - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: - return support_wireless - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFIWAVE2]: - return support_wifiwave2 - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: - return dhcp_data - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: - return wireless_data - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIFIWAVE2]: - return wifiwave2_data - if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: - return ARP_DATA - return {} - - options: dict[str, Any] = {} - if "force_dhcp" in kwargs: - options.update({"force_dhcp": True}) - - if "arp_ping" in kwargs: - options.update({"arp_ping": True}) - - config_entry = MockConfigEntry( - domain=mikrotik.DOMAIN, data=MOCK_DATA, options=options - ) - config_entry.add_to_hass(hass) + return command_responses.get(cmd, {}) with ( patch("librouteros.connect"), patch.object(mikrotik.coordinator.MikrotikData, "command", new=mock_command), ): - await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def create_mock_config_entry( + *, + data: dict[str, Any] | None = None, + options: dict[str, Any] | None = None, + domain: str = DOMAIN, +) -> MockConfigEntry: + """Create a Mikrotik test config entry with optional overrides.""" + return MockConfigEntry( + domain=domain, + data=data or MOCK_DATA, + options=options or {}, + version=1, + minor_version=1, + ) + + +async def setup_mikrotik_entry( + hass: HomeAssistant, + **kwargs: Any, +) -> MockConfigEntry: + """Set up a Mikrotik config entry with defaults that tests can override.""" + options = dict(kwargs.get("options", {})) + if "force_dhcp" in kwargs: + options["force_dhcp"] = True + if "arp_ping" in kwargs: + options["arp_ping"] = True + + config_entry = create_mock_config_entry(options=options) + + command_responses = _build_command_responses( + support_wireless=kwargs.get("support_wireless", True), + support_wifiwave2=kwargs.get("support_wifiwave2", False), + dhcp_data=kwargs.get("dhcp_data", DHCP_DATA), + wireless_data=kwargs.get("wireless_data", WIRELESS_DATA), + wifiwave2_data=kwargs.get("wifiwave2_data", WIFIWAVE2_DATA), + health_data=kwargs.get("health_data", HEALTH_DATA), + system_data=kwargs.get("system_data", SYSTEM_DATA), + ) + + await setup_integration(hass, config_entry, command_responses=command_responses) + return config_entry diff --git a/tests/components/mikrotik/conftest.py b/tests/components/mikrotik/conftest.py index 2152786deb6f..e9dfe8fb3d7c 100644 --- a/tests/components/mikrotik/conftest.py +++ b/tests/components/mikrotik/conftest.py @@ -1,12 +1,21 @@ -"""Config tests Mikrotik.""" +"""Mikrotik test configuration.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import MagicMock, patch import pytest +from . import create_mock_config_entry + + +@pytest.fixture +def mock_config_entry(): + """Create Mikrotik config entries with optional overrides.""" + return create_mock_config_entry + @pytest.fixture(autouse=True) -def mock_api(): +def mock_api() -> Generator[MagicMock]: """Mock api.""" with ( patch("librouteros.create_transport"), diff --git a/tests/components/mikrotik/const.py b/tests/components/mikrotik/const.py new file mode 100644 index 000000000000..761a9b986318 --- /dev/null +++ b/tests/components/mikrotik/const.py @@ -0,0 +1,184 @@ +"""Constants for Mikrotik tests.""" + +from homeassistant.components.mikrotik.const import ( + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_DETECTION_TIME, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +TEST_MODEL = "RB5009" +TEST_FIRMWARE = "7.18.2" +TEST_SERIAL_NUMBER = "ABC123" + +MOCK_DATA = { + CONF_NAME: "Mikrotik", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, +} + +MOCK_OPTIONS = { + CONF_ARP_PING: False, + CONF_FORCE_DHCP: False, + CONF_DETECTION_TIME: DEFAULT_DETECTION_TIME, +} + +DEVICE_1_DHCP = { + ".id": "*1A", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "active-address": "0.0.0.1", + "host-name": "Device_1", + "comment": "Mobile", +} +DEVICE_2_DHCP = { + ".id": "*1B", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "active-address": "0.0.0.2", + "host-name": "Device_2", + "comment": "PC", +} +DEVICE_3_DHCP_NUMERIC_NAME = { + ".id": "*1C", + "address": "0.0.0.3", + "mac-address": "00:00:00:00:00:03", + "active-address": "0.0.0.3", + "host-name": 123, + "comment": "Mobile", +} +DEVICE_4_DHCP = { + ".id": "*F7", + "address": "0.0.0.4", + "mac-address": "00:00:00:00:00:04", + "active-address": "0.0.0.4", + "host-name": "Device_4", + "comment": "Wifiwave2 device", +} +DEVICE_1_WIRELESS = { + ".id": "*264", + "interface": "wlan1", + "mac-address": "00:00:00:00:00:01", + "ap": False, + "wds": False, + "bridge": False, + "rx-rate": "72.2Mbps-20MHz/1S/SGI", + "tx-rate": "72.2Mbps-20MHz/1S/SGI", + "packets": "59542,17464", + "bytes": "17536671,2966351", + "frames": "59542,17472", + "frame-bytes": "17655785,2862445", + "hw-frames": "78935,38395", + "hw-frame-bytes": "25636019,4063445", + "tx-frames-timed-out": 0, + "uptime": "5h49m36s", + "last-activity": "170ms", + "signal-strength": "-62@1Mbps", + "signal-to-noise": 52, + "signal-strength-ch0": -63, + "signal-strength-ch1": -69, + "strength-at-rates": ( + "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms," + "-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms," + "-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms," + "-65@HT20-7 5s680ms" + ), + "tx-ccq": 93, + "p-throughput": 54928, + "last-ip": "0.0.0.1", + "802.1x-port-enabled": True, + "authentication-type": "wpa2-psk", + "encryption": "aes-ccm", + "group-encryption": "aes-ccm", + "management-protection": False, + "wmm-enabled": True, + "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} + +DEVICE_2_WIRELESS = { + **DEVICE_1_WIRELESS, + ".id": "*265", + "mac-address": "00:00:00:00:00:02", + "last-ip": "0.0.0.2", +} +DEVICE_3_WIRELESS = { + **DEVICE_1_WIRELESS, + ".id": "*266", + "mac-address": "00:00:00:00:00:03", + "last-ip": "0.0.0.3", +} + +DEVICE_4_WIFIWAVE2 = { + ".id": "*F7", + "interface": "wifi1", + "ssid": "test-ssid", + "mac-address": "00:00:00:00:00:04", + "uptime": "2d15h28m27s", + "signal": -47, + "tx-rate": 54000000, + "rx-rate": 54000000, + "packets": "17748,18516", + "bytes": "1851474,2037295", + "tx-bits-per-second": 0, + "rx-bits-per-second": 0, + "authorized": True, +} + +DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] + +WIRELESS_DATA = [DEVICE_1_WIRELESS] +WIFIWAVE2_DATA = [DEVICE_4_WIFIWAVE2] + +ARP_DATA = [ + { + ".id": "*1", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, + { + ".id": "*2", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, +] + +HEALTH_DATA = [ + {"name": "voltage", "value": 24.2}, + {"name": "temperature", "value": 50.0}, +] + +SYSTEM_DATA = [ + { + "cpu-load": 15, + "total-memory": 1000, + "free-memory": 200, + "total-hdd-space": 100, + "free-hdd-space": 25, + "uptime": "1w2d3h4m5s", + } +] diff --git a/tests/components/mikrotik/snapshots/test_sensor.ambr b/tests/components/mikrotik/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..97dc60967ef8 --- /dev/null +++ b/tests/components/mikrotik/snapshots/test_sensor.ambr @@ -0,0 +1,333 @@ +# serializer version: 1 +# name: test_sensor_entities_created[sensor.mikrotik_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mikrotik_cpu_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'CPU usage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CPU usage', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cpu_load', + 'unique_id': 'ABC123_cpu-load', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mikrotik CPU usage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_disk_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mikrotik_disk_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Disk usage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk usage', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disk_usage', + 'unique_id': 'ABC123_disk-usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_disk_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mikrotik Disk usage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_disk_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.0', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mikrotik_memory_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Memory usage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Memory usage', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'memory_usage', + 'unique_id': 'ABC123_memory-usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_memory_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mikrotik Memory usage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_memory_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.mikrotik_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Mikrotik Temperature', + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.mikrotik_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Uptime', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'uptime', + : 'Mikrotik Uptime', + }), + 'context': , + 'entity_id': 'sensor.mikrotik_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-12-23T08:55:55+00:00', + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.mikrotik_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'mikrotik', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC123_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_created[sensor.mikrotik_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Mikrotik Voltage', + : , + }), + 'context': , + 'entity_id': 'sensor.mikrotik_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.2', + }) +# --- diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index f65c7f0dfc5b..923d1301d7d2 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -23,8 +23,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - DEMO_USER_INPUT = { CONF_HOST: "0.0.0.0", CONF_USERNAME: "username", @@ -92,9 +90,9 @@ async def test_flow_works(hass: HomeAssistant, api) -> None: assert result["data"][CONF_PORT] == 8278 -async def test_options(hass: HomeAssistant, api) -> None: +async def test_options(hass: HomeAssistant, api, mock_config_entry) -> None: """Test updating options.""" - entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = mock_config_entry(data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) @@ -122,10 +120,12 @@ async def test_options(hass: HomeAssistant, api) -> None: } -async def test_host_already_configured(hass: HomeAssistant, auth_error) -> None: +async def test_host_already_configured( + hass: HomeAssistant, auth_error, mock_config_entry +) -> None: """Test host already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = mock_config_entry(data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -168,12 +168,9 @@ async def test_wrong_credentials(hass: HomeAssistant, auth_error) -> None: } -async def test_reauth_success(hass: HomeAssistant, api) -> None: +async def test_reauth_success(hass: HomeAssistant, api, mock_config_entry) -> None: """Test we can reauth.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=DEMO_USER_INPUT, - ) + entry = mock_config_entry(data=DEMO_USER_INPUT) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -196,12 +193,11 @@ async def test_reauth_success(hass: HomeAssistant, api) -> None: assert result2["reason"] == "reauth_successful" -async def test_reauth_failed(hass: HomeAssistant, auth_error) -> None: +async def test_reauth_failed( + hass: HomeAssistant, auth_error, mock_config_entry +) -> None: """Test reauth fails due to wrong password.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=DEMO_USER_INPUT, - ) + entry = mock_config_entry(data=DEMO_USER_INPUT) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -222,12 +218,11 @@ async def test_reauth_failed(hass: HomeAssistant, auth_error) -> None: } -async def test_reauth_failed_conn_error(hass: HomeAssistant, conn_error) -> None: +async def test_reauth_failed_conn_error( + hass: HomeAssistant, conn_error, mock_config_entry +) -> None: """Test reauth failed due to connection error.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=DEMO_USER_INPUT, - ) + entry = mock_config_entry(data=DEMO_USER_INPUT) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index d2645916b390..8ea31c6105c3 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from . import ( +from . import setup_mikrotik_entry +from .const import ( DEVICE_2_WIRELESS, DEVICE_3_DHCP_NUMERIC_NAME, DEVICE_3_WIRELESS, @@ -22,18 +23,19 @@ from . import ( MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA, - setup_mikrotik_entry, ) -from tests.common import MockConfigEntry, async_fire_time_changed, patch +from tests.common import async_fire_time_changed, patch @pytest.fixture def mock_device_registry_devices( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry, ) -> None: """Create device registry devices so the device tracker entities are enabled.""" - config_entry = MockConfigEntry(domain="something_else") + config_entry = mock_config_entry(domain="something_else", data={}) config_entry.add_to_hass(hass) for idx, device in enumerate( @@ -214,11 +216,15 @@ async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) async def test_restoring_devices( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry, ) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" - config_entry = MockConfigEntry( - domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS + config_entry = mock_config_entry( + domain=mikrotik.DOMAIN, + data=MOCK_DATA, + options=MOCK_OPTIONS, ) config_entry.add_to_hass(hass) diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 722a243794a8..52d2f1b07d5f 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -4,33 +4,24 @@ from unittest.mock import MagicMock from librouteros.exceptions import ConnectionClosed, LibRouterosError -from homeassistant.components import mikrotik from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import MOCK_DATA - -from tests.common import MockConfigEntry +from . import setup_integration -async def test_successful_config_entry(hass: HomeAssistant) -> None: +async def test_successful_config_entry(hass: HomeAssistant, mock_config_entry) -> None: """Test config entry successful setup.""" - entry = MockConfigEntry( - domain=mikrotik.DOMAIN, - data=MOCK_DATA, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) + entry = mock_config_entry() + await setup_integration(hass, entry, command_responses={}) assert entry.state is ConfigEntryState.LOADED -async def test_hub_connection_error(hass: HomeAssistant, mock_api: MagicMock) -> None: +async def test_hub_connection_error( + hass: HomeAssistant, mock_api: MagicMock, mock_config_entry +) -> None: """Test setup fails due to connection error.""" - entry = MockConfigEntry( - domain=mikrotik.DOMAIN, - data=MOCK_DATA, - ) + entry = mock_config_entry() entry.add_to_hass(hass) mock_api.side_effect = ConnectionClosed @@ -41,13 +32,10 @@ async def test_hub_connection_error(hass: HomeAssistant, mock_api: MagicMock) -> async def test_hub_authentication_error( - hass: HomeAssistant, mock_api: MagicMock + hass: HomeAssistant, mock_api: MagicMock, mock_config_entry ) -> None: """Test setup fails due to authentication error.""" - entry = MockConfigEntry( - domain=mikrotik.DOMAIN, - data=MOCK_DATA, - ) + entry = mock_config_entry() entry.add_to_hass(hass) mock_api.side_effect = LibRouterosError("invalid user name or password") @@ -57,16 +45,10 @@ async def test_hub_authentication_error( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry(hass: HomeAssistant, mock_config_entry) -> None: """Test unloading an entry.""" - entry = MockConfigEntry( - domain=mikrotik.DOMAIN, - data=MOCK_DATA, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = mock_config_entry() + await setup_integration(hass, entry, command_responses={}) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/mikrotik/test_sensor.py b/tests/components/mikrotik/test_sensor.py new file mode 100644 index 000000000000..4f0f2c17537f --- /dev/null +++ b/tests/components/mikrotik/test_sensor.py @@ -0,0 +1,115 @@ +"""Tests for the Mikrotik sensor platform.""" + +from unittest.mock import patch + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_mikrotik_entry + +from tests.common import snapshot_platform + + +@freeze_time("2026-01-01T12:00:00+00:00") +async def test_sensor_entities_created( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test Mikrotik sensor entities are created with expected values.""" + with patch("homeassistant.components.mikrotik.PLATFORMS", [Platform.SENSOR]): + config_entry = await setup_mikrotik_entry(hass) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_sensor_wrong_data(hass: HomeAssistant) -> None: + """Test Mikrotik sensor entities handle missing data gracefully.""" + await setup_mikrotik_entry( + hass, + health_data=[ + {"name": "voltage", "value": 24.2}, + ], + system_data=[ + { + "cpu-load": 15, + "total-memory": 0, + "free-memory": 200, + "total-hdd-space": 0, + "free-hdd-space": 25, + "uptime": None, + } + ], + ) + + assert (state := hass.states.get("sensor.mikrotik_voltage")) + assert state.state == "24.2" + + assert (state := hass.states.get("sensor.mikrotik_temperature")) is None + + assert (state := hass.states.get("sensor.mikrotik_cpu_usage")) + assert state.state == "15" + + assert (state := hass.states.get("sensor.mikrotik_memory_usage")) + assert state.state == STATE_UNKNOWN + + assert (state := hass.states.get("sensor.mikrotik_disk_usage")) + assert state.state == STATE_UNKNOWN + + assert (state := hass.states.get("sensor.mikrotik_uptime")) is None + + +@pytest.mark.parametrize( + "uptime_api", + [ + pytest.param("3u2h3m4s", id="invalid_unit"), + pytest.param("2h30", id="missing_unit"), + ], +) +@freeze_time("2026-01-01T12:00:00+00:00") +async def test_sensor_bad_uptime_data( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + uptime_api: str, +) -> None: + """Test Mikrotik sensor entities handle missing data gracefully.""" + + await setup_mikrotik_entry( + hass, + system_data=[ + { + "cpu-load": 15, + "total-memory": 0, + "free-memory": 200, + "total-hdd-space": 0, + "free-hdd-space": 25, + "uptime": uptime_api, + } + ], + ) + + assert f"Unknown uptime format: {uptime_api}" in caplog.text + + assert (state := hass.states.get("sensor.mikrotik_uptime")) + assert state.state == STATE_UNKNOWN + + +async def test_sensor_no_data(hass: HomeAssistant) -> None: + """Test Mikrotik sensor entities handle missing data gracefully.""" + await setup_mikrotik_entry( + hass, + health_data=[], + system_data=[], + ) + + assert hass.states.get("sensor.mikrotik_voltage") is None + assert hass.states.get("sensor.mikrotik_temperature") is None + assert hass.states.get("sensor.mikrotik_cpu_usage") is None + assert hass.states.get("sensor.mikrotik_memory_usage") is None + assert hass.states.get("sensor.mikrotik_disk_usage") is None + assert hass.states.get("sensor.mikrotik_uptime") is None diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index 7664a1b9bdc1..c5cb4abb6660 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -274,16 +274,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d expected_helper_device_id: str | None, expected_events: list[str], ) -> None: - """Test config entry removed when the source entity is removed.""" + """Test the source entity is removed but the source device is not removed.""" source_entity_entry = entity_registry.async_get(source_entity_id) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() @@ -297,15 +290,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) - # Remove the source entity's config entry from the device, this removes the - # source entity + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.mold_indicator.async_unload_entry", wraps=mold_indicator.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id - ) + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -314,6 +304,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") assert mold_indicator_entity_entry.device_id == expected_helper_device_id + # Check that the source device is not removed + assert device_registry.async_get(source_device.id) is not None + # Check if the mold_indicator config entry is not in the device source_device = device_registry.async_get(source_device.id) assert mold_indicator_config_entry.entry_id not in source_device.config_entries @@ -533,7 +526,7 @@ async def test_migration_1_1( indoor_temperature_entity_entry: er.RegistryEntry, outdoor_temperature_entity_entry: er.RegistryEntry, ) -> None: - """Test migration from v1.1 removes mold_indicator config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" mold_indicator_config_entry = MockConfigEntry( data={}, @@ -551,25 +544,15 @@ async def test_migration_1_1( ) mold_indicator_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - indoor_humidity_device.id, - add_config_entry_id=mold_indicator_config_entry.entry_id, - ) - - # Check preconditions - switch_device = device_registry.async_get(indoor_humidity_device.id) - assert mold_indicator_config_entry.entry_id in switch_device.config_entries - await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) await hass.async_block_till_done() assert mold_indicator_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device - switch_device = device_registry.async_get(switch_device.id) - assert mold_indicator_config_entry.entry_id not in switch_device.config_entries + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device + source_device = device_registry.async_get(indoor_humidity_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") assert ( mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id diff --git a/tests/components/monarch_money/snapshots/test_sensor.ambr b/tests/components/monarch_money/snapshots/test_sensor.ambr index a73e71086ddf..19322633e2c1 100644 --- a/tests/components/monarch_money/snapshots/test_sensor.ambr +++ b/tests/components/monarch_money/snapshots/test_sensor.ambr @@ -1020,7 +1020,7 @@ # name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_data_age-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via vin_audit', : 'timestamp', : 'VinAudit 2050 Toyota RAV8 Data age', }), @@ -1074,7 +1074,7 @@ # name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_value-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via vin_audit', : 'monetary', : 'https://api.monarchmoney.com/cdn-cgi/image/width=128/images/institution/159427559853802644', : 'VinAudit 2050 Toyota RAV8 Value', @@ -1131,7 +1131,7 @@ # name: test_all_entities[sensor.zillow_house_balance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via zillow', : 'monetary', : 'data:image/png;base64,base64Nonce', : 'Zillow House Balance', @@ -1186,7 +1186,7 @@ # name: test_all_entities[sensor.zillow_house_data_age-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 'Data provided by Monarch Money API via Manual entry', + : 'Data provided by Monarch Money API via zillow', : 'timestamp', : 'Zillow House Data age', }), diff --git a/tests/components/monarch_money/test_config_flow.py b/tests/components/monarch_money/test_config_flow.py index d7a23e2e385c..b33b5f0ff676 100644 --- a/tests/components/monarch_money/test_config_flow.py +++ b/tests/components/monarch_money/test_config_flow.py @@ -134,7 +134,9 @@ async def test_form_mfa( assert result["step_id"] == "user" # Add a bad MFA Code response - mock_config_api.return_value.multi_factor_authenticate.side_effect = KeyError + mock_config_api.return_value.multi_factor_authenticate.side_effect = ( + LoginFailedException("Bad MFA code") + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py index 2a353bb60ba5..149e423be91d 100644 --- a/tests/components/moon/test_sensor.py +++ b/tests/components/moon/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.moon.sensor import ( +from homeassistant.components.moon.helpers import ( STATE_FIRST_QUARTER, STATE_FULL_MOON, STATE_LAST_QUARTER, @@ -47,7 +47,7 @@ async def test_moon_day( mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.moon.sensor.moon.phase", return_value=moon_value + "homeassistant.components.moon.helpers.moon.phase", return_value=moon_value ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/moon/test_trigger.py b/tests/components/moon/test_trigger.py new file mode 100644 index 000000000000..266fb2dbfa34 --- /dev/null +++ b/tests/components/moon/test_trigger.py @@ -0,0 +1,103 @@ +"""Tests for the moon triggers.""" + +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + +_PHASE = "homeassistant.components.moon.helpers.moon.phase" + + +@pytest.fixture(autouse=True) +async def setup_moon(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Set up the moon integration so its trigger platform is available.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + +async def _arm(hass: HomeAssistant, options: dict[str, Any] | None = None) -> None: + """Set up an automation with the moon phase_changed trigger.""" + trigger: dict[str, Any] = {"platform": "moon.phase_changed"} + if options is not None: + trigger["options"] = options + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": trigger, + "action": { + "service": "test.automation", + "data_template": { + "phase": "{{ trigger.phase }}", + "previous_phase": "{{ trigger.previous_phase }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + +def _next_local_midnight() -> datetime: + """Return the next local midnight, when the phase trigger re-evaluates.""" + return dt_util.start_of_local_day() + timedelta(days=1) + + +async def test_phase_changed_fires_on_any_change( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the trigger fires on every phase change when unfiltered.""" + with patch(_PHASE, return_value=0.0): + await _arm(hass) + assert len(service_calls) == 0 + + with patch(_PHASE, return_value=14.0): + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert service_calls[0].data["phase"] == "full_moon" + assert service_calls[0].data["previous_phase"] == "new_moon" + + +async def test_phase_changed_ignores_same_phase( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test the trigger does not fire when the phase is unchanged.""" + with patch(_PHASE, return_value=14.0): + await _arm(hass) + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == 0 + + +@pytest.mark.parametrize( + ("new_value", "expected_calls"), + [(14.0, 1), (5.0, 0)], +) +async def test_phase_changed_with_phase_filter( + hass: HomeAssistant, + service_calls: list[ServiceCall], + new_value: float, + expected_calls: int, +) -> None: + """Test the trigger only fires for the configured phase.""" + with patch(_PHASE, return_value=0.0): + await _arm(hass, options={"phase": "full_moon"}) + + with patch(_PHASE, return_value=new_value): + async_fire_time_changed(hass, _next_local_midnight()) + await hass.async_block_till_done() + + assert len(service_calls) == expected_calls diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 1d64f4742af1..26abd4018071 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -70,6 +70,21 @@ from tests.typing import ( WebSocketGenerator, ) + +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + TEST_SINGLE_CONFIGS = [ ( "homeassistant/device_automation/0AFFD2/bla1/config", @@ -2047,15 +2062,24 @@ async def test_cleanup_device_multiple_config_entries( ) await hass.async_block_till_done() - # Verify device and registry entries are created - device_entry = device_registry.async_get_device( - connections={("mac", "12:34:56:AB:CD:EF")} - ) - assert device_entry is not None - assert device_entry.config_entries == { + # Verify device and registry entries are created. Identifiers and connections are + # unique per config entry, so MQTT discovery creates a separate device owned by the + # MQTT config entry, sharing the connection with the pre-existing device + mqtt_device_entry = _get_device_for_config_entry( + device_registry, mqtt_config_entry.entry_id, - config_entry.entry_id, - } + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + assert mqtt_device_entry is not None + assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + is not None + ) entity_entry = entity_registry.async_get("sensor.mqtt_sensor") assert entity_entry is not None @@ -2065,7 +2089,7 @@ async def test_cleanup_device_multiple_config_entries( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] response = await ws_client.remove_device( - device_entry.id, mqtt_config_entry.entry_id + mqtt_device_entry.id, mqtt_config_entry.entry_id ) assert response["success"] @@ -2165,15 +2189,24 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) await hass.async_block_till_done() - # Verify device and registry entries are created - device_entry = device_registry.async_get_device( - connections={("mac", "12:34:56:AB:CD:EF")} - ) - assert device_entry is not None - assert device_entry.config_entries == { + # Verify device and registry entries are created. Identifiers and connections are + # unique per config entry, so MQTT discovery creates a separate device owned by the + # MQTT config entry, sharing the connection with the pre-existing device + mqtt_device_entry = _get_device_for_config_entry( + device_registry, mqtt_config_entry.entry_id, - config_entry.entry_id, - } + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + assert mqtt_device_entry is not None + assert mqtt_device_entry.config_entries == {mqtt_config_entry.entry_id} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + connections={("mac", "12:34:56:AB:CD:EF")}, + ) + is not None + ) entity_entry = entity_registry.async_get("sensor.mqtt_sensor") assert entity_entry is not None diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 1bf8a425da56..f5f4e52ce488 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -46,6 +46,20 @@ DEFAULT_TAG_SCAN_JSON = ( ) +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + @pytest.mark.no_fail_on_log_exception async def test_discover_bad_tag( hass: HomeAssistant, @@ -570,24 +584,45 @@ async def test_cleanup_tag( async_fire_mqtt_message(hass, "homeassistant/tag/bla2/config", data2) await hass.async_block_till_done() - # Verify device registry entries are created - device_entry1 = device_registry.async_get_device( - identifiers={("mqtt", "helloworld")} + # Verify device registry entries are created. Identifiers are unique per config + # entry, so the test config entry and MQTT get separate "helloworld" devices + device_entry1 = _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + identifiers={("mqtt", "helloworld")}, ) assert device_entry1 is not None - assert device_entry1.config_entries == {config_entry.entry_id, mqtt_entry.entry_id} + assert device_entry1.config_entries == {config_entry.entry_id} + mqtt_device_entry1 = _get_device_for_config_entry( + device_registry, + mqtt_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + assert mqtt_device_entry1 is not None + assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id} device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None - # Remove other config entry from the device + # Removing the test config entry deletes its device; the MQTT device is untouched + # and MQTT does not clear its discovery topic device_registry.async_update_device( device_entry1.id, remove_config_entry_id=config_entry.entry_id ) - device_entry1 = device_registry.async_get_device( - identifiers={("mqtt", "helloworld")} + assert ( + _get_device_for_config_entry( + device_registry, + config_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + is None ) - assert device_entry1 is not None - assert device_entry1.config_entries == {mqtt_entry.entry_id} + mqtt_device_entry1 = _get_device_for_config_entry( + device_registry, + mqtt_entry.entry_id, + identifiers={("mqtt", "helloworld")}, + ) + assert mqtt_device_entry1 is not None + assert mqtt_device_entry1.config_entries == {mqtt_entry.entry_id} device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None mqtt_mock.async_publish.assert_not_called() @@ -595,7 +630,7 @@ async def test_cleanup_tag( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] response = await ws_client.remove_device( - device_entry1.id, mqtt_config_entry.entry_id + mqtt_device_entry1.id, mqtt_config_entry.entry_id ) assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 3b94bd94c983..00225be7b2ff 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -15,9 +15,12 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths +from homeassistant.auth.models import Credentials from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEEK_POSITION, @@ -28,6 +31,7 @@ from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, + SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, @@ -72,7 +76,7 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -82,7 +86,7 @@ from .common import ( trigger_subscription_callback, ) -from tests.common import AsyncMock +from tests.common import AsyncMock, MockUser MOCK_TRACK = Track( item_id="1", @@ -824,6 +828,149 @@ async def test_media_player_play_media_action( ) +async def _add_ha_user(hass: HomeAssistant, login_username: str | None) -> MockUser: + """Add a Home Assistant user, optionally with a local login username.""" + user = MockUser(is_owner=True).add_to_hass(hass) + if login_username is not None: + await hass.auth.async_link_user( + user, + Credentials( + auth_provider_type="homeassistant", + auth_provider_id=None, + data={"username": login_username}, + is_new=False, + ), + ) + return user + + +@pytest.mark.parametrize( + ("login_username", "expected_username"), + [ + ("user_user", "user_user"), + ("USER_USER", "user_user"), + ("user_admin", "user_admin"), + ("does_not_exist", None), + ("user_disabled", None), + ("party_guest", None), + (None, None), + ], + ids=[ + "match", + "match_case_insensitive", + "match_admin", + "no_match", + "disabled_user", + "guest_user", + "no_login_username", + ], +) +async def test_media_player_play_media_default_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, + login_username: str | None, + expected_username: str | None, +) -> None: + """Test that play media defaults to the username of the calling user.""" + music_assistant_client.server_info.schema_version = 33 + music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + + user = await _add_ha_user(hass, login_username) + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + context=Context(user_id=user.id), + ) + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + username=expected_username, + sort_by=None, + ) + + +async def test_media_player_play_media_default_username_explicit_override( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that an explicit username takes precedence over the calling user.""" + music_assistant_client.server_info.schema_version = 33 + music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + + user = await _add_ha_user(hass, "user_user") + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_USERNAME: "user_admin", + }, + blocking=True, + context=Context(user_id=user.id), + ) + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + username="user_admin", + sort_by=None, + ) + + +async def test_media_player_standard_play_media_default_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test that the standard play_media action also defaults to the calling user.""" + music_assistant_client.server_info.schema_version = 33 + music_assistant_client.music.verify_item_uri = AsyncMock(return_value=True) + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + + user = await _add_ha_user(hass, "user_user") + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_CONTENT_ID: "spotify://track/1234", + ATTR_MEDIA_CONTENT_TYPE: "music", + }, + blocking=True, + context=Context(user_id=user.id), + ) + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + username="user_user", + sort_by=None, + ) + + async def test_media_player_play_announcement_action( hass: HomeAssistant, music_assistant_client: MagicMock, diff --git a/tests/components/mystrom/snapshots/test_init.ambr b/tests/components/mystrom/snapshots/test_init.ambr index 76e207a295af..0538ba5cb686 100644 --- a/tests/components/mystrom/snapshots/test_init.ambr +++ b/tests/components/mystrom/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_bulb DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'myStrom Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.58.0', 'via_device_id': None, diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr index 66b4c9efe356..3ff976524746 100644 --- a/tests/components/myuplink/snapshots/test_init.ambr +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[alfred-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Gotham City', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10001', 'sw_version': '9682R7A', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info[batman-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Batcave', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10002', 'sw_version': '9682R7B', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_info[robin-multi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Duckburg', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '10003', 'sw_version': '9682R7C', 'via_device_id': None, diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr index 96b3def82e77..7e2b4f2bb798 100644 --- a/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_init.ambr @@ -3,13 +3,13 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), 'disabled_by': None, - 'entry_type': None, + 'entry_type': , 'hw_version': None, 'id': , 'identifiers': set({ @@ -25,20 +25,19 @@ 'model_id': None, 'name': 'To work', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), 'disabled_by': None, - 'entry_type': None, + 'entry_type': , 'hw_version': None, 'id': , 'identifiers': set({ @@ -54,7 +53,6 @@ 'model_id': None, 'name': 'To home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/neopool/__init__.py b/tests/components/neopool/__init__.py new file mode 100644 index 000000000000..327810d614a2 --- /dev/null +++ b/tests/components/neopool/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the NeoPool integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the NeoPool 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/neopool/conftest.py b/tests/components/neopool/conftest.py new file mode 100644 index 000000000000..4ef31e0dfeb9 --- /dev/null +++ b/tests/components/neopool/conftest.py @@ -0,0 +1,175 @@ +"""Common fixtures for the NeoPool tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.neopool.const import ( + CURRENT_VERSION, + DEFAULT_PORT, + DEFAULT_UNIT_ID, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from tests.common import MockConfigEntry + +MOCK_HOST = "192.0.2.1" +MOCK_PORT = DEFAULT_PORT +MOCK_NAME = "Pool" +MOCK_SERIAL = "1234567890" + + +MOCK_POOL_DATA: dict[str, Any] = { + "MBF_POWER_MODULE_VERSION": 0x1234, + "MBF_PAR_VERSION": 0x100, + "MBF_PAR_MODEL": 0x0003, + "MBF_PAR_SERNUM": int(MOCK_SERIAL), + "MBF_PAR_FILTRATION_CONF": 1, + "MBF_PAR_FILT_GPIO": 1, + "MBF_PAR_LIGHTING_GPIO": 2, + "MBF_PAR_HEATING_GPIO": 3, + "MBF_PAR_PH_ACID_RELAY_GPIO": 4, + "MBF_PAR_PH_BASE_RELAY_GPIO": 5, + "MBF_PAR_RX_RELAY_GPIO": 6, + "MBF_PAR_CL_RELAY_GPIO": 7, + "MBF_PAR_CD_RELAY_GPIO": 0, + "MBF_PAR_UV_RELAY_GPIO": 1, + "MBF_PAR_FILTVALVE_GPIO": 1, + "MBF_PAR_FILTVALVE_ENABLE": 1, + "MBF_PAR_TEMPERATURE_ACTIVE": 1, + "MBF_PAR_UICFG_MACHINE": 0, + "MBF_PAR_RELAY_PH": 0, + "Hydrolysis module detected": True, + "Redox measurement module detected": True, + "pH measurement module detected": True, + "Chlorine measurement module detected": True, + "Conductivity measurement module detected": True, + "Ionization module detected": True, + "MBF_PAR_FILT_MODE": 0, + "filtration_mode": "manual", + "filtration_speed_state": "off", + "MBF_MEASURE_TEMPERATURE": 250, + "MBF_MEASURE_PH": 720, + "MBF_MEASURE_RX": 650, + "MBF_MEASURE_CL": 120, + "MBF_MEASURE_CONDUCTIVITY": 45, + "MBF_HIDRO_CURRENT": 70, + "MBF_HIDRO_VOLTAGE": 24, + "MBF_ION_CURRENT": 50, + "MBF_PAR_INTELLIGENT_INTERVALS": 4, + "MBF_PAR_INTELLIGENT_TT_NEXT_INTERVAL": 7200, + "MBF_PAR_FILTVALVE_REMAINING": 0, + "HIDRO_POLARITY": 0, + "ION_POLARITY": 0, + "PH_PUMP_STATUS": "off", + "HIDRO in Pol1": False, + "HIDRO in Pol2": False, + "HIDRO in dead time": False, + "ION in Pol1": False, + "ION in Pol2": False, + "ION in dead time": False, + "pH control module": True, + "pH pump active": False, + "pH acid pump active": False, + "Filtration Pump": False, + "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19, + "Pool Cover": 0, + "CELL_RUNTIME_TOTAL": 0x00010000, + "CELL_RUNTIME_PART": 0x00000E10, + "CELL_RUNTIME_POLA": 0x00000708, + "CELL_RUNTIME_POLB": 0x00000708, + "CELL_RUNTIME_POL_CHANGES": 0x00000007, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.neopool.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a config entry with a bare-serial unique_id.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCK_NAME, + unique_id=MOCK_SERIAL, + version=CURRENT_VERSION, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_NAME: MOCK_NAME, + "unit_id": DEFAULT_UNIT_ID, + "modbus_framer": "tcp", + }, + ) + + +@pytest.fixture +def mock_neopool_client() -> Generator[MagicMock]: + """Patch the NeoPoolModbusClient and return a configurable mock instance.""" + with ( + patch( + "homeassistant.components.neopool.NeoPoolModbusClient", + autospec=True, + ) as mock_client_cls, + patch( + "homeassistant.components.neopool.config_flow.async_probe_serial", + new=AsyncMock(return_value=MOCK_SERIAL), + ), + ): + mock_client = mock_client_cls.return_value + mock_client.async_read_all = AsyncMock(return_value=dict(MOCK_POOL_DATA)) + mock_client.close = AsyncMock() + yield mock_client + + +@pytest.fixture +def minimal_pool_data() -> dict[str, Any]: + """Pool data with all optional capability flags off. + + Used to drive the 'should-skip' branches for supported_fn gating. + """ + return { + "MBF_POWER_MODULE_VERSION": 0x1234, + "MBF_PAR_VERSION": 0x100, + "MBF_PAR_MODEL": 0, + "MBF_PAR_SERNUM": int(MOCK_SERIAL), + "MBF_PAR_FILTRATION_CONF": 0, + "MBF_PAR_FILT_GPIO": 0, + "MBF_PAR_LIGHTING_GPIO": 0, + "MBF_PAR_HEATING_GPIO": 0, + "MBF_PAR_PH_ACID_RELAY_GPIO": 0, + "MBF_PAR_PH_BASE_RELAY_GPIO": 0, + "MBF_PAR_RX_RELAY_GPIO": 0, + "MBF_PAR_CL_RELAY_GPIO": 0, + "MBF_PAR_CD_RELAY_GPIO": 0, + "MBF_PAR_UV_RELAY_GPIO": 0, + "MBF_PAR_FILTVALVE_GPIO": 0, + "MBF_PAR_FILTVALVE_ENABLE": 0, + "MBF_PAR_TEMPERATURE_ACTIVE": 0, + "Hydrolysis module detected": False, + "Redox measurement module detected": False, + "pH measurement module detected": False, + "MBF_PAR_FILT_MODE": 0, + "filtration_mode": "manual", + "filtration_speed_state": "off", + "Filtration Pump": False, + } + + +@pytest.fixture +def mock_socket_connection() -> Generator[AsyncMock]: + """Patch the lib probe in config_flow so we don't hit the network.""" + with patch( + "homeassistant.components.neopool.config_flow.async_probe_serial", + new=AsyncMock(return_value=MOCK_SERIAL), + ) as mock: + yield mock diff --git a/tests/components/neopool/snapshots/test_sensor.ambr b/tests/components/neopool/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..e830f3ba5cbe --- /dev/null +++ b/tests/components/neopool/snapshots/test_sensor.ambr @@ -0,0 +1,1379 @@ +# serializer version: 1 +# name: test_all_entities[sensor.neopool_backwash_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_backwash_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backwash time remaining', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backwash time remaining', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtvalve_remaining', + 'unique_id': '1234567890_mbf_par_filtvalve_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_backwash_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Backwash time remaining', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_backwash_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_polarity_changes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_polarity_changes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell polarity changes', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cell polarity changes', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_pol_changes', + 'unique_id': '1234567890_cell_runtime_pol_changes', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_cell_polarity_changes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Cell polarity changes', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_polarity_changes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime in polarity 1', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime in polarity 1', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_pola', + 'unique_id': '1234567890_cell_runtime_pola', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime in polarity 1', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime in polarity 2', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime in polarity 2', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_polb', + 'unique_id': '1234567890_cell_runtime_polb', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_in_polarity_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime in polarity 2', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_in_polarity_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_since_reset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_since_reset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime since reset', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime since reset', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_part', + 'unique_id': '1234567890_cell_runtime_part', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_since_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime since reset', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_since_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_cell_runtime_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cell runtime total', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cell runtime total', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cell_runtime_total', + 'unique_id': '1234567890_cell_runtime_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_cell_runtime_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'NeoPool Cell runtime total', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_cell_runtime_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2044444444444', + }) +# --- +# name: test_all_entities[sensor.neopool_conductivity_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_conductivity_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Conductivity level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Conductivity level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_conductivity', + 'unique_id': '1234567890_mbf_measure_conductivity', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_conductivity_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Conductivity level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_conductivity_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_current_filtration_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'low', + 'mid', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_current_filtration_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current filtration speed', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current filtration speed', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtration_speed', + 'unique_id': '1234567890_filtration_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_current_filtration_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Current filtration speed', + : list([ + 'off', + 'low', + 'mid', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_current_filtration_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_filtration_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_filtration_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration mode', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filt_mode', + 'unique_id': '1234567890_mbf_par_filt_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_filtration_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Filtration mode', + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_filtration_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_hydrolysis_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis intensity', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hydrolysis intensity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_current', + 'unique_id': '1234567890_mbf_hidro_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Hydrolysis intensity', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_polarity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'no_flow', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_hydrolysis_polarity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis polarity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrolysis polarity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_polarity', + 'unique_id': '1234567890_hidro_polarity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_polarity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Hydrolysis polarity', + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'no_flow', + 'off', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_polarity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_hydrolysis_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrolysis voltage', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_voltage', + 'unique_id': '1234567890_mbf_hidro_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_hydrolysis_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'NeoPool Hydrolysis voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_hydrolysis_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_intervals-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_intelligent_mode_intervals', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intelligent mode intervals', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intelligent mode intervals', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intelligent_intervals', + 'unique_id': '1234567890_mbf_par_intelligent_intervals', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_intervals-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Intelligent mode intervals', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_intelligent_mode_intervals', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_next_interval_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.neopool_intelligent_mode_next_interval_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intelligent mode next interval start', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intelligent mode next interval start', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intelligent_tt_next_interval', + 'unique_id': '1234567890_mbf_par_intelligent_tt_next_interval', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_intelligent_mode_next_interval_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'NeoPool Intelligent mode next interval start', + }), + 'context': , + 'entity_id': 'sensor.neopool_intelligent_mode_next_interval_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-07-03T14:00:00+00:00', + }) +# --- +# name: test_all_entities[sensor.neopool_ionization_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ionization_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ionization level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ionization level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ion_current', + 'unique_id': '1234567890_mbf_ion_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_ionization_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Ionization level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_ionization_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.neopool_ionizer_polarity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ionizer_polarity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ionizer polarity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ionizer polarity', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ion_polarity', + 'unique_id': '1234567890_ion_polarity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ionizer_polarity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Ionizer polarity', + : list([ + 'pol1', + 'pol2', + 'dead_time', + 'off', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ionizer_polarity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensor.neopool_ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234567890_mbf_measure_ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ph', + : 'NeoPool pH', + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_ph_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'ok', + 'ph_high', + 'ph_low', + 'pump_stopped', + 'ph_over', + 'ph_under', + 'tank_level', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_ph_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH alarm', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_status_alarm', + 'unique_id': '1234567890_mbf_ph_status_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool pH alarm', + : list([ + 'ok', + 'ph_high', + 'ph_low', + 'pump_stopped', + 'ph_over', + 'ph_under', + 'tank_level', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ph_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_ph_pump_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'idle', + 'acid', + 'base', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.neopool_ph_pump_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH pump status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH pump status', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_pump_status', + 'unique_id': '1234567890_ph_pump_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.neopool_ph_pump_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool pH pump status', + : list([ + 'off', + 'idle', + 'acid', + 'base', + 'both', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_ph_pump_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[sensor.neopool_redox_potential-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_redox_potential', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox potential', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox potential', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_rx', + 'unique_id': '1234567890_mbf_measure_rx', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_redox_potential-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'NeoPool Redox potential', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_redox_potential', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_salt_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_salt_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Salt level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_cl', + 'unique_id': '1234567890_mbf_measure_cl', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_salt_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Salt level', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_salt_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.neopool_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'measure_temperature', + 'unique_id': '1234567890_mbf_measure_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.neopool_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'NeoPool Water temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.neopool_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_when_modules_absent[sensor.neopool_filtration_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.neopool_filtration_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filtration mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filtration mode', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filt_mode', + 'unique_id': '1234567890_mbf_par_filt_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_when_modules_absent[sensor.neopool_filtration_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'NeoPool Filtration mode', + : list([ + 'manual', + 'auto', + 'heating', + 'smart', + 'intelligent', + 'backwash', + ]), + }), + 'context': , + 'entity_id': 'sensor.neopool_filtration_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- diff --git a/tests/components/neopool/test_config_flow.py b/tests/components/neopool/test_config_flow.py new file mode 100644 index 000000000000..96ecb532914f --- /dev/null +++ b/tests/components/neopool/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the NeoPool config flow.""" + +from unittest.mock import AsyncMock + +from neopool_modbus.exceptions import ( + NeoPoolConnectionError, + NeoPoolModbusError, + NeoPoolTimeoutError, +) +import pytest + +from homeassistant.components.neopool.const import DEFAULT_UNIT_ID, 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 MOCK_HOST, MOCK_PORT, MOCK_SERIAL + +from tests.common import MockConfigEntry + +USER_INPUT = { + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + "unit_id": DEFAULT_UNIT_ID, + "modbus_framer": "tcp", +} + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test a happy-path config flow creates the entry.""" + 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 + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_HOST + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["result"].unique_id == MOCK_SERIAL + assert mock_setup_entry.call_count == 1 + + +@pytest.mark.parametrize( + ("exc_cls", "error_key"), + [ + (NeoPoolConnectionError, "cannot_connect"), + (NeoPoolTimeoutError, "cannot_connect"), + (NeoPoolModbusError, "cannot_read_modbus"), + ], +) +async def test_user_flow_probe_errors_recover( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_socket_connection: AsyncMock, + exc_cls: type[Exception], + error_key: str, +) -> None: + """Probe errors surface as form errors, and the flow recovers on retry.""" + mock_socket_connection.side_effect = exc_cls("boom") + + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_HOST: error_key} + + mock_socket_connection.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config flow aborts when the same 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"], USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/neopool/test_init.py b/tests/components/neopool/test_init.py new file mode 100644 index 000000000000..8ef873df5f89 --- /dev/null +++ b/tests/components/neopool/test_init.py @@ -0,0 +1,192 @@ +"""Test the NeoPool integration setup, unload, and lifecycle.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from freezegun.api import FrozenDateTimeFactory +from neopool_modbus.registers import MAX_RELAY_GPIO +import pytest + +from homeassistant.components.neopool.const import DOMAIN +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, issue_registry as ir + +from . import setup_integration +from .conftest import MOCK_POOL_DATA, MOCK_SERIAL + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_setup_and_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Set up the integration end-to-end and tear it down again.""" + await setup_integration(hass, mock_config_entry) + 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 + + +async def test_setup_first_refresh_fails_marks_retry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, +) -> None: + """Setup re-tries when the first Modbus read raises.""" + mock_neopool_client.async_read_all = AsyncMock( + side_effect=ConnectionError("Modbus down") + ) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_device_registered_with_firmware( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The first successful read populates firmware on the device entry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_SERIAL)}) + assert device is not None + assert "18.52" in (device.sw_version or "") + + +async def test_transient_modbus_failure_after_first_success_marks_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Entities transition to unavailable when polling fails after a good read.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.neopool_water_temperature" + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + mock_neopool_client.async_read_all.side_effect = ConnectionError("Modbus fail") + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_corrupt_gpio_creates_repair_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """A GPIO register outside 0..MAX_RELAY_GPIO opens a corrupted_gpio issue.""" + bad_data = dict(MOCK_POOL_DATA) + bad_data["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=bad_data) + + await setup_integration(hass, mock_config_entry) + + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.severity is ir.IssueSeverity.ERROR + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_clean_gpio_does_not_create_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """A clean read does not open a corrupted_gpio issue.""" + await setup_integration(hass, mock_config_entry) + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_corrupt_gpio_clears_stale_issue_from_previous_session( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """A stale issue from a previous session clears on the first clean poll.""" + ir.async_create_issue( + hass, + DOMAIN, + "corrupted_gpio", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="corrupted_gpio", + translation_placeholders={"details": "- stale"}, + ) + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is not None + + await setup_integration(hass, mock_config_entry) + + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +async def test_corrupt_gpio_logs_error_only_on_state_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """The ERROR log fires on entering corruption and clears on healing.""" + bad_data = dict(MOCK_POOL_DATA) + bad_data["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=bad_data) + + await setup_integration(hass, mock_config_entry) + assert sum("Corrupted GPIO register" in r.message for r in caplog.records) == 1 + + caplog.clear() + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert not any("Corrupted GPIO register" in r.message for r in caplog.records) + + mock_neopool_client.async_read_all = AsyncMock(return_value=dict(MOCK_POOL_DATA)) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") is None + + +async def test_corrupt_gpio_updates_issue_on_value_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """The repair issue details refresh when a corrupted register value changes.""" + first = dict(MOCK_POOL_DATA) + first["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 1 + mock_neopool_client.async_read_all = AsyncMock(return_value=first) + + await setup_integration(hass, mock_config_entry) + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.translation_placeholders is not None + assert str(MAX_RELAY_GPIO + 1) in issue.translation_placeholders["details"] + + second = dict(MOCK_POOL_DATA) + second["MBF_PAR_FILT_GPIO"] = MAX_RELAY_GPIO + 2 + mock_neopool_client.async_read_all = AsyncMock(return_value=second) + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "corrupted_gpio") + assert issue is not None + assert issue.translation_placeholders is not None + assert str(MAX_RELAY_GPIO + 2) in issue.translation_placeholders["details"] diff --git a/tests/components/neopool/test_sensor.py b/tests/components/neopool/test_sensor.py new file mode 100644 index 000000000000..4e925f15ae95 --- /dev/null +++ b/tests/components/neopool/test_sensor.py @@ -0,0 +1,242 @@ +"""Tests for the NeoPool sensor platform.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sensor import ATTR_OPTIONS +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import MOCK_POOL_DATA + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_measurement_sensors_suppressed_when_filtration_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Probe sensors report unknown while filtration pump is off (stale reading).""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "Filtration Pump": False, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in ( + "sensor.neopool_ph", + "sensor.neopool_redox_potential", + "sensor.neopool_water_temperature", + ): + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "unknown" + + +async def test_production_sensors_zero_when_filtration_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Production sensors report 0 while filtration pump is off (cell idle).""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "Filtration Pump": False, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in ( + "sensor.neopool_hydrolysis_intensity", + "sensor.neopool_ionization_level", + ): + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "0" + + +@pytest.mark.parametrize( + ("filt_mode", "expected"), + [ + (0, "manual"), + (1, "auto"), + (2, "heating"), + (3, "smart"), + (4, "intelligent"), + (13, "backwash"), + ], +) +async def test_filt_mode_native_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + filt_mode: int, + expected: str, +) -> None: + """Filt mode native value reads the lib's decoded filtration_mode key.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_FILT_MODE": filt_mode, + "filtration_mode": expected, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.neopool_filtration_mode") + assert state is not None + assert state.state == expected + + +@pytest.mark.parametrize( + ("relay", "expected_options"), + [ + pytest.param(1, ["off", "idle", "acid"], id="acid_only"), + pytest.param(2, ["off", "idle", "base"], id="base_only"), + pytest.param(0, ["off", "idle", "acid", "base", "both"], id="both_relays"), + ], +) +async def test_ph_pump_status_options_per_relay_config( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + relay: int, + expected_options: list[str], +) -> None: + """The pH pump status options list shrinks based on the relay configuration.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_RELAY_PH": relay, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.neopool_ph_pump_status") + assert state is not None + assert state.attributes[ATTR_OPTIONS] == expected_options + + +async def test_hidro_current_g_per_hour_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """In g/h mode HIDRO_CURRENT swaps unit and bumps display precision.""" + await setup_integration(hass, mock_config_entry) + mock_neopool_client.async_read_all.return_value = { + **MOCK_POOL_DATA, + "MBF_PAR_UICFG_MACHINE": 1, + } + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.neopool_hydrolysis_intensity") + assert state is not None + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "g/h" + + +_CELL_RUNTIME_ENTITY_IDS: dict[str, str] = { + "CELL_RUNTIME_TOTAL": "sensor.neopool_cell_runtime_total", + "CELL_RUNTIME_PART": "sensor.neopool_cell_runtime_since_reset", + "CELL_RUNTIME_POLA": "sensor.neopool_cell_runtime_in_polarity_1", + "CELL_RUNTIME_POLB": "sensor.neopool_cell_runtime_in_polarity_2", + "CELL_RUNTIME_POL_CHANGES": "sensor.neopool_cell_polarity_changes", +} + + +@pytest.mark.parametrize( + ("key", "expected_seconds"), + [ + ("CELL_RUNTIME_TOTAL", 65536), + ("CELL_RUNTIME_PART", 3600), + ("CELL_RUNTIME_POLA", 1800), + ("CELL_RUNTIME_POLB", 1800), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_cell_runtime_duration_sensor_reads_combined_register( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + key: str, + expected_seconds: int, +) -> None: + """Each duration CELL_RUNTIME_* sensor reads the combined u32 key from coordinator data. + + Sensors have ``entity_registry_enabled_default=False``; enabling every + disabled-by-default entity via the fixture avoids the reload dance. The + sensors declare seconds but suggest hours, so ``state.state`` is expressed + in hours (converted by the frontend layer). + """ + await setup_integration(hass, mock_config_entry) + + entity_id = _CELL_RUNTIME_ENTITY_IDS[key] + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert float(state.state) == pytest.approx(expected_seconds / 3600, abs=1e-4) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_cell_runtime_pol_changes_sensor_reads_combined_register( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """CELL_RUNTIME_POL_CHANGES reads the combined u32 key as a raw counter. + + Unlike the duration sensors, this one has no unit and no unit conversion, + so ``state.state`` is the raw integer from coordinator data. + """ + await setup_integration(hass, mock_config_entry) + + entity_id = _CELL_RUNTIME_ENTITY_IDS["CELL_RUNTIME_POL_CHANGES"] + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} not registered" + assert state.state == "7" + + +@pytest.mark.freeze_time("2026-07-03T12:00:00Z") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_neopool_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Snapshot every entity registered by the sensor platform.""" + with patch("homeassistant.components.neopool.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2026-07-03T12:00:00Z") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_setup_when_modules_absent( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_neopool_client: MagicMock, + minimal_pool_data: dict[str, Any], +) -> None: + """Snapshot the sensor entities registered when no modules are present.""" + mock_neopool_client.async_read_all.return_value = minimal_pool_data + with patch("homeassistant.components.neopool.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 1dc0858909cf..8b8b60559148 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -121,7 +121,7 @@ async def simulate_webhook(hass: HomeAssistant, webhook_id: str, response) -> No def selected_platforms(platforms: list[Platform]) -> Iterator[None]: """Restrict loaded platforms to list given.""" with ( - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", platforms), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", platforms), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), diff --git a/tests/components/netatmo/snapshots/test_binary_sensor.ambr b/tests/components/netatmo/snapshots/test_binary_sensor.ambr index c0080ae96633..235a9c1e1d34 100644 --- a/tests/components/netatmo/snapshots/test_binary_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_binary_sensor.ambr @@ -42,8 +42,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Baby Bedroom Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.baby_bedroom_connectivity', @@ -96,8 +96,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Bedroom Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.bedroom_connectivity', @@ -150,8 +150,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Kitchen Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.kitchen_connectivity', @@ -204,8 +204,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Livingroom Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.livingroom_connectivity', @@ -258,8 +258,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Parents Bedroom Connectivity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'binary_sensor.parents_bedroom_connectivity', @@ -416,8 +416,8 @@ : 'Data provided by Netatmo', : 'connectivity', : 'Villa Connectivity', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'binary_sensor.villa_connectivity', diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index fd12cb9fb69c..b37ef2729d93 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[netatmo-0009999992] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Entrance Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[netatmo-0009999993] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Bubendorff blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[netatmo-00:11:22:33:00:11:45:fe] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Unknown 00:11:22:33:00:11:45:fe', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_devices[netatmo-1002003001] DeviceRegistryEntrySnapshot({ 'area_id': 'corridor', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Corridor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_devices[netatmo-12:34:56:00:00:a1:4c:da] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Consumption meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_devices[netatmo-12:34:56:00:01:01:01:a1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Bathroom light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': None, 'name': 'Line 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': None, 'name': 'Line 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': None, 'name': 'Line 3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#3] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': None, 'name': 'Line 4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#4] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': None, 'name': 'Line 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#5] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': None, 'name': 'Total', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#6] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': None, 'name': 'Gas', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#7] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': None, 'name': 'Hot water', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e#8] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': None, 'name': 'Cold water', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_devices[netatmo-12:34:56:00:16:0e] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -489,7 +474,6 @@ 'model_id': None, 'name': 'Écocompteur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -498,8 +482,8 @@ # name: test_devices[netatmo-12:34:56:00:86:99] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': None, 'name': 'Window Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_devices[netatmo-12:34:56:00:f1:62] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': None, 'name': 'Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_devices[netatmo-12:34:56:03:1b:e4] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': None, 'name': 'Villa Garden', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_devices[netatmo-12:34:56:10:b9:0e] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': None, 'name': 'Front', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_devices[netatmo-12:34:56:10:f1:66] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': None, 'name': 'Netatmo-Doorbell', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_devices[netatmo-12:34:56:25:cf:a8] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_devices[netatmo-12:34:56:26:65:14] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': None, 'name': 'Livingroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_devices[netatmo-12:34:56:26:68:92] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': None, 'name': 'Baby Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +722,8 @@ # name: test_devices[netatmo-12:34:56:26:69:0c] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -768,7 +744,6 @@ 'model_id': None, 'name': 'Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -777,8 +752,8 @@ # name: test_devices[netatmo-12:34:56:3e:c5:46] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -799,7 +774,6 @@ 'model_id': None, 'name': 'Parents Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -808,8 +782,8 @@ # name: test_devices[netatmo-12:34:56:80:00:12:ac:f2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -830,7 +804,6 @@ 'model_id': None, 'name': 'Prise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -839,8 +812,8 @@ # name: test_devices[netatmo-12:34:56:80:1c:42] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -861,7 +834,6 @@ 'model_id': None, 'name': 'Villa Outdoor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -870,8 +842,8 @@ # name: test_devices[netatmo-12:34:56:80:44:92] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -892,7 +864,6 @@ 'model_id': None, 'name': 'Villa Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -901,8 +872,8 @@ # name: test_devices[netatmo-12:34:56:80:7e:18] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -923,7 +894,6 @@ 'model_id': None, 'name': 'Villa Bathroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -932,8 +902,8 @@ # name: test_devices[netatmo-12:34:56:80:bb:26] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -954,7 +924,6 @@ 'model_id': None, 'name': 'Villa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -963,8 +932,8 @@ # name: test_devices[netatmo-12:34:56:80:c1:ea] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -985,7 +954,6 @@ 'model_id': None, 'name': 'Villa Rain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -994,8 +962,8 @@ # name: test_devices[netatmo-222452125] DeviceRegistryEntrySnapshot({ 'area_id': 'bureau', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1016,7 +984,6 @@ 'model_id': None, 'name': 'Bureau Modulate', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1025,8 +992,8 @@ # name: test_devices[netatmo-2746182631] DeviceRegistryEntrySnapshot({ 'area_id': 'livingroom', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1047,7 +1014,6 @@ 'model_id': None, 'name': 'Livingroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1056,8 +1022,8 @@ # name: test_devices[netatmo-2833524037] DeviceRegistryEntrySnapshot({ 'area_id': 'entrada', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1078,7 +1044,6 @@ 'model_id': None, 'name': 'Valve1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1087,8 +1052,8 @@ # name: test_devices[netatmo-2940411577] DeviceRegistryEntrySnapshot({ 'area_id': 'cocina', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1109,7 +1074,6 @@ 'model_id': None, 'name': 'Valve2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1118,8 +1082,8 @@ # name: test_devices[netatmo-91763b24c43d3e344f424e8b] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1140,7 +1104,6 @@ 'model_id': None, 'name': 'MYHOME', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1112,8 @@ # name: test_devices[netatmo-Home avg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1171,7 +1134,6 @@ 'model_id': None, 'name': 'Home avg', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1180,8 +1142,8 @@ # name: test_devices[netatmo-Home max] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1202,7 +1164,6 @@ 'model_id': None, 'name': 'Home max', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1211,8 +1172,8 @@ # name: test_devices[netatmo-Home min] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1233,7 +1194,6 @@ 'model_id': None, 'name': 'Home min', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 964e76a793cf..d9396f569900 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -50,8 +50,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Baby Bedroom Atmospheric pressure', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -108,8 +108,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Baby Bedroom Carbon dioxide', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -172,8 +172,8 @@ : 'Data provided by Netatmo', : 'enum', : 'Baby Bedroom Health index', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : list([ 'healthy', 'fine', @@ -235,8 +235,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Baby Bedroom Humidity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -296,8 +296,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Baby Bedroom Noise', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -351,8 +351,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Baby Bedroom Pressure trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.baby_bedroom_pressure_trend', @@ -404,8 +404,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Baby Bedroom Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.baby_bedroom_reachability', @@ -463,8 +463,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Baby Bedroom Temperature', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -518,8 +518,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Baby Bedroom Temperature trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.baby_bedroom_temperature_trend', @@ -571,8 +571,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Baby Bedroom Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', @@ -975,8 +975,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Bedroom Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.bedroom_reachability', @@ -1138,8 +1138,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Bedroom Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.bedroom_wi_fi_strength', @@ -1575,8 +1575,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Home avg Atmospheric pressure', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1633,8 +1633,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home avg Gust angle', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -1694,8 +1694,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home avg Gust strength', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1752,8 +1752,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Home avg Humidity', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1813,8 +1813,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home avg Precipitation', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1874,8 +1874,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home avg Precipitation last hour', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1935,8 +1935,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home avg Precipitation today', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -1996,8 +1996,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Home avg Temperature', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2054,8 +2054,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home avg Wind direction', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -2115,8 +2115,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home avg Wind speed', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2179,8 +2179,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Home max Atmospheric pressure', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2237,8 +2237,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home max Gust angle', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -2298,8 +2298,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home max Gust strength', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2356,8 +2356,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Home max Humidity', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2417,8 +2417,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home max Precipitation', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2478,8 +2478,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home max Precipitation last hour', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2539,8 +2539,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home max Precipitation today', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2600,8 +2600,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Home max Temperature', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2658,8 +2658,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home max Wind direction', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -2719,8 +2719,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home max Wind speed', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2783,8 +2783,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Home min Atmospheric pressure', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2841,8 +2841,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home min Gust angle', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -2902,8 +2902,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home min Gust strength', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -2960,8 +2960,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Home min Humidity', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3021,8 +3021,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home min Precipitation', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3082,8 +3082,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home min Precipitation last hour', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3143,8 +3143,8 @@ : 'Data provided by Netatmo', : 'precipitation', : 'Home min Precipitation today', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3204,8 +3204,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Home min Temperature', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3262,8 +3262,8 @@ : 'Data provided by Netatmo', : 'wind_direction', : 'Home min Wind direction', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : '°', }), @@ -3323,8 +3323,8 @@ : 'Data provided by Netatmo', : 'wind_speed', : 'Home min Wind speed', - 'latitude': 32.17901225, - 'longitude': -117.17901225, + : 32.17901225, + : -117.17901225, : , : , }), @@ -3438,8 +3438,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Kitchen Atmospheric pressure', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3496,8 +3496,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Kitchen Carbon dioxide', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3560,8 +3560,8 @@ : 'Data provided by Netatmo', : 'enum', : 'Kitchen Health index', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : list([ 'healthy', 'fine', @@ -3623,8 +3623,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Kitchen Humidity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3684,8 +3684,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Kitchen Noise', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3739,8 +3739,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Kitchen Pressure trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.kitchen_pressure_trend', @@ -3792,8 +3792,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Kitchen Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.kitchen_reachability', @@ -3851,8 +3851,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Kitchen Temperature', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -3906,8 +3906,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Kitchen Temperature trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.kitchen_temperature_trend', @@ -3959,8 +3959,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Kitchen Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.kitchen_wi_fi_strength', @@ -4276,8 +4276,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Livingroom Atmospheric pressure', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4390,8 +4390,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Livingroom Carbon dioxide', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4454,8 +4454,8 @@ : 'Data provided by Netatmo', : 'enum', : 'Livingroom Health index', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : list([ 'healthy', 'fine', @@ -4517,8 +4517,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Livingroom Humidity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4578,8 +4578,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Livingroom Noise', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4633,8 +4633,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Livingroom Pressure trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.livingroom_pressure_trend', @@ -4686,8 +4686,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Livingroom Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.livingroom_reachability', @@ -4745,8 +4745,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Livingroom Temperature', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4800,8 +4800,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Livingroom Temperature trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.livingroom_temperature_trend', @@ -4853,8 +4853,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Livingroom Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.livingroom_wi_fi_strength', @@ -4915,8 +4915,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Parents Bedroom Atmospheric pressure', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -4973,8 +4973,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Parents Bedroom Carbon dioxide', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -5037,8 +5037,8 @@ : 'Data provided by Netatmo', : 'enum', : 'Parents Bedroom Health index', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : list([ 'healthy', 'fine', @@ -5100,8 +5100,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Parents Bedroom Humidity', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -5161,8 +5161,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Parents Bedroom Noise', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -5216,8 +5216,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Parents Bedroom Pressure trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.parents_bedroom_pressure_trend', @@ -5269,8 +5269,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Parents Bedroom Reachability', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.parents_bedroom_reachability', @@ -5328,8 +5328,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Parents Bedroom Temperature', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, : , : , }), @@ -5383,8 +5383,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Parents Bedroom Temperature trend', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.parents_bedroom_temperature_trend', @@ -5436,8 +5436,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Parents Bedroom Wi-Fi strength', - 'latitude': 13.377726, - 'longitude': 52.516263, + : 13.377726, + : 52.516263, }), 'context': , 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', @@ -5771,8 +5771,8 @@ : 'Data provided by Netatmo', : 'atmospheric_pressure', : 'Villa Atmospheric pressure', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -6589,8 +6589,8 @@ : 'Data provided by Netatmo', : 'carbon_dioxide', : 'Villa Carbon dioxide', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -7181,8 +7181,8 @@ : 'Data provided by Netatmo', : 'humidity', : 'Villa Humidity', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -7242,8 +7242,8 @@ : 'Data provided by Netatmo', : 'sound_pressure', : 'Villa Noise', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -7621,8 +7621,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Villa Pressure trend', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'sensor.villa_pressure_trend', @@ -8009,8 +8009,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Villa Reachability', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'sensor.villa_reachability', @@ -8068,8 +8068,8 @@ : 'Data provided by Netatmo', : 'temperature', : 'Villa Temperature', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, : , : , }), @@ -8123,8 +8123,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Villa Temperature trend', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'sensor.villa_temperature_trend', @@ -8176,8 +8176,8 @@ 'attributes': ReadOnlyDict({ : 'Data provided by Netatmo', : 'Villa Wi-Fi strength', - 'latitude': 46.123456, - 'longitude': 6.1234567, + : 46.123456, + : 6.1234567, }), 'context': , 'entity_id': 'sensor.villa_wi_fi_strength', diff --git a/tests/components/netatmo/test_binary_sensor.py b/tests/components/netatmo/test_binary_sensor.py index 8e9988c309d2..65b8b53e680b 100644 --- a/tests/components/netatmo/test_binary_sensor.py +++ b/tests/components/netatmo/test_binary_sensor.py @@ -59,7 +59,7 @@ async def test_doortag_setup( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( @@ -171,7 +171,7 @@ async def test_doortag_opening_status_change( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( @@ -298,7 +298,7 @@ async def test_doortag_opening_category( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.data_handler.PLATFORMS", + "homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera", "binary_sensor"], ), patch( diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 7ccbe041f663..c26e18140ab6 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -474,7 +474,7 @@ async def test_camera_reconnect_webhook( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -587,7 +587,7 @@ async def test_camera_webhook_consistency( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -697,7 +697,7 @@ async def test_setup_component_no_devices( """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post_no_data(*args, **kwargs): + async def fake_post_no_data(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 @@ -707,7 +707,7 @@ async def test_setup_component_no_devices( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -750,7 +750,7 @@ async def test_camera_image_raises_exception( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["camera"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 9773b7029433..743ec43d6130 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -7,7 +7,6 @@ from pyatmo.const import ALL_SCOPES import pytest from homeassistant import config_entries -from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( CONF_NEW_AREA, CONF_WEATHER_AREAS, @@ -35,10 +34,7 @@ VALID_CONFIG = {} async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - flow = config_flow.NetatmoFlowHandler() - flow.hass = hass + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -60,7 +56,18 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" + + +async def test_abort_if_legacy_entry_without_unique_id(hass: HomeAssistant) -> None: + """Check user flow aborts for a legacy entry that has no unique_id yet.""" + MockConfigEntry(domain=DOMAIN, unique_id=None).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" @pytest.mark.usefixtures("current_request_with_host") diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index c6f45b129287..9fbaf006169b 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -3,9 +3,11 @@ from datetime import timedelta from functools import partial from time import time +from typing import Any from unittest.mock import AsyncMock, patch import aiohttp +from freezegun.api import FrozenDateTimeFactory from pyatmo.const import ALL_SCOPES import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +15,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import cloud, webhook from homeassistant.components.netatmo import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_WEBHOOK_ID, Platform +from homeassistant.const import ( + CONF_WEBHOOK_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ( OAuth2TokenRequestReauthError, @@ -113,7 +120,7 @@ async def test_setup_component_with_config( """Test setup of the netatmo component with dev account.""" fake_post_hits = 0 - async def fake_post(*args, **kwargs): + async def fake_post(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 @@ -129,7 +136,7 @@ async def test_setup_component_with_config( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["sensor"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["sensor"]), ): mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -301,7 +308,7 @@ async def test_setup_with_cloud( patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", []), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -371,7 +378,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", []), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", []), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), @@ -427,7 +434,7 @@ async def test_setup_component_with_delay( "pyatmo.AbstractAsyncAuth.async_post_api_request", side_effect=partial(fake_post_request, hass), ) as mock_post_api_request, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["light"]), ): assert await async_setup_component( hass, DOMAIN, {"netatmo": {"client_id": "123", "client_secret": "abc"}} @@ -656,3 +663,89 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("platform", "entity_id", "module_id", "initial_state"), + [ + pytest.param( + "switch", "switch.prise", "12:34:56:80:00:12:ac:f2", "on", id="switch" + ), + pytest.param( + "cover", "cover.entrance_blinds", "0009999992", "closed", id="cover" + ), + pytest.param( + "fan", + "fan.centralized_ventilation_controler", + "12:34:56:00:01:01:01:b1", + "on", + id="fan", + ), + pytest.param( + "light", + "light.unknown_00_11_22_33_00_11_45_fe", + "00:11:22:33:00:11:45:fe", + "off", + id="light", + ), + pytest.param( + "button", + "button.entrance_blinds_preferred_position", + "0009999992", + STATE_UNKNOWN, + id="button", + ), + ], +) +async def test_entity_unavailable_when_device_unreachable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + platform: str, + entity_id: str, + module_id: str, + initial_state: str, +) -> None: + """Test that entities become unavailable when their device is unreachable.""" + reachable = True + + def set_reachable(payload: dict) -> None: + home = payload.get("body", {}).get("home") + if not isinstance(home, dict): + return + for module in home.get("modules", []): + if module.get("id") == module_id: + module["reachable"] = reachable + + async def fake_post(*args: Any, **kwargs: Any): + return await fake_post_request( + hass, *args, msg_callback=set_reachable, **kwargs + ) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", [platform]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + return_value=AsyncMock(), + ), + patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == initial_state + + reachable = False + for _ in range(11): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 9641519d4c04..83fe5a54607a 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -1,5 +1,6 @@ """The tests for Netatmo light.""" +from typing import Any from unittest.mock import AsyncMock, patch from syrupy.assertion import SnapshotAssertion @@ -113,7 +114,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> """Test setup with no devices.""" fake_post_hits = 0 - async def fake_post_request_no_data(*args, **kwargs): + async def fake_post_request_no_data(*args: Any, **kwargs: Any): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 @@ -127,7 +128,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, - patch("homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"]), + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["light"]), patch( "homeassistant.components.netatmo.async_get_config_entry_implementation", ), diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index 6279f3ff429a..601d9afc8636 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -1,6 +1,7 @@ """Test Local Media Source.""" import ast +from unittest.mock import AsyncMock import pytest @@ -12,24 +13,30 @@ from homeassistant.components.media_source import ( async_browse_media, async_resolve_media, ) -from homeassistant.components.netatmo import DATA_CAMERAS, DATA_EVENTS, DOMAIN +from homeassistant.components.netatmo.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import async_load_fixture +from .common import selected_platforms + +from tests.common import MockConfigEntry, async_load_fixture -async def test_async_browse_media(hass: HomeAssistant) -> None: +async def test_async_browse_media( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: """Test browse media.""" - assert await async_setup_component(hass, DOMAIN, {}) + with selected_platforms([Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - # Prepare cached Netatmo event date - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_EVENTS] = ast.literal_eval( + # Prepare cached Netatmo event data + data_handler = config_entry.runtime_data + data_handler.events = ast.literal_eval( await async_load_fixture(hass, "events.txt", DOMAIN) ) - - hass.data[DOMAIN][DATA_CAMERAS] = { + data_handler.cameras = { "12:34:56:78:90:ab": "MyCamera", "12:34:56:78:90:ac": "MyOutdoorCamera", } @@ -58,7 +65,7 @@ async def test_async_browse_media(hass: HomeAssistant) -> None: with pytest.raises(BrowseError) as excinfo: await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/") assert str(excinfo.value) == ( - "Failed to browse media with content id media-source://netatmo/: " + "Failed to browse media with content ID media-source://netatmo/: " "Invalid media source URI" ) # Test successful listing diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index fd7b09daa4f9..259a0703653c 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -1,7 +1,12 @@ """The tests for Netatmo switch.""" +from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +import pyatmo +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import ( @@ -9,13 +14,13 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import selected_platforms, snapshot_platform_entities +from .common import fake_post_request, selected_platforms, snapshot_platform_entities -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_entity( @@ -89,3 +94,51 @@ async def test_switch_setup_and_services( ] } ) + + +@pytest.mark.parametrize( + "error", + [TimeoutError, pyatmo.ApiError], + ids=["timeout", "api_error"], +) +async def test_switch_unavailable_on_fetch_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + error: type[Exception], +) -> None: + """Test the switch becomes unavailable when the data cannot be fetched.""" + raise_error = False + + async def fake_post(*args: Any, **kwargs: Any): + if raise_error: + raise error + return await fake_post_request(hass, *args, **kwargs) + + with ( + patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, + patch("homeassistant.components.netatmo.coordinator.PLATFORMS", ["switch"]), + patch( + "homeassistant.components.netatmo.async_get_config_entry_implementation", + return_value=AsyncMock(), + ), + patch("homeassistant.components.netatmo.webhook.webhook_generate_url"), + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + switch_entity = "switch.prise" + assert hass.states.get(switch_entity).state == "on" + + raise_error = True + for _ in range(11): + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get(switch_entity).state == STATE_UNAVAILABLE diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr index fd58e6e0002d..a201c9baded4 100644 --- a/tests/components/netgear_lte/snapshots/test_init.ambr +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.5.1', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Netgear LM1200', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'FFFFFFFFFFFFF', 'sw_version': 'EC25AFFDR07A09M4G', 'via_device_id': None, diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index d54a4e2b5e68..6309eaa183c3 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -608,7 +608,7 @@ async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses "homeassistant.components.network.util.ifaddr.get_adapters", return_value=[], ): - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() with pytest.raises(HomeAssistantError): await network.async_get_source_ip(hass, MDNS_TARGET_IP) diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index e87db0a25c0d..a0b8cb4ff9cf 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -7,6 +7,14 @@ 'verify_ssl': True, }) # --- +# name: test_reconfigure_entry + dict({ + 'password': 'other_password', + 'url': 'https://my.nc_url.local', + 'username': 'other_user', + 'verify_ssl': True, + }) +# --- # name: test_user_create_entry dict({ 'password': 'nc_pass', diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 16b6bf3bc046..211d2168909b 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -32,7 +32,7 @@ async def test_user_create_entry( DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {} # test NextcloudMonitorAuthorizationError @@ -46,7 +46,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "invalid_auth"} # test NextcloudMonitorConnectionError @@ -60,7 +60,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "connection_error"} # test NextcloudMonitorRequestError @@ -74,7 +74,7 @@ async def test_user_create_entry( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {"base": "connection_error"} # test success @@ -93,6 +93,86 @@ async def test_user_create_entry( assert result["data"] == snapshot +async def test_reconfigure_entry( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test that the reconfigure step works.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="https://my.nc_url.local", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {} + + # test NextcloudMonitorAuthorizationError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "connection_error"} + + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "config" + assert result["errors"] == {"base": "connection_error"} + + # test success + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + **VALID_CONFIG, + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == snapshot + + async def test_user_already_configured(hass: HomeAssistant) -> None: """Test that errors are shown when duplicates are added.""" entry = MockConfigEntry( @@ -107,7 +187,7 @@ async def test_user_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "config" assert result["errors"] == {} with patch( diff --git a/tests/components/nina/snapshots/test_service.ambr b/tests/components/nina/snapshots/test_service.ambr new file mode 100644 index 000000000000..e5e91fd7b997 --- /dev/null +++ b/tests/components/nina/snapshots/test_service.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_service_get_details + dict({ + 'binary_sensor.aach_warning_1': dict({ + 'affected_areas': 'Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere.', + 'description': 'Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.', + 'expires': '3021-11-22T05:19:00+01:00', + 'headline': 'Ausfall Notruf 112', + 'id': 'mow.DE-NW-BN-SE030-20201014-30-000', + 'recommended_actions': 'ACHTUNG! Hinweis auf mögliche Gefahren: Es können zum Beispiel einzelne Äste herabstürzen. Achte besonders auf herabfallende Gegenstände.', + 'sender': 'Deutscher Wetterdienst', + 'sent': '2021-10-11T05:20:00+01:00', + 'severity': 'Minor', + 'start': '2021-11-01T05:20:00+01:00', + 'web': 'https://www.wettergefahren.de', + }), + }) +# --- diff --git a/tests/components/nina/test_service.py b/tests/components/nina/test_service.py new file mode 100644 index 000000000000..a10b2efece13 --- /dev/null +++ b/tests/components/nina/test_service.py @@ -0,0 +1,67 @@ +"""Test the Nina services.""" + +from unittest.mock import AsyncMock + +from pynina import Warning +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nina.const import DOMAIN, SERVICE_GET_DETAILS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import setup_platform, setup_single_platform + +from tests.common import MockConfigEntry + + +async def test_service_registration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nina_class: AsyncMock +) -> None: + """Test the NINA services be registered.""" + await setup_single_platform(hass, mock_config_entry, None, mock_nina_class, []) + + services = hass.services.async_services_for_domain(DOMAIN) + + assert len(services) == 1 + assert SERVICE_GET_DETAILS in services + + +async def test_service_get_details( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nina_class: AsyncMock, + nina_warnings: list[Warning], + snapshot: SnapshotAssertion, +) -> None: + """Test the get details service.""" + await setup_platform(hass, mock_config_entry, mock_nina_class, nina_warnings) + + target_entity_id = "binary_sensor.aach_warning_1" + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_DETAILS, + {ATTR_ENTITY_ID: target_entity_id}, + blocking=True, + return_response=True, + ) + assert result == snapshot + + +async def test_service_get_details_no_warning( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_nina_class: AsyncMock +) -> None: + """Test the get details service when no warning is present.""" + await setup_platform(hass, mock_config_entry, mock_nina_class, []) + + target_entity_id = "binary_sensor.aach_warning_1" + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_DETAILS, + {ATTR_ENTITY_ID: target_entity_id}, + blocking=True, + return_response=True, + ) + + assert result[target_entity_id] is None diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index 24f63401951b..4f3be1f6f48b 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -3,10 +3,48 @@ from unittest.mock import MagicMock from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + + +def device_identifiers( + device_registry: dr.DeviceRegistry, entry_id: str +) -> set[tuple[str, str]]: + """Return the identifiers of all devices for the config entry.""" + identifiers: set[tuple[str, str]] = set() + for device in dr.async_entries_for_config_entry(device_registry, entry_id): + identifiers |= device.identifiers + return identifiers + + +def entity_unique_ids(entity_registry: er.EntityRegistry, entry_id: str) -> set[str]: + """Return the unique ids of all entities for the config entry.""" + return { + entry.unique_id + for entry in er.async_entries_for_config_entry(entity_registry, entry_id) + } + + +def dispatch_hub_update(hub: MagicMock) -> None: + """Fire the hub's registered push-update callbacks without awaiting. + + Mirrors pynobo dispatching a single message: call this twice in a row to + reproduce buffered messages processed with no event-loop yield between them. + """ + for call in hub.register_callback.call_args_list: + call.args[0](hub) async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: """Fire the hub's registered push-update callbacks and wait for state to settle.""" - for call in hub.register_callback.call_args_list: - call.args[0](hub) + dispatch_hub_update(hub) + await hass.async_block_till_done() + + +async def fire_hub_connection( + hass: HomeAssistant, hub: MagicMock, connected: bool +) -> None: + """Fire the hub's registered connection-state callbacks and wait for state to settle.""" + hub.connected = connected + for call in hub.register_connection_callback.call_args_list: + call.args[0](hub, connected) await hass.async_block_till_done() diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 7df91c55130e..0cab97fed99c 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -54,6 +54,12 @@ def config_entry_options() -> dict[str, Any]: return {} +@pytest.fixture +def hub_connected() -> bool: + """Whether the mocked hub reports itself connected after setup.""" + return True + + @pytest.fixture def mock_config_entry( ip_address: str, @@ -75,6 +81,7 @@ def mock_config_entry( @pytest.fixture def mock_nobo_class( connect_exc: BaseException | None, + hub_connected: bool, ) -> Generator[MagicMock]: """Patch the integration's imported `nobo` class with a populated hub instance.""" with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls: @@ -82,6 +89,7 @@ def mock_nobo_class( if connect_exc is not None: hub.connect.side_effect = connect_exc + hub.connected = hub_connected hub.hub_serial = SERIAL hub.hub_info = { "name": "My Eco Hub", @@ -98,10 +106,12 @@ def mock_nobo_class( "temp_eco_c": "17", }, } - model = MagicMock() - # Direct assignment overrides MagicMock's auto-attr for `.name`. - model.name = "Panel heater" - model.has_temp_sensor = True + model = pynobo_nobo.Model( + model_id="183", + type="THERMOSTAT_FLOOR", + name="Panel heater", + has_temp_sensor=True, + ) hub.components = { "200000059091": { "serial": "200000059091", diff --git a/tests/components/nobo_hub/snapshots/test_diagnostics.ambr b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..72d02fa6421e --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'components': list([ + dict({ + 'model': dict({ + 'has_temp_sensor': True, + 'model_id': '183', + 'name': 'Panel heater', + 'requires_control_panel': False, + 'supports_comfort': False, + 'supports_eco': False, + 'type': 'THERMOSTAT_FLOOR', + }), + 'name': 'Floor sensor', + 'serial': '**REDACTED**', + 'zone_id': '1', + }), + ]), + 'entry_data': dict({ + 'ip_address': '**REDACTED**', + 'serial': '**REDACTED**', + }), + 'hub_info': dict({ + 'hardware_version': 'hw', + 'name': 'My Eco Hub', + 'serial': '**REDACTED**', + 'software_version': '115', + }), + 'overrides': dict({ + '988': dict({ + 'mode': '0', + 'target_id': '-1', + 'target_type': '0', + }), + }), + 'week_profiles': dict({ + '0': dict({ + 'name': 'Default', + 'profile': '00000', + 'week_profile_id': '0', + }), + }), + 'zones': dict({ + '1': dict({ + 'name': 'Living room', + 'temp_comfort_c': '21', + 'temp_eco_c': '17', + 'week_profile_id': '0', + 'zone_id': '1', + }), + }), + }) +# --- diff --git a/tests/components/nobo_hub/test_climate.py b/tests/components/nobo_hub/test_climate.py index 2e42c5934285..a2ec77f978ea 100644 --- a/tests/components/nobo_hub/test_climate.py +++ b/tests/components/nobo_hub/test_climate.py @@ -27,16 +27,24 @@ from homeassistant.components.nobo_hub.const import ( DOMAIN, OVERRIDE_TYPE_NOW, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +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 fire_hub_update +from . import entity_unique_ids, fire_hub_update +from .conftest import SERIAL from tests.common import MockConfigEntry, snapshot_platform CLIMATE_ENTITY = "climate.living_room_living_room" +BEDROOM_ZONE = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", +} @pytest.fixture @@ -189,14 +197,14 @@ async def test_set_preset_with_override_type_now( @pytest.mark.usefixtures("init_integration") -async def test_zone_removed_marks_unavailable( +async def test_zone_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A zone removed via the Nobø app must not crash and goes unavailable.""" + """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(CLIMATE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(CLIMATE_ENTITY) is None @pytest.mark.usefixtures("init_integration") @@ -264,3 +272,43 @@ async def test_climate_action_wraps_library_error( ) assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == expected_key + + +@pytest.mark.usefixtures("init_integration") +async def test_new_zone_adds_entity( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone added on the hub at runtime creates a climate entity.""" + entry_id = mock_config_entry.entry_id + assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) reappears.""" + entry_id = mock_config_entry.entry_id + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = BEDROOM_ZONE + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) diff --git a/tests/components/nobo_hub/test_config_flow.py b/tests/components/nobo_hub/test_config_flow.py index d8155df92244..14fe846426a6 100644 --- a/tests/components/nobo_hub/test_config_flow.py +++ b/tests/components/nobo_hub/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, PropertyMock, patch +from pynobo import PynoboConnectionError import pytest from homeassistant import config_entries @@ -15,6 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from .conftest import SERIAL, STORED_IP + from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( @@ -404,7 +407,10 @@ async def test_configure_invalid_ip_address( ("connect_outcome", "expected_error"), [ ({"return_value": False}, "cannot_connect"), - ({"side_effect": ConnectionRefusedError(61, "")}, "cannot_connect_ip"), + ( + {"side_effect": PynoboConnectionError("Failed to connect")}, + "cannot_connect_ip", + ), ], ids=["serial_mismatch", "tcp_failure"], ) @@ -417,10 +423,10 @@ async def test_configure_cannot_connect( """Connect failures map to distinct error keys; retry recovers. pynobo's async_connect_hub returns False on a successful TCP connect - followed by a handshake REJECT (serial mismatch) and raises OSError - on TCP-level failure (wrong IP / hub offline). We surface these as - cannot_connect ("check serial number") and cannot_connect_ip - ("check IP address") respectively. + followed by a handshake REJECT (serial mismatch) and raises + PynoboConnectionError on TCP-level failure (wrong IP / hub offline). + We surface these as cannot_connect ("check serial number") and + cannot_connect_ip ("check IP address") respectively. """ with patch( "homeassistant.components.nobo_hub.config_flow.nobo.async_discover_hubs", @@ -773,6 +779,163 @@ async def test_dhcp_discovery_no_broadcast(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_discover" +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow_changes_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """A new IP is probed before save when the entry is not loaded.""" + new_ip = "192.168.1.200" + 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"] == "reconfigure" + assert result["description_placeholders"] == {CONF_SERIAL: SERIAL} + + with ( + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + return_value=True, + ) as mock_connect, + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: new_ip}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SERIAL: SERIAL, CONF_IP_ADDRESS: new_ip} + mock_connect.assert_awaited_once_with(new_ip, SERIAL) + + +@pytest.mark.parametrize( + ("submitted_ip", "connect_outcome", "expected_error", "expected_connect_count"), + [ + ( + "192.168.1.200", + {"side_effect": PynoboConnectionError("Failed to connect")}, + "cannot_connect_ip", + 1, + ), + ("not-an-ip", {"return_value": True}, "invalid_ip", 0), + ], + ids=["unreachable_ip", "invalid_format"], +) +@pytest.mark.usefixtures("mock_setup_entry", "mock_unload_entry") +async def test_reconfigure_flow_rejects_bad_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + submitted_ip: str, + connect_outcome: dict[str, object], + expected_error: str, + expected_connect_count: int, +) -> None: + """A bad IP is rejected inline; resubmitting a good IP completes the reconfigure.""" + recovery_ip = "192.168.1.201" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + **connect_outcome, + ) as mock_connect: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: submitted_ip}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {CONF_IP_ADDRESS: expected_error} + assert mock_config_entry.data[CONF_IP_ADDRESS] == STORED_IP + assert mock_connect.await_count == expected_connect_count + + with ( + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub", + return_value=True, + ), + patch( + "homeassistant.components.nobo_hub.config_flow.nobo.hub_info", + new_callable=PropertyMock, + create=True, + return_value={"name": "My Nobø Ecohub"}, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_IP_ADDRESS: recovery_ip}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_SERIAL: SERIAL, CONF_IP_ADDRESS: recovery_ip} + + +async def test_reconfigure_flow_unchanged_ip_skips_reload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, +) -> None: + """Submitting the same IP while connected aborts without triggering a reload.""" + 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() + mock_setup_entry.reset_mock() + + result = await mock_config_entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: STORED_IP}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + mock_unload_entry.assert_not_awaited() + mock_setup_entry.assert_not_awaited() + + +async def test_reconfigure_flow_changed_ip_triggers_reload_and_skips_probe( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, +) -> None: + """Submitting a different IP while connected reloads the entry without probing.""" + new_ip = "192.168.1.200" + 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() + mock_setup_entry.reset_mock() + mock_unload_entry.reset_mock() + + result = await mock_config_entry.start_reconfigure_flow(hass) + with patch( + "homeassistant.components.nobo_hub.config_flow.nobo.async_connect_hub" + ) as mock_connect: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_IP_ADDRESS: new_ip}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_IP_ADDRESS] == new_ip + mock_connect.assert_not_awaited() + mock_unload_entry.assert_awaited_once() + mock_setup_entry.assert_awaited_once() + + async def test_options_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, diff --git a/tests/components/nobo_hub/test_diagnostics.py b/tests/components/nobo_hub/test_diagnostics.py new file mode 100644 index 000000000000..2fcdeb95328c --- /dev/null +++ b/tests/components/nobo_hub/test_diagnostics.py @@ -0,0 +1,53 @@ +"""Tests for the Nobø Ecohub diagnostics.""" + +from unittest.mock import MagicMock + +from pynobo import nobo as pynobo_nobo +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.diagnostics import REDACTED +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_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert result == snapshot + + +async def test_entry_diagnostics_redacts_unknown_model_name( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + mock_nobo_hub: MagicMock, +) -> None: + """An unknown model's name embeds the serial, so it is dropped; model_id is kept.""" + mock_nobo_hub.components = { + "999000012345": { + "serial": "999000012345", + "name": "Mystery device", + "zone_id": "1", + "model": pynobo_nobo.Model( + model_id="999", + type=pynobo_nobo.Model.UNKNOWN, + name="Unknown (serial number: 999 000 012 345)", + ), + }, + } + + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + component = result["components"][0] + assert component["serial"] == REDACTED + assert component["model"]["model_id"] == "999" + assert component["model"]["name"] == REDACTED diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 7c628c6ec5bb..f89c50b5bffc 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -1,8 +1,9 @@ """Tests for the Nobø Ecohub integration setup.""" +import logging from unittest.mock import MagicMock -from pynobo import nobo as pynobo_nobo +from pynobo import PynoboConnectionError, nobo as pynobo_nobo import pytest from homeassistant.components.nobo_hub.const import ( @@ -11,15 +12,29 @@ from homeassistant.components.nobo_hub.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er +from . import ( + device_identifiers, + dispatch_hub_update, + entity_unique_ids, + fire_hub_connection, + fire_hub_update, +) from .conftest import SERIAL, STORED_IP from tests.common import MockConfigEntry NEW_IP = "192.168.1.55" +GLOBAL_ENTITY = "select.my_eco_hub_global_override" + + +@pytest.fixture +def platforms(request: pytest.FixtureRequest) -> list[Platform]: + """Default to select; override per-test via indirect parametrize.""" + return getattr(request, "param", [Platform.SELECT]) async def test_setup_uses_stored_ip( @@ -46,7 +61,7 @@ async def test_setup_rediscovery_updates_ip( """A failed direct connect falls back to rediscovery and persists the new IP.""" mock_config_entry.add_to_hass(hass) failing_hub = MagicMock(spec=pynobo_nobo) - failing_hub.connect.side_effect = OSError("Unreachable") + failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [failing_hub, mock_nobo_class.return_value] mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} @@ -68,7 +83,7 @@ async def test_setup_retries_when_rediscovery_finds_nothing( """Setup retries when stored IP fails and rediscovery is empty.""" mock_config_entry.add_to_hass(hass) failing_hub = MagicMock(spec=pynobo_nobo) - failing_hub.connect.side_effect = OSError("Unreachable") + failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [failing_hub] mock_nobo_class.async_discover_hubs.return_value = set() @@ -91,9 +106,9 @@ async def test_setup_retries_when_rediscovered_ip_also_fails( """Setup retries when both stored and rediscovered IPs fail.""" mock_config_entry.add_to_hass(hass) first_failing_hub = MagicMock(spec=pynobo_nobo) - first_failing_hub.connect.side_effect = OSError("Unreachable") + first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") second_failing_hub = MagicMock(spec=pynobo_nobo) - second_failing_hub.connect.side_effect = OSError("Unreachable") + second_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub] mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} @@ -108,6 +123,27 @@ async def test_setup_retries_when_rediscovered_ip_also_fails( } +async def test_setup_does_not_catch_plain_os_error_on_rediscovered_ip( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_class: MagicMock, +) -> None: + """A plain OSError from the rediscovered IP is not caught by the fallback.""" + mock_config_entry.add_to_hass(hass) + first_failing_hub = MagicMock(spec=pynobo_nobo) + first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable") + second_failing_hub = MagicMock(spec=pynobo_nobo) + second_failing_hub.connect.side_effect = OSError("boom") + mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub] + mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)} + + assert not 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 + assert mock_nobo_class.call_count == 2 + + @pytest.mark.parametrize( ("stored_options", "expected_options"), [ @@ -224,3 +260,243 @@ async def test_setup_registers_hub_device_with_mac( assert device.connections == { (dr.CONNECTION_NETWORK_MAC, "7c:83:06:01:11:92"), } + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_available_when_hub_connected(hass: HomeAssistant) -> None: + """Entities are available when the hub reports connected.""" + state = hass.states.get(GLOBAL_ENTITY) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_unavailable_on_disconnect_and_recovers( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Entities become unavailable on disconnect and recover on reconnect.""" + assert hass.states.get(GLOBAL_ENTITY).state != STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, False) + assert hass.states.get(GLOBAL_ENTITY).state == STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, True) + assert hass.states.get(GLOBAL_ENTITY).state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_log_on_disconnect_and_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Disconnects and reconnects both log at info level.""" + caplog.clear() + await fire_hub_connection(hass, mock_nobo_hub, False) + assert any( + record.levelno == logging.INFO + and "Lost connection to Nobø Ecohub" in record.message + for record in caplog.records + ) + + caplog.clear() + await fire_hub_connection(hass, mock_nobo_hub, True) + assert any( + record.levelno == logging.INFO + and "Reconnected to Nobø Ecohub" in record.message + for record in caplog.records + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_connection_callbacks_deregistered_on_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_hub: MagicMock, +) -> None: + """Every registered connection callback is deregistered on entry unload.""" + registered = mock_nobo_hub.register_connection_callback.call_count + assert registered > 0 + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_nobo_hub.deregister_connection_callback.call_count == registered + + +@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_zone_removed_during_disconnect_stays_unavailable_on_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A zone removed via the Nobø app while disconnected stays unavailable on reconnect. + + The connection callback fires before the data callback (pynobo Option C). + Without the `available` property's existence check, the connection callback's + `_attr_available = True` would briefly flip the entity to available before the + data callback's _read_state could re-mark it unavailable. + """ + entity = "climate.living_room_living_room" + assert hass.states.get(entity).state != STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, False) + assert hass.states.get(entity).state == STATE_UNAVAILABLE + + # Simulate the zone being removed via the Nobø app while disconnected: + # by the time the hub reconnects and _get_initial_data runs, hub.zones + # no longer contains the zone. + mock_nobo_hub.zones = {} + + await fire_hub_connection(hass, mock_nobo_hub, True) + assert hass.states.get(entity).state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_removed_zone_removes_device( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Removing a zone on the hub removes its device but keeps the hub device.""" + entry_id = mock_config_entry.entry_id + assert (DOMAIN, f"{SERIAL}:1") in device_identifiers(device_registry, entry_id) + + del mock_nobo_hub.zones["1"] + await fire_hub_update(hass, mock_nobo_hub) + + identifiers = device_identifiers(device_registry, entry_id) + assert (DOMAIN, f"{SERIAL}:1") not in identifiers + assert (DOMAIN, SERIAL) in identifiers + + +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_removed_component_removes_device( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Removing a temperature-sensor component on the hub removes its device.""" + entry_id = mock_config_entry.entry_id + assert (DOMAIN, "200000059091") in device_identifiers(device_registry, entry_id) + + del mock_nobo_hub.components["200000059091"] + await fire_hub_update(hass, mock_nobo_hub) + + assert (DOMAIN, "200000059091") not in device_identifiers(device_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_disconnected_hub_does_not_remove_devices( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices are retained when topology looks empty because the hub is disconnected.""" + entry_id = mock_config_entry.entry_id + before = device_identifiers(device_registry, entry_id) + + mock_nobo_hub.connected = False + mock_nobo_hub.zones.clear() + mock_nobo_hub.components.clear() + await fire_hub_update(hass, mock_nobo_hub) + + assert device_identifiers(device_registry, entry_id) == before + + +@pytest.mark.parametrize( + "platforms", + [[Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]], + indirect=True, +) +@pytest.mark.usefixtures("init_integration") +async def test_disconnect_does_not_readd_entities_on_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale empty topology while disconnected must not forget known ids. + + Otherwise the reconcile would clear the known-id sets and re-add every + entity on reconnect, colliding with the still-registered unique ids. + """ + saved_zones = dict(mock_nobo_hub.zones) + saved_components = dict(mock_nobo_hub.components) + + mock_nobo_hub.connected = False + mock_nobo_hub.zones.clear() + mock_nobo_hub.components.clear() + await fire_hub_update(hass, mock_nobo_hub) + + mock_nobo_hub.connected = True + mock_nobo_hub.zones.update(saved_zones) + mock_nobo_hub.components.update(saved_components) + await fire_hub_update(hass, mock_nobo_hub) + + assert "already exists" not in caplog.text + + +@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_buffered_remove_then_readd_same_id( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A buffered remove + same-id re-add (no event-loop yield between) re-registers cleanly. + + pynobo can process a delete and an id-reusing add back-to-back before the + loop yields (buffered messages), so synchronous device removal must fully + deregister the old entity before the re-add, or the add collides with the + still-registered unique id. + """ + entry_id = mock_config_entry.entry_id + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + + # Remove then re-add the same id with no await (no event-loop yield) between. + del mock_nobo_hub.zones["2"] + dispatch_hub_update(mock_nobo_hub) + mock_nobo_hub.zones["2"] = zone + dispatch_hub_update(mock_nobo_hub) + await hass.async_block_till_done() + + assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id) + assert "already exists" not in caplog.text + + +@pytest.mark.usefixtures("mock_nobo_class") +async def test_stale_device_pruned_at_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """A device for a zone removed while Home Assistant was down is pruned at setup.""" + mock_config_entry.add_to_hass(hass) + stale_device = (DOMAIN, f"{SERIAL}:99") + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={stale_device}, + ) + entry_id = mock_config_entry.entry_id + assert stale_device in device_identifiers(device_registry, entry_id) + + assert await hass.config_entries.async_setup(entry_id) + await hass.async_block_till_done() + + assert stale_device not in device_identifiers(device_registry, entry_id) diff --git a/tests/components/nobo_hub/test_select.py b/tests/components/nobo_hub/test_select.py index 3c6871254655..31c401543cd9 100644 --- a/tests/components/nobo_hub/test_select.py +++ b/tests/components/nobo_hub/test_select.py @@ -12,12 +12,13 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +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 fire_hub_update +from . import entity_unique_ids, fire_hub_update +from .conftest import SERIAL from tests.common import MockConfigEntry, snapshot_platform @@ -153,11 +154,64 @@ async def test_week_profile_push_update( @pytest.mark.usefixtures("init_integration") -async def test_zone_removed_marks_week_profile_unavailable( +async def test_zone_removed_removes_week_profile_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A zone removed via the Nobø app must not crash and goes unavailable.""" + """Removing a zone via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.zones.pop("1") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(PROFILE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(PROFILE_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears_profile_selector( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) restores its selector.""" + entry_id = mock_config_entry.entry_id + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_new_zone_adds_profile_selector( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone added on the hub at runtime creates a week-profile selector.""" + entry_id = mock_config_entry.entry_id + assert f"{SERIAL}:2:profile" not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + } + await fire_hub_update(hass, mock_nobo_hub) + + assert f"{SERIAL}:2:profile" in entity_unique_ids(entity_registry, entry_id) diff --git a/tests/components/nobo_hub/test_sensor.py b/tests/components/nobo_hub/test_sensor.py index 340a06117561..12fad4c74a7b 100644 --- a/tests/components/nobo_hub/test_sensor.py +++ b/tests/components/nobo_hub/test_sensor.py @@ -5,11 +5,11 @@ from unittest.mock import MagicMock import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import fire_hub_update +from . import entity_unique_ids, fire_hub_update from tests.common import MockConfigEntry, snapshot_platform @@ -58,11 +58,75 @@ async def test_temperature_push_update( @pytest.mark.usefixtures("init_integration") -async def test_component_removed_marks_unavailable( +async def test_component_removed_removes_entity( hass: HomeAssistant, mock_nobo_hub: MagicMock, ) -> None: - """A component removed via the Nobø app must not crash and goes unavailable.""" + """Removing a component via the Nobø app must not crash and removes the entity.""" mock_nobo_hub.components.pop("200000059091") await fire_hub_update(hass, mock_nobo_hub) - assert hass.states.get(TEMPERATURE_ENTITY).state == STATE_UNAVAILABLE + assert hass.states.get(TEMPERATURE_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_component_reappears( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A component removed and re-added under the same serial (the hub reuses serials) reappears.""" + entry_id = mock_config_entry.entry_id + serial = "200000059092" + model = MagicMock() + model.name = "Panel heater" + model.has_temp_sensor = True + component = { + "serial": serial, + "name": "Bedroom sensor", + "zone_id": "1", + "model": model, + } + + mock_nobo_hub.components[serial] = component + await fire_hub_update(hass, mock_nobo_hub) + assert serial in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.components[serial] + await fire_hub_update(hass, mock_nobo_hub) + assert serial not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.components[serial] = component + await fire_hub_update(hass, mock_nobo_hub) + assert serial in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.parametrize( + ("has_temp_sensor", "present"), + [(True, True), (False, False)], + ids=["temp_sensor", "no_temp_sensor"], +) +@pytest.mark.usefixtures("init_integration") +async def test_new_component_added( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + has_temp_sensor: bool, + present: bool, +) -> None: + """A component yields a sensor only when it has a temperature sensor.""" + entry_id = mock_config_entry.entry_id + serial = "200000059092" + model = MagicMock() + model.name = "Panel heater" + model.has_temp_sensor = has_temp_sensor + mock_nobo_hub.components[serial] = { + "serial": serial, + "name": "Bedroom sensor", + "zone_id": "1", + "model": model, + } + await fire_hub_update(hass, mock_nobo_hub) + + assert (serial in entity_unique_ids(entity_registry, entry_id)) is present diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index 25ccfb852ecd..ab284da16b63 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -1,6 +1,7 @@ """Fixtures for the Nord Pool integration.""" from collections.abc import AsyncGenerator +from http import HTTPStatus import json from typing import Any @@ -108,6 +109,28 @@ async def get_data_from_library( }, json=load_json[2], ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2025-10-03", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2025-10-04", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) client = NordPoolClient(aioclient_mock.create_session(hass.loop)) yield client await client._session.close() diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index b03faa74081e..8f3364b15dc6 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -29,7 +29,7 @@ from . import ENTRY_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.freeze_time("2025-10-01T10:00:00+00:00") +@pytest.mark.freeze_time("2025-10-01T10:00:00+02:00") async def test_coordinator( hass: HomeAssistant, get_client: NordPoolClient, @@ -48,11 +48,12 @@ async def test_coordinator( 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.67405" + assert state.state == "1.03744" - 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 + assert "Next data update at 2025-10-01 11:00:00+02:00" in caplog.text + assert "Next listener update at 2025-10-01 10:15:00+02:00" in caplog.text + caplog.clear() with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", @@ -64,10 +65,10 @@ async def test_coordinator( 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 state.state == "0.95013" - 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 + assert "Next data update at 2025-10-01 11:00:00+02:00" not in caplog.text + assert "Next listener update at 2025-10-01 10:30:00+02:00" in caplog.text with ( patch( @@ -80,7 +81,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 == "0.66068" + assert state.state == "0.72279" with ( patch( @@ -94,9 +95,10 @@ 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 == "0.68544" + assert state.state == "0.63858" assert "Authentication error" in caplog.text + caplog.clear() with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", @@ -110,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 == "0.72953" + assert state.state == "0.66068" assert "Empty response" in caplog.text with ( @@ -125,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 == "0.90294" + assert state.state == "0.68544" assert "error" in caplog.text with ( @@ -140,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 == "1.16266" + assert state.state == "0.72953" assert "error" in caplog.text with ( @@ -155,14 +157,14 @@ 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 == "1.90004" + assert state.state == "0.90294" 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 == "3.42983" + assert state.state == "1.16266" # Test manual polling hass.config_entries.async_update_entry( @@ -173,14 +175,14 @@ async def test_coordinator( 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" + assert state.state == "1.90004" # 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" + assert state.state == "3.42983" # Test manually updating the data with ( @@ -201,7 +203,7 @@ async def test_coordinator( 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" + assert state.state == "1.42403" hass.config_entries.async_update_entry( entry=config_entry, pref_disable_polling=False diff --git a/tests/components/novy_cooker_hood/conftest.py b/tests/components/novy_cooker_hood/conftest.py index f9a0b62cf3b1..55f763bfe2fb 100644 --- a/tests/components/novy_cooker_hood/conftest.py +++ b/tests/components/novy_cooker_hood/conftest.py @@ -1,5 +1,8 @@ """Common fixtures for the Novy Cooker Hood tests.""" +from collections.abc import Iterator +from unittest.mock import patch + import pytest from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN @@ -13,6 +16,13 @@ from tests.components.radio_frequency.common import MockRadioFrequencyEntity TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter" +@pytest.fixture(autouse=True) +def mock_command_delay() -> Iterator[None]: + """Drop the inter-command delay so tests don't spend real time waiting.""" + with patch("homeassistant.components.novy_cooker_hood.fan._COMMAND_DELAY", 0): + yield + + @pytest.fixture def mock_config_entry( mock_rf_entity: MockRadioFrequencyEntity, diff --git a/tests/components/novy_cooker_hood/test_fan.py b/tests/components/novy_cooker_hood/test_fan.py index 2a1c8590f931..46ec4301a1fd 100644 --- a/tests/components/novy_cooker_hood/test_fan.py +++ b/tests/components/novy_cooker_hood/test_fan.py @@ -1,5 +1,9 @@ """Tests for the Novy Hood fan platform.""" +from unittest.mock import AsyncMock, call, patch + +import pytest + from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PERCENTAGE_STEP, @@ -19,10 +23,10 @@ from tests.components.radio_frequency.common import MockRadioFrequencyEntity ENTITY_ID = "fan.novy_cooker_hood" +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_turn_on_calibrates_to_level_1( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """Default turn_on sends 4 minus + 1 plus and lands at 25%.""" state = hass.states.get(ENTITY_ID) @@ -47,10 +51,10 @@ async def test_turn_on_calibrates_to_level_1( assert all(c.context is context for c in mock_rf_entity.send_command_calls) +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_turn_on_with_percentage_calibrates_to_level( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """turn_on with percentage targets the matching level via calibration.""" await hass.services.async_call( @@ -67,10 +71,10 @@ async def test_turn_on_with_percentage_calibrates_to_level( assert len(mock_rf_entity.send_command_calls) == 6 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_set_percentage_zero_turns_off( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """set_percentage(0) turns the fan off via the calibration sequence.""" await hass.services.async_call( @@ -87,10 +91,10 @@ async def test_set_percentage_zero_turns_off( assert len(mock_rf_entity.send_command_calls) == 4 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_turn_off_sends_four_minuses( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """turn_off sends 4 minus presses.""" await hass.services.async_call( @@ -107,10 +111,10 @@ async def test_turn_off_sends_four_minuses( assert len(mock_rf_entity.send_command_calls) == 4 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_set_percentage_calibrates( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """set_percentage(75) sends 4 minus + 3 plus and lands at level 3.""" await hass.services.async_call( @@ -127,10 +131,10 @@ async def test_set_percentage_calibrates( assert len(mock_rf_entity.send_command_calls) == 7 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_increase_speed_sends_single_plus( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """increase_speed sends one plus and bumps level by one (no recalibration).""" await hass.services.async_call( @@ -199,10 +203,10 @@ async def test_decrease_speed_sends_single_minus( assert len(mock_rf_entity.send_command_calls) == 1 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_increase_speed_with_step_sends_n_presses( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """increase_speed with percentage_step sends N plus presses (no recalibration).""" await hass.services.async_call( @@ -244,10 +248,10 @@ async def test_decrease_speed_with_step_sends_n_presses( assert len(mock_rf_entity.send_command_calls) == 2 +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_decrease_speed_clamps_at_off( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """decrease_speed at level 0 still sends one minus but level stays at 0.""" await hass.services.async_call( @@ -263,6 +267,31 @@ async def test_decrease_speed_clamps_at_off( assert len(mock_rf_entity.send_command_calls) == 1 +@pytest.mark.usefixtures("init_novy_cooker_hood") +async def test_set_percentage_sleeps_between_presses( + hass: HomeAssistant, + mock_rf_entity: MockRadioFrequencyEntity, +) -> None: + """A delay is awaited between every RF press, including between sequences.""" + delay = 0.5 + with ( + patch("homeassistant.components.novy_cooker_hood.fan._COMMAND_DELAY", delay), + patch( + "homeassistant.components.novy_cooker_hood.fan.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep, + ): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 75}, + blocking=True, + ) + + assert len(mock_rf_entity.send_command_calls) == 7 + assert mock_sleep.await_args_list == [call(delay)] * 6 + + async def test_restore_state( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, diff --git a/tests/components/novy_cooker_hood/test_light.py b/tests/components/novy_cooker_hood/test_light.py index f117f76b1f2f..13125e3dd98b 100644 --- a/tests/components/novy_cooker_hood/test_light.py +++ b/tests/components/novy_cooker_hood/test_light.py @@ -1,5 +1,6 @@ """Tests for the Novy Hood light platform.""" +import pytest from rf_protocols.codes.novy.cooker_hood import NovyCookerHoodButton from homeassistant.components.light import ( @@ -15,6 +16,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import entity_registry as er from .conftest import TRANSMITTER_ENTITY_ID @@ -25,10 +27,10 @@ from tests.components.radio_frequency.common import MockRadioFrequencyEntity ENTITY_ID = "light.novy_cooker_hood_light" +@pytest.mark.usefixtures("init_novy_cooker_hood") async def test_turn_on_and_off_send_light_once_each( hass: HomeAssistant, mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """Turn on sends a light toggle and flips is_on; turn off does the same.""" state = hass.states.get(ENTITY_ID) @@ -87,12 +89,35 @@ async def test_restore_state( assert state.state == STATE_ON +@pytest.mark.usefixtures("mock_rf_entity", "init_novy_cooker_hood") async def test_entity_follows_transmitter_availability( hass: HomeAssistant, - mock_rf_entity: MockRadioFrequencyEntity, - init_novy_cooker_hood: MockConfigEntry, ) -> None: """The light becomes unavailable when the transmitter does, and back.""" await assert_availability_follows_source_entity( hass, ENTITY_ID, TRANSMITTER_ENTITY_ID ) + + +async def test_tracking_follows_transmitter_rename( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rf_entity: MockRadioFrequencyEntity, + init_novy_cooker_hood: MockConfigEntry, +) -> None: + """Availability tracking and sending survive a transmitter entity rename.""" + new_transmitter_id = "radio_frequency.renamed_transmitter" + entity_registry.async_update_entity( + TRANSMITTER_ENTITY_ID, new_entity_id=new_transmitter_id + ) + await hass.async_block_till_done() + + await assert_availability_follows_source_entity(hass, ENTITY_ID, new_transmitter_id) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert len(mock_rf_entity.send_command_calls) == 1 diff --git a/tests/components/nrgkick/snapshots/test_device_tracker.ambr b/tests/components/nrgkick/snapshots/test_device_tracker.ambr index fee6fa604b8d..6fd55e796937 100644 --- a/tests/components/nrgkick/snapshots/test_device_tracker.ambr +++ b/tests/components/nrgkick/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 1.5, : list([ ]), - : 47.0748, - : 15.4376, + : 47.0748, + : 15.4376, : , : , }), diff --git a/tests/components/nrgkick/snapshots/test_init.ambr b/tests/components/nrgkick/snapshots/test_init.ambr index ef360a6b468e..4723836a404f 100644 --- a/tests/components/nrgkick/snapshots/test_init.ambr +++ b/tests/components/nrgkick/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.100', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'NRGkick Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'TEST123456', 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index f920b064f0bc..fbb9697e84ed 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info[device_connections] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Connections', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_info[device_spelling_bee] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Spelling Bee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_info[device_wordle] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wordle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 2a830a5bfc8a..699f9e038a4c 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -63,7 +63,6 @@ def mock_client(): client.ct_connected = True client.cap_available = True client.cap_enabled = True - client.energy = 1000 client.device_info = { "name": "Ohme Home Pro", "model": "Home Pro", diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr index dc49f5f40424..3614096e5390 100644 --- a/tests/components/ohme/snapshots/test_init.ambr +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Ohme Home Pro', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'chargerid', 'sw_version': 'v2.65', 'via_device_id': None, diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index a4e0ece11629..597aff8c7dad 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -107,67 +107,6 @@ 'state': '0', }) # --- -# name: test_sensors[sensor.ohme_home_pro_energy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ohme_home_pro_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Energy', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Energy', - 'platform': 'ohme', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'chargerid_energy', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.ohme_home_pro_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'energy', - : 'Ohme Home Pro Energy', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.ohme_home_pro_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.0', - }) -# --- # name: test_sensors[sensor.ohme_home_pro_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/ohme/test_sensor.py b/tests/components/ohme/test_sensor.py index b7c8f82aafc1..edab5dc5988e 100644 --- a/tests/components/ohme/test_sensor.py +++ b/tests/components/ohme/test_sensor.py @@ -41,15 +41,15 @@ async def test_sensors_unavailable( """Test that sensors show as unavailable after a coordinator failure.""" await setup_integration(hass, mock_config_entry) - state = hass.states.get("sensor.ohme_home_pro_energy") - assert state.state == "1.0" + state = hass.states.get("sensor.ohme_home_pro_status") + assert state.state == "charging" mock_client.async_get_charge_session.side_effect = ApiException freezer.tick(timedelta(seconds=60)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.ohme_home_pro_energy") + state = hass.states.get("sensor.ohme_home_pro_status") assert state.state == STATE_UNAVAILABLE mock_client.async_get_charge_session.side_effect = None @@ -57,5 +57,5 @@ async def test_sensors_unavailable( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get("sensor.ohme_home_pro_energy") - assert state.state == "1.0" + state = hass.states.get("sensor.ohme_home_pro_status") + assert state.state == "charging" diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 712f3cfc322f..77ece15c131c 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -3,7 +3,7 @@ import asyncio from unittest.mock import ANY, AsyncMock, patch -from httpx import ConnectError +from httpx import HTTPError from ollama import ResponseError import pytest @@ -378,7 +378,8 @@ async def test_reauth_flow_success( ("side_effect", "error"), [ (ResponseError(error="Unauthorized", status_code=401), "invalid_auth"), - (ConnectError(message="Connection failed"), "cannot_connect"), + (ConnectionError("Connection failed"), "cannot_connect"), + (TimeoutError(), "cannot_connect"), ], ) async def test_reauth_flow_errors(hass: HomeAssistant, side_effect, error) -> None: @@ -437,7 +438,8 @@ async def test_reauth_flow_errors(hass: HomeAssistant, side_effect, error) -> No @pytest.mark.parametrize( ("side_effect", "error"), [ - (ConnectError(message=""), "cannot_connect"), + (ConnectionError("Failed to connect to Ollama"), "cannot_connect"), + (TimeoutError(), "cannot_connect"), (RuntimeError(), "unknown"), ], ) @@ -462,7 +464,8 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: @pytest.mark.parametrize( ("side_effect", "error"), [ - (ConnectError(message=""), "cannot_connect"), + (ConnectionError(), "cannot_connect"), + (TimeoutError(), "cannot_connect"), (RuntimeError(), "unknown"), ], ) @@ -513,15 +516,24 @@ async def test_form_invalid_url(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "invalid_url"} +@pytest.mark.parametrize( + "side_effect", + [ + TimeoutError(), + ConnectionError("Failed to connect to Ollama"), + HTTPError("HTTP error"), + ], +) async def test_subentry_connection_error( hass: HomeAssistant, mock_init_component, mock_config_entry: MockConfigEntry, + side_effect: Exception, ) -> None: """Test subentry creation when connection to Ollama server fails.""" with patch( "ollama.AsyncClient.list", - side_effect=ConnectError("Connection failed"), + side_effect=side_effect, ): new_flow = await hass.config_entries.subentries.async_init( (mock_config_entry.entry_id, "conversation"), diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 530210e7f324..0644a9faa891 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components import conversation, ollama from homeassistant.components.conversation import trace +from homeassistant.components.llm import LLMTools from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -292,7 +293,7 @@ async def test_template_variables( ), ], ) -@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -314,7 +315,7 @@ async def test_function_call( ) mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) def completion_result(*args, messages, **kwargs): for message in messages: @@ -379,7 +380,7 @@ async def test_function_call( ) -@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.llm.async_get_tools", new_callable=AsyncMock) async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -398,7 +399,7 @@ async def test_function_exception( ) mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") - mock_get_tools.return_value = [mock_tool] + mock_get_tools.return_value = LLMTools(tools=[mock_tool]) def completion_result(*args, messages, **kwargs): for message in messages: diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index d16d4fd4c0b4..340d1dcb7249 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -732,7 +732,7 @@ async def test_migration_from_v2_1( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/omie/__init__.py b/tests/components/omie/__init__.py index f84145810296..b07d40d8df39 100644 --- a/tests/components/omie/__init__.py +++ b/tests/components/omie/__init__.py @@ -4,6 +4,19 @@ from datetime import date from pyomie.model import OMIEResults +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the OMIE integration.""" + 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() + def price_enc(country: int, day: int, hour: int, minute: int) -> float: """Encode the given data into a price. diff --git a/tests/components/omie/conftest.py b/tests/components/omie/conftest.py index 5c722fd01077..d13ececb629e 100644 --- a/tests/components/omie/conftest.py +++ b/tests/components/omie/conftest.py @@ -92,6 +92,76 @@ def mock_omie_results_jan15() -> OMIEResults: ) +@pytest.fixture +def mock_omie_results_oct15() -> OMIEResults: + """Return mock OMIEResults for 2025-10-15.""" + test_date = dt.date(2025, 10, 15) + spot_data = SpotData( + url="https://example.com?date=2025-10-15", + market_date=test_date.isoformat(), + header="Test Data Oct 15", + es_pt_total_power=[], + es_purchases_power=[], + pt_purchases_power=[], + es_sales_power=[], + pt_sales_power=[], + es_pt_power=[], + es_to_pt_exports_power=[], + es_from_pt_imports_power=[], + es_spot_price=[ + price_enc(country=34, day=15, hour=h, minute=m) + for h in range(24) + for m in (0, 15, 30, 45) + ], + pt_spot_price=[ + price_enc(country=351, day=15, hour=h, minute=m) + for h in range(24) + for m in (0, 15, 30, 45) + ], + ) + return OMIEResults( + updated_at=dt.datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + market_date=test_date, + contents=spot_data, + raw=json.dumps(spot_data), + ) + + +@pytest.fixture +def mock_omie_results_oct26_dst() -> OMIEResults: + """Return mock OMIEResults for 2025-10-26, the 25-hour CET DST fall-back day.""" + test_date = dt.date(2025, 10, 26) + spot_data = SpotData( + url="https://example.com?date=2025-10-26", + market_date=test_date.isoformat(), + header="Test Data Oct 26", + es_pt_total_power=[], + es_purchases_power=[], + pt_purchases_power=[], + es_sales_power=[], + pt_sales_power=[], + es_pt_power=[], + es_to_pt_exports_power=[], + es_from_pt_imports_power=[], + es_spot_price=[ + price_enc(country=34, day=26, hour=h, minute=m) + for h in range(25) + for m in (0, 15, 30, 45) + ], + pt_spot_price=[ + price_enc(country=351, day=26, hour=h, minute=m) + for h in range(25) + for m in (0, 15, 30, 45) + ], + ) + return OMIEResults( + updated_at=dt.datetime.now(), # pylint: disable=home-assistant-enforce-naive-now + market_date=test_date, + contents=spot_data, + raw=json.dumps(spot_data), + ) + + @pytest.fixture def mock_omie_results_jan16() -> OMIEResults: """Return mock OMIEResults for 2024-01-16.""" diff --git a/tests/components/omie/snapshots/test_services.ambr b/tests/components/omie/snapshots/test_services.ambr new file mode 100644 index 000000000000..d8768b762e97 --- /dev/null +++ b/tests/components/omie/snapshots/test_services.ambr @@ -0,0 +1,1941 @@ +# serializer version: 1 +# name: test_get_prices_for_date[both] + dict({ + 'es': list([ + dict({ + 'end': '2025-10-15T00:15:00+02:00', + 'price': 34150000.0, + 'start': '2025-10-15T00:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:30:00+02:00', + 'price': 34150015.0, + 'start': '2025-10-15T00:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:45:00+02:00', + 'price': 34150030.0, + 'start': '2025-10-15T00:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:00:00+02:00', + 'price': 34150045.0, + 'start': '2025-10-15T00:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:15:00+02:00', + 'price': 34150100.0, + 'start': '2025-10-15T01:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:30:00+02:00', + 'price': 34150115.0, + 'start': '2025-10-15T01:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:45:00+02:00', + 'price': 34150130.0, + 'start': '2025-10-15T01:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:00:00+02:00', + 'price': 34150145.0, + 'start': '2025-10-15T01:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:15:00+02:00', + 'price': 34150200.0, + 'start': '2025-10-15T02:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:30:00+02:00', + 'price': 34150215.0, + 'start': '2025-10-15T02:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:45:00+02:00', + 'price': 34150230.0, + 'start': '2025-10-15T02:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:00:00+02:00', + 'price': 34150245.0, + 'start': '2025-10-15T02:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:15:00+02:00', + 'price': 34150300.0, + 'start': '2025-10-15T03:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:30:00+02:00', + 'price': 34150315.0, + 'start': '2025-10-15T03:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:45:00+02:00', + 'price': 34150330.0, + 'start': '2025-10-15T03:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:00:00+02:00', + 'price': 34150345.0, + 'start': '2025-10-15T03:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:15:00+02:00', + 'price': 34150400.0, + 'start': '2025-10-15T04:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:30:00+02:00', + 'price': 34150415.0, + 'start': '2025-10-15T04:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:45:00+02:00', + 'price': 34150430.0, + 'start': '2025-10-15T04:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:00:00+02:00', + 'price': 34150445.0, + 'start': '2025-10-15T04:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:15:00+02:00', + 'price': 34150500.0, + 'start': '2025-10-15T05:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:30:00+02:00', + 'price': 34150515.0, + 'start': '2025-10-15T05:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:45:00+02:00', + 'price': 34150530.0, + 'start': '2025-10-15T05:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:00:00+02:00', + 'price': 34150545.0, + 'start': '2025-10-15T05:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:15:00+02:00', + 'price': 34150600.0, + 'start': '2025-10-15T06:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:30:00+02:00', + 'price': 34150615.0, + 'start': '2025-10-15T06:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:45:00+02:00', + 'price': 34150630.0, + 'start': '2025-10-15T06:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:00:00+02:00', + 'price': 34150645.0, + 'start': '2025-10-15T06:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:15:00+02:00', + 'price': 34150700.0, + 'start': '2025-10-15T07:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:30:00+02:00', + 'price': 34150715.0, + 'start': '2025-10-15T07:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:45:00+02:00', + 'price': 34150730.0, + 'start': '2025-10-15T07:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:00:00+02:00', + 'price': 34150745.0, + 'start': '2025-10-15T07:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:15:00+02:00', + 'price': 34150800.0, + 'start': '2025-10-15T08:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:30:00+02:00', + 'price': 34150815.0, + 'start': '2025-10-15T08:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:45:00+02:00', + 'price': 34150830.0, + 'start': '2025-10-15T08:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:00:00+02:00', + 'price': 34150845.0, + 'start': '2025-10-15T08:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:15:00+02:00', + 'price': 34150900.0, + 'start': '2025-10-15T09:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:30:00+02:00', + 'price': 34150915.0, + 'start': '2025-10-15T09:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:45:00+02:00', + 'price': 34150930.0, + 'start': '2025-10-15T09:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:00:00+02:00', + 'price': 34150945.0, + 'start': '2025-10-15T09:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:15:00+02:00', + 'price': 34151000.0, + 'start': '2025-10-15T10:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:30:00+02:00', + 'price': 34151015.0, + 'start': '2025-10-15T10:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:45:00+02:00', + 'price': 34151030.0, + 'start': '2025-10-15T10:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:00:00+02:00', + 'price': 34151045.0, + 'start': '2025-10-15T10:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:15:00+02:00', + 'price': 34151100.0, + 'start': '2025-10-15T11:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:30:00+02:00', + 'price': 34151115.0, + 'start': '2025-10-15T11:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:45:00+02:00', + 'price': 34151130.0, + 'start': '2025-10-15T11:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:00:00+02:00', + 'price': 34151145.0, + 'start': '2025-10-15T11:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:15:00+02:00', + 'price': 34151200.0, + 'start': '2025-10-15T12:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:30:00+02:00', + 'price': 34151215.0, + 'start': '2025-10-15T12:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:45:00+02:00', + 'price': 34151230.0, + 'start': '2025-10-15T12:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:00:00+02:00', + 'price': 34151245.0, + 'start': '2025-10-15T12:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:15:00+02:00', + 'price': 34151300.0, + 'start': '2025-10-15T13:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:30:00+02:00', + 'price': 34151315.0, + 'start': '2025-10-15T13:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:45:00+02:00', + 'price': 34151330.0, + 'start': '2025-10-15T13:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:00:00+02:00', + 'price': 34151345.0, + 'start': '2025-10-15T13:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:15:00+02:00', + 'price': 34151400.0, + 'start': '2025-10-15T14:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:30:00+02:00', + 'price': 34151415.0, + 'start': '2025-10-15T14:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:45:00+02:00', + 'price': 34151430.0, + 'start': '2025-10-15T14:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:00:00+02:00', + 'price': 34151445.0, + 'start': '2025-10-15T14:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:15:00+02:00', + 'price': 34151500.0, + 'start': '2025-10-15T15:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:30:00+02:00', + 'price': 34151515.0, + 'start': '2025-10-15T15:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:45:00+02:00', + 'price': 34151530.0, + 'start': '2025-10-15T15:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:00:00+02:00', + 'price': 34151545.0, + 'start': '2025-10-15T15:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:15:00+02:00', + 'price': 34151600.0, + 'start': '2025-10-15T16:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:30:00+02:00', + 'price': 34151615.0, + 'start': '2025-10-15T16:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:45:00+02:00', + 'price': 34151630.0, + 'start': '2025-10-15T16:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:00:00+02:00', + 'price': 34151645.0, + 'start': '2025-10-15T16:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:15:00+02:00', + 'price': 34151700.0, + 'start': '2025-10-15T17:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:30:00+02:00', + 'price': 34151715.0, + 'start': '2025-10-15T17:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:45:00+02:00', + 'price': 34151730.0, + 'start': '2025-10-15T17:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:00:00+02:00', + 'price': 34151745.0, + 'start': '2025-10-15T17:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:15:00+02:00', + 'price': 34151800.0, + 'start': '2025-10-15T18:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:30:00+02:00', + 'price': 34151815.0, + 'start': '2025-10-15T18:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:45:00+02:00', + 'price': 34151830.0, + 'start': '2025-10-15T18:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:00:00+02:00', + 'price': 34151845.0, + 'start': '2025-10-15T18:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:15:00+02:00', + 'price': 34151900.0, + 'start': '2025-10-15T19:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:30:00+02:00', + 'price': 34151915.0, + 'start': '2025-10-15T19:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:45:00+02:00', + 'price': 34151930.0, + 'start': '2025-10-15T19:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:00:00+02:00', + 'price': 34151945.0, + 'start': '2025-10-15T19:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:15:00+02:00', + 'price': 34152000.0, + 'start': '2025-10-15T20:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:30:00+02:00', + 'price': 34152015.0, + 'start': '2025-10-15T20:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:45:00+02:00', + 'price': 34152030.0, + 'start': '2025-10-15T20:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:00:00+02:00', + 'price': 34152045.0, + 'start': '2025-10-15T20:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:15:00+02:00', + 'price': 34152100.0, + 'start': '2025-10-15T21:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:30:00+02:00', + 'price': 34152115.0, + 'start': '2025-10-15T21:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:45:00+02:00', + 'price': 34152130.0, + 'start': '2025-10-15T21:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:00:00+02:00', + 'price': 34152145.0, + 'start': '2025-10-15T21:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:15:00+02:00', + 'price': 34152200.0, + 'start': '2025-10-15T22:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:30:00+02:00', + 'price': 34152215.0, + 'start': '2025-10-15T22:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:45:00+02:00', + 'price': 34152230.0, + 'start': '2025-10-15T22:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:00:00+02:00', + 'price': 34152245.0, + 'start': '2025-10-15T22:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:15:00+02:00', + 'price': 34152300.0, + 'start': '2025-10-15T23:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:30:00+02:00', + 'price': 34152315.0, + 'start': '2025-10-15T23:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:45:00+02:00', + 'price': 34152330.0, + 'start': '2025-10-15T23:30:00+02:00', + }), + dict({ + 'end': '2025-10-16T00:00:00+02:00', + 'price': 34152345.0, + 'start': '2025-10-15T23:45:00+02:00', + }), + ]), + 'pt': list([ + dict({ + 'end': '2025-10-15T00:15:00+02:00', + 'price': 351150000.0, + 'start': '2025-10-15T00:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:30:00+02:00', + 'price': 351150015.0, + 'start': '2025-10-15T00:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:45:00+02:00', + 'price': 351150030.0, + 'start': '2025-10-15T00:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:00:00+02:00', + 'price': 351150045.0, + 'start': '2025-10-15T00:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:15:00+02:00', + 'price': 351150100.0, + 'start': '2025-10-15T01:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:30:00+02:00', + 'price': 351150115.0, + 'start': '2025-10-15T01:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:45:00+02:00', + 'price': 351150130.0, + 'start': '2025-10-15T01:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:00:00+02:00', + 'price': 351150145.0, + 'start': '2025-10-15T01:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:15:00+02:00', + 'price': 351150200.0, + 'start': '2025-10-15T02:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:30:00+02:00', + 'price': 351150215.0, + 'start': '2025-10-15T02:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:45:00+02:00', + 'price': 351150230.0, + 'start': '2025-10-15T02:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:00:00+02:00', + 'price': 351150245.0, + 'start': '2025-10-15T02:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:15:00+02:00', + 'price': 351150300.0, + 'start': '2025-10-15T03:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:30:00+02:00', + 'price': 351150315.0, + 'start': '2025-10-15T03:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:45:00+02:00', + 'price': 351150330.0, + 'start': '2025-10-15T03:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:00:00+02:00', + 'price': 351150345.0, + 'start': '2025-10-15T03:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:15:00+02:00', + 'price': 351150400.0, + 'start': '2025-10-15T04:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:30:00+02:00', + 'price': 351150415.0, + 'start': '2025-10-15T04:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:45:00+02:00', + 'price': 351150430.0, + 'start': '2025-10-15T04:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:00:00+02:00', + 'price': 351150445.0, + 'start': '2025-10-15T04:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:15:00+02:00', + 'price': 351150500.0, + 'start': '2025-10-15T05:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:30:00+02:00', + 'price': 351150515.0, + 'start': '2025-10-15T05:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:45:00+02:00', + 'price': 351150530.0, + 'start': '2025-10-15T05:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:00:00+02:00', + 'price': 351150545.0, + 'start': '2025-10-15T05:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:15:00+02:00', + 'price': 351150600.0, + 'start': '2025-10-15T06:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:30:00+02:00', + 'price': 351150615.0, + 'start': '2025-10-15T06:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:45:00+02:00', + 'price': 351150630.0, + 'start': '2025-10-15T06:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:00:00+02:00', + 'price': 351150645.0, + 'start': '2025-10-15T06:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:15:00+02:00', + 'price': 351150700.0, + 'start': '2025-10-15T07:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:30:00+02:00', + 'price': 351150715.0, + 'start': '2025-10-15T07:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:45:00+02:00', + 'price': 351150730.0, + 'start': '2025-10-15T07:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:00:00+02:00', + 'price': 351150745.0, + 'start': '2025-10-15T07:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:15:00+02:00', + 'price': 351150800.0, + 'start': '2025-10-15T08:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:30:00+02:00', + 'price': 351150815.0, + 'start': '2025-10-15T08:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:45:00+02:00', + 'price': 351150830.0, + 'start': '2025-10-15T08:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:00:00+02:00', + 'price': 351150845.0, + 'start': '2025-10-15T08:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:15:00+02:00', + 'price': 351150900.0, + 'start': '2025-10-15T09:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:30:00+02:00', + 'price': 351150915.0, + 'start': '2025-10-15T09:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:45:00+02:00', + 'price': 351150930.0, + 'start': '2025-10-15T09:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:00:00+02:00', + 'price': 351150945.0, + 'start': '2025-10-15T09:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:15:00+02:00', + 'price': 351151000.0, + 'start': '2025-10-15T10:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:30:00+02:00', + 'price': 351151015.0, + 'start': '2025-10-15T10:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:45:00+02:00', + 'price': 351151030.0, + 'start': '2025-10-15T10:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:00:00+02:00', + 'price': 351151045.0, + 'start': '2025-10-15T10:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:15:00+02:00', + 'price': 351151100.0, + 'start': '2025-10-15T11:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:30:00+02:00', + 'price': 351151115.0, + 'start': '2025-10-15T11:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:45:00+02:00', + 'price': 351151130.0, + 'start': '2025-10-15T11:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:00:00+02:00', + 'price': 351151145.0, + 'start': '2025-10-15T11:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:15:00+02:00', + 'price': 351151200.0, + 'start': '2025-10-15T12:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:30:00+02:00', + 'price': 351151215.0, + 'start': '2025-10-15T12:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:45:00+02:00', + 'price': 351151230.0, + 'start': '2025-10-15T12:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:00:00+02:00', + 'price': 351151245.0, + 'start': '2025-10-15T12:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:15:00+02:00', + 'price': 351151300.0, + 'start': '2025-10-15T13:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:30:00+02:00', + 'price': 351151315.0, + 'start': '2025-10-15T13:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:45:00+02:00', + 'price': 351151330.0, + 'start': '2025-10-15T13:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:00:00+02:00', + 'price': 351151345.0, + 'start': '2025-10-15T13:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:15:00+02:00', + 'price': 351151400.0, + 'start': '2025-10-15T14:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:30:00+02:00', + 'price': 351151415.0, + 'start': '2025-10-15T14:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:45:00+02:00', + 'price': 351151430.0, + 'start': '2025-10-15T14:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:00:00+02:00', + 'price': 351151445.0, + 'start': '2025-10-15T14:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:15:00+02:00', + 'price': 351151500.0, + 'start': '2025-10-15T15:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:30:00+02:00', + 'price': 351151515.0, + 'start': '2025-10-15T15:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:45:00+02:00', + 'price': 351151530.0, + 'start': '2025-10-15T15:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:00:00+02:00', + 'price': 351151545.0, + 'start': '2025-10-15T15:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:15:00+02:00', + 'price': 351151600.0, + 'start': '2025-10-15T16:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:30:00+02:00', + 'price': 351151615.0, + 'start': '2025-10-15T16:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:45:00+02:00', + 'price': 351151630.0, + 'start': '2025-10-15T16:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:00:00+02:00', + 'price': 351151645.0, + 'start': '2025-10-15T16:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:15:00+02:00', + 'price': 351151700.0, + 'start': '2025-10-15T17:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:30:00+02:00', + 'price': 351151715.0, + 'start': '2025-10-15T17:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:45:00+02:00', + 'price': 351151730.0, + 'start': '2025-10-15T17:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:00:00+02:00', + 'price': 351151745.0, + 'start': '2025-10-15T17:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:15:00+02:00', + 'price': 351151800.0, + 'start': '2025-10-15T18:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:30:00+02:00', + 'price': 351151815.0, + 'start': '2025-10-15T18:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:45:00+02:00', + 'price': 351151830.0, + 'start': '2025-10-15T18:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:00:00+02:00', + 'price': 351151845.0, + 'start': '2025-10-15T18:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:15:00+02:00', + 'price': 351151900.0, + 'start': '2025-10-15T19:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:30:00+02:00', + 'price': 351151915.0, + 'start': '2025-10-15T19:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:45:00+02:00', + 'price': 351151930.0, + 'start': '2025-10-15T19:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:00:00+02:00', + 'price': 351151945.0, + 'start': '2025-10-15T19:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:15:00+02:00', + 'price': 351152000.0, + 'start': '2025-10-15T20:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:30:00+02:00', + 'price': 351152015.0, + 'start': '2025-10-15T20:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:45:00+02:00', + 'price': 351152030.0, + 'start': '2025-10-15T20:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:00:00+02:00', + 'price': 351152045.0, + 'start': '2025-10-15T20:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:15:00+02:00', + 'price': 351152100.0, + 'start': '2025-10-15T21:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:30:00+02:00', + 'price': 351152115.0, + 'start': '2025-10-15T21:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:45:00+02:00', + 'price': 351152130.0, + 'start': '2025-10-15T21:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:00:00+02:00', + 'price': 351152145.0, + 'start': '2025-10-15T21:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:15:00+02:00', + 'price': 351152200.0, + 'start': '2025-10-15T22:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:30:00+02:00', + 'price': 351152215.0, + 'start': '2025-10-15T22:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:45:00+02:00', + 'price': 351152230.0, + 'start': '2025-10-15T22:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:00:00+02:00', + 'price': 351152245.0, + 'start': '2025-10-15T22:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:15:00+02:00', + 'price': 351152300.0, + 'start': '2025-10-15T23:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:30:00+02:00', + 'price': 351152315.0, + 'start': '2025-10-15T23:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:45:00+02:00', + 'price': 351152330.0, + 'start': '2025-10-15T23:30:00+02:00', + }), + dict({ + 'end': '2025-10-16T00:00:00+02:00', + 'price': 351152345.0, + 'start': '2025-10-15T23:45:00+02:00', + }), + ]), + }) +# --- +# name: test_get_prices_for_date[portugal] + dict({ + 'pt': list([ + dict({ + 'end': '2025-10-15T00:15:00+02:00', + 'price': 351150000.0, + 'start': '2025-10-15T00:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:30:00+02:00', + 'price': 351150015.0, + 'start': '2025-10-15T00:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:45:00+02:00', + 'price': 351150030.0, + 'start': '2025-10-15T00:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:00:00+02:00', + 'price': 351150045.0, + 'start': '2025-10-15T00:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:15:00+02:00', + 'price': 351150100.0, + 'start': '2025-10-15T01:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:30:00+02:00', + 'price': 351150115.0, + 'start': '2025-10-15T01:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:45:00+02:00', + 'price': 351150130.0, + 'start': '2025-10-15T01:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:00:00+02:00', + 'price': 351150145.0, + 'start': '2025-10-15T01:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:15:00+02:00', + 'price': 351150200.0, + 'start': '2025-10-15T02:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:30:00+02:00', + 'price': 351150215.0, + 'start': '2025-10-15T02:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:45:00+02:00', + 'price': 351150230.0, + 'start': '2025-10-15T02:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:00:00+02:00', + 'price': 351150245.0, + 'start': '2025-10-15T02:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:15:00+02:00', + 'price': 351150300.0, + 'start': '2025-10-15T03:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:30:00+02:00', + 'price': 351150315.0, + 'start': '2025-10-15T03:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:45:00+02:00', + 'price': 351150330.0, + 'start': '2025-10-15T03:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:00:00+02:00', + 'price': 351150345.0, + 'start': '2025-10-15T03:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:15:00+02:00', + 'price': 351150400.0, + 'start': '2025-10-15T04:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:30:00+02:00', + 'price': 351150415.0, + 'start': '2025-10-15T04:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:45:00+02:00', + 'price': 351150430.0, + 'start': '2025-10-15T04:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:00:00+02:00', + 'price': 351150445.0, + 'start': '2025-10-15T04:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:15:00+02:00', + 'price': 351150500.0, + 'start': '2025-10-15T05:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:30:00+02:00', + 'price': 351150515.0, + 'start': '2025-10-15T05:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:45:00+02:00', + 'price': 351150530.0, + 'start': '2025-10-15T05:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:00:00+02:00', + 'price': 351150545.0, + 'start': '2025-10-15T05:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:15:00+02:00', + 'price': 351150600.0, + 'start': '2025-10-15T06:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:30:00+02:00', + 'price': 351150615.0, + 'start': '2025-10-15T06:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:45:00+02:00', + 'price': 351150630.0, + 'start': '2025-10-15T06:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:00:00+02:00', + 'price': 351150645.0, + 'start': '2025-10-15T06:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:15:00+02:00', + 'price': 351150700.0, + 'start': '2025-10-15T07:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:30:00+02:00', + 'price': 351150715.0, + 'start': '2025-10-15T07:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:45:00+02:00', + 'price': 351150730.0, + 'start': '2025-10-15T07:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:00:00+02:00', + 'price': 351150745.0, + 'start': '2025-10-15T07:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:15:00+02:00', + 'price': 351150800.0, + 'start': '2025-10-15T08:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:30:00+02:00', + 'price': 351150815.0, + 'start': '2025-10-15T08:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:45:00+02:00', + 'price': 351150830.0, + 'start': '2025-10-15T08:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:00:00+02:00', + 'price': 351150845.0, + 'start': '2025-10-15T08:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:15:00+02:00', + 'price': 351150900.0, + 'start': '2025-10-15T09:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:30:00+02:00', + 'price': 351150915.0, + 'start': '2025-10-15T09:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:45:00+02:00', + 'price': 351150930.0, + 'start': '2025-10-15T09:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:00:00+02:00', + 'price': 351150945.0, + 'start': '2025-10-15T09:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:15:00+02:00', + 'price': 351151000.0, + 'start': '2025-10-15T10:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:30:00+02:00', + 'price': 351151015.0, + 'start': '2025-10-15T10:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:45:00+02:00', + 'price': 351151030.0, + 'start': '2025-10-15T10:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:00:00+02:00', + 'price': 351151045.0, + 'start': '2025-10-15T10:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:15:00+02:00', + 'price': 351151100.0, + 'start': '2025-10-15T11:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:30:00+02:00', + 'price': 351151115.0, + 'start': '2025-10-15T11:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:45:00+02:00', + 'price': 351151130.0, + 'start': '2025-10-15T11:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:00:00+02:00', + 'price': 351151145.0, + 'start': '2025-10-15T11:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:15:00+02:00', + 'price': 351151200.0, + 'start': '2025-10-15T12:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:30:00+02:00', + 'price': 351151215.0, + 'start': '2025-10-15T12:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:45:00+02:00', + 'price': 351151230.0, + 'start': '2025-10-15T12:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:00:00+02:00', + 'price': 351151245.0, + 'start': '2025-10-15T12:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:15:00+02:00', + 'price': 351151300.0, + 'start': '2025-10-15T13:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:30:00+02:00', + 'price': 351151315.0, + 'start': '2025-10-15T13:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:45:00+02:00', + 'price': 351151330.0, + 'start': '2025-10-15T13:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:00:00+02:00', + 'price': 351151345.0, + 'start': '2025-10-15T13:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:15:00+02:00', + 'price': 351151400.0, + 'start': '2025-10-15T14:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:30:00+02:00', + 'price': 351151415.0, + 'start': '2025-10-15T14:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:45:00+02:00', + 'price': 351151430.0, + 'start': '2025-10-15T14:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:00:00+02:00', + 'price': 351151445.0, + 'start': '2025-10-15T14:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:15:00+02:00', + 'price': 351151500.0, + 'start': '2025-10-15T15:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:30:00+02:00', + 'price': 351151515.0, + 'start': '2025-10-15T15:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:45:00+02:00', + 'price': 351151530.0, + 'start': '2025-10-15T15:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:00:00+02:00', + 'price': 351151545.0, + 'start': '2025-10-15T15:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:15:00+02:00', + 'price': 351151600.0, + 'start': '2025-10-15T16:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:30:00+02:00', + 'price': 351151615.0, + 'start': '2025-10-15T16:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:45:00+02:00', + 'price': 351151630.0, + 'start': '2025-10-15T16:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:00:00+02:00', + 'price': 351151645.0, + 'start': '2025-10-15T16:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:15:00+02:00', + 'price': 351151700.0, + 'start': '2025-10-15T17:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:30:00+02:00', + 'price': 351151715.0, + 'start': '2025-10-15T17:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:45:00+02:00', + 'price': 351151730.0, + 'start': '2025-10-15T17:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:00:00+02:00', + 'price': 351151745.0, + 'start': '2025-10-15T17:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:15:00+02:00', + 'price': 351151800.0, + 'start': '2025-10-15T18:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:30:00+02:00', + 'price': 351151815.0, + 'start': '2025-10-15T18:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:45:00+02:00', + 'price': 351151830.0, + 'start': '2025-10-15T18:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:00:00+02:00', + 'price': 351151845.0, + 'start': '2025-10-15T18:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:15:00+02:00', + 'price': 351151900.0, + 'start': '2025-10-15T19:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:30:00+02:00', + 'price': 351151915.0, + 'start': '2025-10-15T19:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:45:00+02:00', + 'price': 351151930.0, + 'start': '2025-10-15T19:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:00:00+02:00', + 'price': 351151945.0, + 'start': '2025-10-15T19:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:15:00+02:00', + 'price': 351152000.0, + 'start': '2025-10-15T20:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:30:00+02:00', + 'price': 351152015.0, + 'start': '2025-10-15T20:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:45:00+02:00', + 'price': 351152030.0, + 'start': '2025-10-15T20:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:00:00+02:00', + 'price': 351152045.0, + 'start': '2025-10-15T20:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:15:00+02:00', + 'price': 351152100.0, + 'start': '2025-10-15T21:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:30:00+02:00', + 'price': 351152115.0, + 'start': '2025-10-15T21:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:45:00+02:00', + 'price': 351152130.0, + 'start': '2025-10-15T21:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:00:00+02:00', + 'price': 351152145.0, + 'start': '2025-10-15T21:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:15:00+02:00', + 'price': 351152200.0, + 'start': '2025-10-15T22:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:30:00+02:00', + 'price': 351152215.0, + 'start': '2025-10-15T22:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:45:00+02:00', + 'price': 351152230.0, + 'start': '2025-10-15T22:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:00:00+02:00', + 'price': 351152245.0, + 'start': '2025-10-15T22:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:15:00+02:00', + 'price': 351152300.0, + 'start': '2025-10-15T23:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:30:00+02:00', + 'price': 351152315.0, + 'start': '2025-10-15T23:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:45:00+02:00', + 'price': 351152330.0, + 'start': '2025-10-15T23:30:00+02:00', + }), + dict({ + 'end': '2025-10-16T00:00:00+02:00', + 'price': 351152345.0, + 'start': '2025-10-15T23:45:00+02:00', + }), + ]), + }) +# --- +# name: test_get_prices_for_date[spain] + dict({ + 'es': list([ + dict({ + 'end': '2025-10-15T00:15:00+02:00', + 'price': 34150000.0, + 'start': '2025-10-15T00:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:30:00+02:00', + 'price': 34150015.0, + 'start': '2025-10-15T00:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T00:45:00+02:00', + 'price': 34150030.0, + 'start': '2025-10-15T00:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:00:00+02:00', + 'price': 34150045.0, + 'start': '2025-10-15T00:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:15:00+02:00', + 'price': 34150100.0, + 'start': '2025-10-15T01:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:30:00+02:00', + 'price': 34150115.0, + 'start': '2025-10-15T01:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T01:45:00+02:00', + 'price': 34150130.0, + 'start': '2025-10-15T01:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:00:00+02:00', + 'price': 34150145.0, + 'start': '2025-10-15T01:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:15:00+02:00', + 'price': 34150200.0, + 'start': '2025-10-15T02:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:30:00+02:00', + 'price': 34150215.0, + 'start': '2025-10-15T02:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T02:45:00+02:00', + 'price': 34150230.0, + 'start': '2025-10-15T02:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:00:00+02:00', + 'price': 34150245.0, + 'start': '2025-10-15T02:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:15:00+02:00', + 'price': 34150300.0, + 'start': '2025-10-15T03:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:30:00+02:00', + 'price': 34150315.0, + 'start': '2025-10-15T03:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T03:45:00+02:00', + 'price': 34150330.0, + 'start': '2025-10-15T03:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:00:00+02:00', + 'price': 34150345.0, + 'start': '2025-10-15T03:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:15:00+02:00', + 'price': 34150400.0, + 'start': '2025-10-15T04:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:30:00+02:00', + 'price': 34150415.0, + 'start': '2025-10-15T04:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T04:45:00+02:00', + 'price': 34150430.0, + 'start': '2025-10-15T04:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:00:00+02:00', + 'price': 34150445.0, + 'start': '2025-10-15T04:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:15:00+02:00', + 'price': 34150500.0, + 'start': '2025-10-15T05:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:30:00+02:00', + 'price': 34150515.0, + 'start': '2025-10-15T05:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T05:45:00+02:00', + 'price': 34150530.0, + 'start': '2025-10-15T05:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:00:00+02:00', + 'price': 34150545.0, + 'start': '2025-10-15T05:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:15:00+02:00', + 'price': 34150600.0, + 'start': '2025-10-15T06:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:30:00+02:00', + 'price': 34150615.0, + 'start': '2025-10-15T06:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T06:45:00+02:00', + 'price': 34150630.0, + 'start': '2025-10-15T06:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:00:00+02:00', + 'price': 34150645.0, + 'start': '2025-10-15T06:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:15:00+02:00', + 'price': 34150700.0, + 'start': '2025-10-15T07:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:30:00+02:00', + 'price': 34150715.0, + 'start': '2025-10-15T07:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T07:45:00+02:00', + 'price': 34150730.0, + 'start': '2025-10-15T07:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:00:00+02:00', + 'price': 34150745.0, + 'start': '2025-10-15T07:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:15:00+02:00', + 'price': 34150800.0, + 'start': '2025-10-15T08:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:30:00+02:00', + 'price': 34150815.0, + 'start': '2025-10-15T08:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T08:45:00+02:00', + 'price': 34150830.0, + 'start': '2025-10-15T08:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:00:00+02:00', + 'price': 34150845.0, + 'start': '2025-10-15T08:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:15:00+02:00', + 'price': 34150900.0, + 'start': '2025-10-15T09:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:30:00+02:00', + 'price': 34150915.0, + 'start': '2025-10-15T09:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T09:45:00+02:00', + 'price': 34150930.0, + 'start': '2025-10-15T09:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:00:00+02:00', + 'price': 34150945.0, + 'start': '2025-10-15T09:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:15:00+02:00', + 'price': 34151000.0, + 'start': '2025-10-15T10:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:30:00+02:00', + 'price': 34151015.0, + 'start': '2025-10-15T10:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T10:45:00+02:00', + 'price': 34151030.0, + 'start': '2025-10-15T10:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:00:00+02:00', + 'price': 34151045.0, + 'start': '2025-10-15T10:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:15:00+02:00', + 'price': 34151100.0, + 'start': '2025-10-15T11:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:30:00+02:00', + 'price': 34151115.0, + 'start': '2025-10-15T11:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T11:45:00+02:00', + 'price': 34151130.0, + 'start': '2025-10-15T11:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:00:00+02:00', + 'price': 34151145.0, + 'start': '2025-10-15T11:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:15:00+02:00', + 'price': 34151200.0, + 'start': '2025-10-15T12:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:30:00+02:00', + 'price': 34151215.0, + 'start': '2025-10-15T12:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T12:45:00+02:00', + 'price': 34151230.0, + 'start': '2025-10-15T12:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:00:00+02:00', + 'price': 34151245.0, + 'start': '2025-10-15T12:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:15:00+02:00', + 'price': 34151300.0, + 'start': '2025-10-15T13:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:30:00+02:00', + 'price': 34151315.0, + 'start': '2025-10-15T13:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T13:45:00+02:00', + 'price': 34151330.0, + 'start': '2025-10-15T13:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:00:00+02:00', + 'price': 34151345.0, + 'start': '2025-10-15T13:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:15:00+02:00', + 'price': 34151400.0, + 'start': '2025-10-15T14:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:30:00+02:00', + 'price': 34151415.0, + 'start': '2025-10-15T14:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T14:45:00+02:00', + 'price': 34151430.0, + 'start': '2025-10-15T14:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:00:00+02:00', + 'price': 34151445.0, + 'start': '2025-10-15T14:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:15:00+02:00', + 'price': 34151500.0, + 'start': '2025-10-15T15:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:30:00+02:00', + 'price': 34151515.0, + 'start': '2025-10-15T15:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T15:45:00+02:00', + 'price': 34151530.0, + 'start': '2025-10-15T15:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:00:00+02:00', + 'price': 34151545.0, + 'start': '2025-10-15T15:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:15:00+02:00', + 'price': 34151600.0, + 'start': '2025-10-15T16:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:30:00+02:00', + 'price': 34151615.0, + 'start': '2025-10-15T16:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T16:45:00+02:00', + 'price': 34151630.0, + 'start': '2025-10-15T16:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:00:00+02:00', + 'price': 34151645.0, + 'start': '2025-10-15T16:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:15:00+02:00', + 'price': 34151700.0, + 'start': '2025-10-15T17:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:30:00+02:00', + 'price': 34151715.0, + 'start': '2025-10-15T17:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T17:45:00+02:00', + 'price': 34151730.0, + 'start': '2025-10-15T17:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:00:00+02:00', + 'price': 34151745.0, + 'start': '2025-10-15T17:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:15:00+02:00', + 'price': 34151800.0, + 'start': '2025-10-15T18:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:30:00+02:00', + 'price': 34151815.0, + 'start': '2025-10-15T18:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T18:45:00+02:00', + 'price': 34151830.0, + 'start': '2025-10-15T18:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:00:00+02:00', + 'price': 34151845.0, + 'start': '2025-10-15T18:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:15:00+02:00', + 'price': 34151900.0, + 'start': '2025-10-15T19:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:30:00+02:00', + 'price': 34151915.0, + 'start': '2025-10-15T19:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T19:45:00+02:00', + 'price': 34151930.0, + 'start': '2025-10-15T19:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:00:00+02:00', + 'price': 34151945.0, + 'start': '2025-10-15T19:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:15:00+02:00', + 'price': 34152000.0, + 'start': '2025-10-15T20:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:30:00+02:00', + 'price': 34152015.0, + 'start': '2025-10-15T20:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T20:45:00+02:00', + 'price': 34152030.0, + 'start': '2025-10-15T20:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:00:00+02:00', + 'price': 34152045.0, + 'start': '2025-10-15T20:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:15:00+02:00', + 'price': 34152100.0, + 'start': '2025-10-15T21:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:30:00+02:00', + 'price': 34152115.0, + 'start': '2025-10-15T21:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T21:45:00+02:00', + 'price': 34152130.0, + 'start': '2025-10-15T21:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:00:00+02:00', + 'price': 34152145.0, + 'start': '2025-10-15T21:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:15:00+02:00', + 'price': 34152200.0, + 'start': '2025-10-15T22:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:30:00+02:00', + 'price': 34152215.0, + 'start': '2025-10-15T22:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T22:45:00+02:00', + 'price': 34152230.0, + 'start': '2025-10-15T22:30:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:00:00+02:00', + 'price': 34152245.0, + 'start': '2025-10-15T22:45:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:15:00+02:00', + 'price': 34152300.0, + 'start': '2025-10-15T23:00:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:30:00+02:00', + 'price': 34152315.0, + 'start': '2025-10-15T23:15:00+02:00', + }), + dict({ + 'end': '2025-10-15T23:45:00+02:00', + 'price': 34152330.0, + 'start': '2025-10-15T23:30:00+02:00', + }), + dict({ + 'end': '2025-10-16T00:00:00+02:00', + 'price': 34152345.0, + 'start': '2025-10-15T23:45:00+02:00', + }), + ]), + }) +# --- diff --git a/tests/components/omie/test_services.py b/tests/components/omie/test_services.py new file mode 100644 index 000000000000..ff15a58cce6c --- /dev/null +++ b/tests/components/omie/test_services.py @@ -0,0 +1,214 @@ +"""Test the OMIE - Spain and Portugal electricity prices services.""" + +import datetime as dt +from unittest.mock import MagicMock + +import aiohttp +from pyomie.model import OMIEResults +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.omie.const import DOMAIN +from homeassistant.components.omie.services import ( + ATTR_COUNTRIES, + SERVICE_GET_PRICES_FOR_DATE, +) +from homeassistant.const import ATTR_DATE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import setup_integration, spot_price_fetcher + +from tests.common import MockConfigEntry + +TEST_DATE = "2025-10-15" +TEST_DST_DATE = "2025-10-26" + + +@pytest.mark.parametrize( + "countries", + [ + pytest.param(["es"], id="spain"), + pytest.param(["pt"], id="portugal"), + pytest.param(["es", "pt"], id="both"), + ], +) +async def test_get_prices_for_date( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyomie: MagicMock, + mock_omie_results_oct15: OMIEResults, + countries: list[str], + snapshot: SnapshotAssertion, +) -> None: + """Test the get_prices_for_date service response.""" + await setup_integration(hass, mock_config_entry) + mock_pyomie.spot_price.side_effect = spot_price_fetcher( + {TEST_DATE: mock_omie_results_oct15} + ) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DATE, ATTR_COUNTRIES: countries}, + blocking=True, + return_response=True, + ) + + assert response == snapshot + + +async def test_get_prices_for_date_default_country( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyomie: MagicMock, + mock_omie_results_oct15: OMIEResults, +) -> None: + """Test the get_prices_for_date service returns both countries by default.""" + await setup_integration(hass, mock_config_entry) + mock_pyomie.spot_price.side_effect = spot_price_fetcher( + {TEST_DATE: mock_omie_results_oct15} + ) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DATE}, + blocking=True, + return_response=True, + ) + + assert set(response) == {"es", "pt"} + + +async def test_get_prices_for_date_dst_fall_back( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyomie: MagicMock, + mock_omie_results_oct26_dst: OMIEResults, +) -> None: + """Test interval ends on the 25-hour DST fall-back day.""" + await setup_integration(hass, mock_config_entry) + mock_pyomie.spot_price.side_effect = spot_price_fetcher( + {TEST_DST_DATE: mock_omie_results_oct26_dst} + ) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DST_DATE, ATTR_COUNTRIES: ["es"]}, + blocking=True, + return_response=True, + ) + + intervals = response["es"] + assert len(intervals) == 100 + for interval in intervals: + start = dt.datetime.fromisoformat(interval["start"]) + end = dt.datetime.fromisoformat(interval["end"]) + assert end - start == dt.timedelta(minutes=15) + + last_cest_quarter = next( + interval + for interval in intervals + if interval["start"] == "2025-10-26T02:45:00+02:00" + ) + assert last_cest_quarter["end"] == "2025-10-26T02:00:00+01:00" + + +@pytest.mark.parametrize( + ("side_effect", "expected_exception", "expected_translation_key"), + [ + pytest.param( + aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=404 + ), + ServiceValidationError, + "data_not_available", + id="not_published_yet", + ), + pytest.param( + aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=500 + ), + HomeAssistantError, + "cannot_connect", + id="server_error", + ), + pytest.param( + aiohttp.ClientError("Connection error"), + HomeAssistantError, + "cannot_connect", + id="client_error", + ), + pytest.param( + TimeoutError(), + HomeAssistantError, + "cannot_connect", + id="timeout", + ), + ], +) +async def test_get_prices_for_date_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyomie: MagicMock, + side_effect: Exception, + expected_exception: type[HomeAssistantError], + expected_translation_key: str, +) -> None: + """Test the get_prices_for_date service error handling.""" + await setup_integration(hass, mock_config_entry) + mock_pyomie.spot_price.side_effect = side_effect + + with pytest.raises(expected_exception) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DATE}, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == expected_translation_key + + +@pytest.mark.usefixtures("mock_pyomie") +async def test_get_prices_for_date_before_market_start( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the get_prices_for_date service with a date before the market start.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: "2024-01-15"}, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == "date_before_market_start" + + +@pytest.mark.usefixtures("mock_pyomie") +async def test_get_prices_for_date_unloaded_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the get_prices_for_date service with no loaded config entry.""" + await setup_integration(hass, mock_config_entry) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICES_FOR_DATE, + {ATTR_DATE: TEST_DATE}, + blocking=True, + return_response=True, + ) + + assert err.value.translation_key == "entry_not_loaded" diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index c3d8d92a9d20..6efcdfed0b6a 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[ondilo_ico-W1122333044455] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Pool 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'W1122333044455', 'sw_version': '1.7.1-stable', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[ondilo_ico-W2233304445566] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Pool 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'W2233304445566', 'sw_version': '1.7.1-stable', 'via_device_id': None, diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr index 2573c34e1fad..f0aad4adec7c 100644 --- a/tests/components/onedrive/snapshots/test_init.ambr +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://onedrive.live.com/?id=root&cid=mock_drive_id', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'My Drive', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/onedrive/test_services.py b/tests/components/onedrive/test_services.py index 5e40ea90a461..f2f9d15c432d 100644 --- a/tests/components/onedrive/test_services.py +++ b/tests/components/onedrive/test_services.py @@ -2,6 +2,8 @@ from collections.abc import Generator from dataclasses import dataclass +import glob +from pathlib import Path import re from typing import Any, cast from unittest.mock import MagicMock, Mock, patch @@ -32,6 +34,11 @@ TEST_DESTINATION_PATH = "photos/snapshots/image.jpg" DESTINATION_FOLDER = "TestFolder" +def _is_file(path: Path) -> bool: + """Return True for concrete files, False for glob patterns.""" + return not glob.has_magic(str(path)) + + @dataclass class MockUploadFile: """Dataclass used to configure the test with a fake file behavior.""" @@ -98,6 +105,151 @@ async def test_upload_service( assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "metadata_id" +async def test_upload_service_wildcard( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call to upload content using a wildcard pattern.""" + await setup_integration(hass, mock_config_entry) + + matched_files = ["image1.jpg", "image2.jpg"] + with ( + patch( + "homeassistant.components.onedrive.services.glob.glob", + return_value=matched_files, + ) as mock_glob, + patch( + "homeassistant.components.onedrive.services.Path.is_file", + autospec=True, + side_effect=_is_file, + ), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: "/config/www/*.jpg", + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, + }, + blocking=True, + ) + + mock_glob.assert_called_once_with("*.jpg", root_dir="/config/www", recursive=True) + assert mock_onedrive_client.upload_file.call_count == len(matched_files) + uploaded_names = [ + call.args[1] for call in mock_onedrive_client.upload_file.call_args_list + ] + assert uploaded_names == ["image1.jpg", "image2.jpg"] + + +async def test_upload_service_wildcard_recursive( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call to upload content using a recursive wildcard pattern.""" + await setup_integration(hass, mock_config_entry) + + matched_files = ["image1.jpg", "sub/image2.jpg"] + with ( + patch( + "homeassistant.components.onedrive.services.glob.glob", + return_value=matched_files, + ) as mock_glob, + patch( + "homeassistant.components.onedrive.services.Path.is_file", + autospec=True, + side_effect=_is_file, + ), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: "/config/www/**/*.jpg", + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, + }, + blocking=True, + ) + + mock_glob.assert_called_once_with( + "**/*.jpg", root_dir="/config/www", recursive=True + ) + assert mock_onedrive_client.upload_file.call_count == len(matched_files) + # the "sub" subfolder is created under the destination folder + created_folders = [ + call.args[1] for call in mock_onedrive_client.create_folder.call_args_list + ] + assert "sub" in created_folders + # the nested file keeps its base name and is routed through the subfolder + uploaded_names = [ + call.args[1] for call in mock_onedrive_client.upload_file.call_args_list + ] + assert uploaded_names == ["image1.jpg", "image2.jpg"] + + +async def test_upload_service_wildcard_no_match( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a wildcard pattern that matches no files.""" + await setup_integration(hass, mock_config_entry) + + pattern = "/config/www/*.jpg" + with ( + patch( + "homeassistant.components.onedrive.services.glob.glob", + return_value=[], + ), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: pattern, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, + }, + blocking=True, + ) + assert exc_info.value.translation_key == "no_files_match_pattern" + assert pattern in exc_info.value.translation_placeholders["patterns"] + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(is_allowed_path=False)]) +async def test_upload_service_wildcard_base_not_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that a wildcard base directory outside the allowlist is rejected.""" + await setup_integration(hass, mock_config_entry) + + with ( + patch( + "homeassistant.components.onedrive.services.glob.glob", + ) as mock_glob, + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: "/config/www/*.jpg", + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, + }, + blocking=True, + ) + assert exc_info.value.translation_key == "no_access_to_path" + assert exc_info.value.translation_placeholders["filename"] == "/config/www" + # globbing must not run for a base directory outside the allowlist + mock_glob.assert_not_called() + + async def test_upload_service_no_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index d7e0d711c252..4209470f76da 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_registry[01.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'DS2401', 'name': '01.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_registry[05.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_registry[10.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_registry[12.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_registry[1D.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': , @@ -157,8 +152,8 @@ # name: test_registry[1F.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_registry[20.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': 'DS2450', 'name': '20.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_registry[22.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_registry[26.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_registry[28.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_registry[28.222222222222-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222222', 'sw_version': '3.2', 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_registry[28.222222222223-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222223', 'sw_version': '3.2', 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_registry[29.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_registry[30.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_registry[3A.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_registry[3B.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -489,7 +474,6 @@ 'model_id': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -498,8 +482,8 @@ # name: test_registry[42.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_registry[7E.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_registry[7E.222222222222-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '222222222222', 'sw_version': '3.2', 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_registry[7E.333333333333-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': 'EDS0065', 'name': '7E.333333333333', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '333333333333', 'sw_version': '3.2', 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_registry[A6.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': 'DS2438', 'name': 'A6.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_registry[EF.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111111', 'sw_version': '3.2', 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_registry[EF.111111111112-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111112', 'sw_version': '3.2', 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_registry[EF.111111111113-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '111111111113', 'sw_version': '3.2', 'via_device_id': None, diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 2839fe10a0ca..22a18743394b 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -92,7 +92,7 @@ def mock_config_entry( @pytest.fixture -def mock_config_entry_with_assist( +async def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" @@ -101,11 +101,12 @@ def mock_config_entry_with_assist( next(iter(mock_config_entry.subentries.values())), data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) + await hass.async_block_till_done() return mock_config_entry @pytest.fixture -def mock_config_entry_with_reasoning_model( +async def mock_config_entry_with_reasoning_model( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" @@ -114,6 +115,7 @@ def mock_config_entry_with_reasoning_model( next(iter(mock_config_entry.subentries.values())), data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "gpt-5-mini"}, ) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index caf16e6990da..dac962c59c7f 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -297,6 +297,27 @@ }), ]) # --- +# name: test_model_args[subentry_options0] + dict({ + 'include': list([ + 'reasoning.encrypted_content', + ]), + 'max_output_tokens': 3000, + 'model': 'gpt-5.6-sol', + 'prompt_cache_retention': '24h', + 'reasoning': dict({ + 'effort': 'low', + 'mode': 'pro', + 'summary': 'auto', + }), + 'service_tier': 'auto', + 'store': False, + 'stream': True, + 'text': dict({ + 'verbosity': 'medium', + }), + }) +# --- # name: test_web_search[False] list([ dict({ diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr index f5006ac979f1..87d719557790 100644 --- a/tests/components/openai_conversation/snapshots/test_init.ambr +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[mock_conversation_subentry_data0] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -18,7 +18,6 @@ 'model_id': None, 'name': 'OpenAI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -27,8 +26,8 @@ # name: test_devices[mock_conversation_subentry_data1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -43,7 +42,6 @@ 'model_id': None, 'name': 'OpenAI Conversation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index a3cb3999e916..d83c1f263d1e 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.components.openai_conversation.const import ( CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, @@ -273,6 +274,7 @@ async def test_subentry_unsupported_model( ("gpt-5.4-pro", ["medium", "high", "xhigh"]), ("gpt-5.5", ["none", "low", "medium", "high", "xhigh"]), ("gpt-5.5-pro", ["medium", "high", "xhigh"]), + ("gpt-5.6", ["none", "low", "medium", "high", "xhigh", "max"]), ], ) async def test_subentry_reasoning_effort_list( @@ -466,6 +468,8 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch( @pytest.mark.parametrize( ("model", "service_tier_options"), [ + ("gpt-5.6", ["auto", "flex", "default", "priority"]), + ("gpt-5.5", ["auto", "flex", "default", "priority"]), ("gpt-5.4", ["auto", "flex", "default", "priority"]), ("gpt-5.4-pro", ["auto", "flex", "default", "priority"]), ("gpt-5.2", ["auto", "flex", "default", "priority"]), @@ -817,12 +821,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, { - CONF_REASONING_EFFORT: "minimal", + CONF_REASONING_EFFORT: "max", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, CONF_VERBOSITY: "high", @@ -831,17 +835,18 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, }, ), { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_STORE_RESPONSES: False, - CONF_REASONING_EFFORT: "minimal", + CONF_REASONING_EFFORT: "max", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, CONF_VERBOSITY: "high", @@ -850,6 +855,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, }, ), # Test that old options are removed after reconfiguration @@ -966,7 +972,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", @@ -974,6 +980,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_SERVICE_TIER: "flex", CONF_CODE_INTERPRETER: True, CONF_VERBOSITY: "medium", + CONF_PRO_MODE: True, }, ( { diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index d1e7ec528bd9..933e57bc7d1b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -21,6 +21,7 @@ from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_CODE_INTERPRETER, + CONF_PRO_MODE, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, CONF_STORE_RESPONSES, @@ -817,3 +818,46 @@ async def test_flex_tier_retry( ) assert mock_create_stream.mock_calls[0][2]["service_tier"] == "flex" assert mock_create_stream.mock_calls[1][2]["service_tier"] == "default" + + +@pytest.mark.parametrize( + "subentry_options", [{CONF_CHAT_MODEL: "gpt-5.6-sol", CONF_PRO_MODE: True}] +) +async def test_model_args( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, + subentry_options: dict, +) -> None: + """Test model arguments for various configuration.""" + + subentry = next( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "conversation" + ) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data=subentry_options, + ) + await hass.async_block_till_done() + + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hi!", output_index=0), + ] + + result = await conversation.async_converse( + hass, + "Hello", + None, + Context(), + agent_id="conversation.openai_conversation", + ) + + model_args = mock_create_stream.call_args.kwargs.copy() + model_args.pop("input") + assert model_args.pop("user") == result.conversation_id + assert model_args == snapshot diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index f8d85e353e74..73dd2a79c5f4 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1278,7 +1278,7 @@ async def test_migration_from_v2_1( device_1 = device_registry.async_update_device( device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None ) - assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + assert device_1.config_entries_subentries == {"mock_entry_id": {"mock_id_1"}} entity_registry.async_get_or_create( "conversation", DOMAIN, diff --git a/tests/components/oralb/__init__.py b/tests/components/oralb/__init__.py index 757a10d22a1c..593d9101e1fc 100644 --- a/tests/components/oralb/__init__.py +++ b/tests/components/oralb/__init__.py @@ -37,6 +37,28 @@ ORALB_IO_SERIES_4_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO = BluetoothServiceInfo( + name="Oral-B Toothbrush", + address="78:DB:2F:C2:48:BE", + rssi=-63, + # running, 6 sectors, currently in sector 5 + manufacturer_data={220: b"\x062\x0c\x03\x00\x00\x1e\x00\x05\x0a\x06"}, + service_uuids=[], + service_data={}, + source="local", +) + +ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO = BluetoothServiceInfo( + name="Oral-B Toothbrush", + address="78:DB:2F:C2:48:BE", + rssi=-63, + # running, 6 sectors, "last sector" sentinel (7) resolves to sector 6 + manufacturer_data={220: b"\x062\x0c\x03\x00\x00\x28\x00\x07\x0a\x06"}, + service_uuids=[], + service_data={}, + source="local", +) + ORALB_IO_SERIES_6_SERVICE_INFO = BluetoothServiceInfoBleak( name="Oral-B Toothbrush", address="B0:D2:78:20:1D:CF", diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index a6b51694a188..c3a40a3bd8e7 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.bluetooth import ( async_address_present, ) from homeassistant.components.oralb.const import DOMAIN +from homeassistant.components.sensor import ATTR_OPTIONS from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -17,6 +18,8 @@ from homeassistant.util import dt as dt_util from . import ( ORALB_IO_SERIES_4_SERVICE_INFO, ORALB_IO_SERIES_6_SERVICE_INFO, + ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO, + ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO, ORALB_SERVICE_INFO, ) @@ -141,6 +144,50 @@ async def test_sensors_io_series_4(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sector_sensor_six_sectors(hass: HomeAssistant) -> None: + """Test the sector sensor while brushing with a six sector routine.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + inject_bluetooth_service_info(hass, ORALB_IO_SIX_SECTORS_SECTOR_5_SERVICE_INFO) + await hass.async_block_till_done() + + sector_sensor = hass.states.get("sensor.io_series_48be_sector") + assert sector_sensor.state == "sector_5" + assert sector_sensor.attributes[ATTR_OPTIONS] == [ + "no_sector", + "sector_1", + "sector_2", + "sector_3", + "sector_4", + "sector_5", + "sector_6", + "sector_7", + ] + + number_of_sectors_sensor = hass.states.get( + "sensor.io_series_48be_number_of_sectors" + ) + assert number_of_sectors_sensor.state == "6" + + # The "last sector" sentinel resolves to the sector count (sector 6) + inject_bluetooth_service_info(hass, ORALB_IO_SIX_SECTORS_LAST_SECTOR_SERVICE_INFO) + await hass.async_block_till_done() + + sector_sensor = hass.states.get("sensor.io_series_48be_sector") + assert sector_sensor.state == "sector_6" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_sensors_battery(hass: HomeAssistant) -> None: """Test receiving battery percentage.""" entry = MockConfigEntry( diff --git a/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json b/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json index 2c7d389eb572..1b530de81464 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json +++ b/tests/components/overkiz/fixtures/setup/cloud_atlantic_cozytouch.json @@ -1598,6 +1598,2313 @@ "type": 5, "uiClass": "ProtocolGateway", "widget": "IOStack" + }, + { + "creationTime": 1673778881000, + "lastUpdateTime": 1673778881000, + "label": "Living room heater", + "deviceURL": "modbuslink://1234-5678-5643/1#1", + "shortcut": false, + "controllableName": "modbuslink:AtlanticElectricalHeaterWithAdjustableTemperatureSetpointMBLComponent", + "definition": { + "commands": [ + { + "commandName": "off", + "nparams": 0 + }, + { + "commandName": "on", + "nparams": 0 + }, + { + "commandName": "refreshAbsenceEndDate", + "nparams": 0 + }, + { + "commandName": "refreshAbsenceMode", + "nparams": 0 + }, + { + "commandName": "refreshAbsenceStartDate", + "nparams": 0 + }, + { + "commandName": "refreshBoostActivation", + "nparams": 0 + }, + { + "commandName": "refreshDateTime", + "nparams": 0 + }, + { + "commandName": "refreshErrorCode", + "nparams": 0 + }, + { + "commandName": "refreshFridayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshHeatingLevel", + "nparams": 0 + }, + { + "commandName": "refreshIdentifier", + "nparams": 0 + }, + { + "commandName": "refreshLanguage", + "nparams": 0 + }, + { + "commandName": "refreshMaximumHeatingTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshMondayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshOccupancyActivation", + "nparams": 0 + }, + { + "commandName": "refreshOnOffState", + "nparams": 0 + }, + { + "commandName": "refreshOperatingMode", + "nparams": 0 + }, + { + "commandName": "refreshSaturdayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshSundayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshThursdayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshTuesdayTimeProgram", + "nparams": 0 + }, + { + "commandName": "refreshWednesdayTimeProgram", + "nparams": 0 + }, + { + "commandName": "setAbsenceEndDate", + "nparams": 1 + }, + { + "commandName": "setAbsenceMode", + "nparams": 1 + }, + { + "commandName": "setAbsenceStartDate", + "nparams": 1 + }, + { + "commandName": "setBoostActivation", + "nparams": 1 + }, + { + "commandName": "setDateTime", + "nparams": 1 + }, + { + "commandName": "setHeatingLevel", + "nparams": 1 + }, + { + "commandName": "setLanguage", + "nparams": 1 + }, + { + "commandName": "setMaximumHeatingTargetTemperature", + "nparams": 1 + }, + { + "commandName": "setMondayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setOccupancyActivation", + "nparams": 1 + }, + { + "commandName": "setOnOff", + "nparams": 1 + }, + { + "commandName": "setOperatingMode", + "nparams": 1 + }, + { + "commandName": "setSaturdayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setSundayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setTargetTemperature", + "nparams": 1 + }, + { + "commandName": "setWednesdayTimeProgram", + "nparams": 1 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "setFridayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setThursdayTimeProgram", + "nparams": 1 + }, + { + "commandName": "setTuesdayTimeProgram", + "nparams": 1 + }, + { + "commandName": "refreshDiagnostic", + "nparams": 0 + }, + { + "commandName": "refreshSetpointLoweringTemperatureInProgMode", + "nparams": 0 + }, + { + "commandName": "refreshStaticStates1", + "nparams": 0 + }, + { + "commandName": "refreshStaticStates2", + "nparams": 0 + }, + { + "commandName": "setSetpointLoweringTemperatureInProgMode", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:AbsenceEndDateState" + }, + { + "type": "DataState", + "qualifiedName": "core:AbsenceStartDateState" + }, + { + "type": "DiscreteState", + "values": ["active", "inactive"], + "qualifiedName": "core:BoostActivationState" + }, + { + "type": "DataState", + "qualifiedName": "core:DateTimeState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorCodeState" + }, + { + "type": "DataState", + "qualifiedName": "core:FridayTimeProgramState" + }, + { + "type": "DataState", + "qualifiedName": "core:IdentifierState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:LanguageState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:MaximumHeatingTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:MondayTimeProgramState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["active", "inactive"], + "qualifiedName": "core:OccupancyActivationState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:OccupancySensorStatusState" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "core:OnOffState" + }, + { + "type": "DiscreteState", + "values": [ + "antifreeze", + "auto", + "away", + "eco", + "frostprotection", + "manual", + "max", + "normal", + "off", + "on", + "prog", + "program", + "boost" + ], + "qualifiedName": "core:OperatingModeState" + }, + { + "type": "DataState", + "qualifiedName": "core:SaturdayTimeProgramState" + }, + { + "type": "DataState", + "qualifiedName": "core:SundayTimeProgramState" + }, + { + "type": "DiscreteState", + "values": [ + "boost", + "comfort", + "comfort-1", + "comfort-2", + "eco", + "frostprotection", + "off", + "secured" + ], + "qualifiedName": "core:TargetHeatingLevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:ThursdayTimeProgramState" + }, + { + "type": "DataState", + "qualifiedName": "core:TuesdayTimeProgramState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:VersionState" + }, + { + "type": "DataState", + "qualifiedName": "core:WednesdayTimeProgramState" + }, + { + "type": "DiscreteState", + "values": ["off", "on", "prog"], + "qualifiedName": "modbuslink:AbsenceModeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "modbuslink:SetpointLoweringTemperatureInProgModeState" + }, + { + "type": "DiscreteState", + "values": ["boost", "comfort", "eco"], + "qualifiedName": "modbuslink:TargetHeatingLevelState" + } + ], + "dataProperties": [], + "widgetName": "AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint", + "uiProfiles": [ + "StatefulHeatingLevel", + "HeatingLevel", + "StatefulThermostat", + "Thermostat", + "StatefulSwitchable", + "Switchable" + ], + "uiClass": "HeatingSystem", + "uiClassifiers": ["emitter"], + "qualifiedName": "modbuslink:AtlanticElectricalHeaterWithAdjustableTemperatureSetpointMBLComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbuslink:AbsenceModeState", + "type": 3, + "value": "off" + }, + { + "name": "core:MaximumHeatingTargetTemperatureState", + "type": 2, + "value": 30.0 + }, + { + "name": "core:IdentifierState", + "type": 3, + "value": "off" + }, + { + "name": "core:MondayTimeProgramState", + "type": 11, + "value": { + "monday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "18:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:TuesdayTimeProgramState", + "type": 11, + "value": { + "tuesday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "18:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:WednesdayTimeProgramState", + "type": 11, + "value": { + "wednesday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "12:00", + "end": "14:00" + }, + { + "start": "18:00", + "end": "22:00" + } + ] + } + }, + { + "name": "core:ThursdayTimeProgramState", + "type": 11, + "value": { + "thursday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "18:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:FridayTimeProgramState", + "type": 11, + "value": { + "friday": [ + { + "start": "06:00", + "end": "08:00" + }, + { + "start": "18:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:SaturdayTimeProgramState", + "type": 11, + "value": { + "saturday": [ + { + "start": "08:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:SundayTimeProgramState", + "type": 11, + "value": { + "sunday": [ + { + "start": "08:00", + "end": "22:00" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + }, + { + "name": "core:TargetTemperatureState", + "type": 2, + "value": 7.0 + }, + { + "name": "modbuslink:SetpointLoweringTemperatureInProgModeState", + "type": 1, + "value": 15 + }, + { + "name": "core:OperatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:BoostActivationState", + "type": 3, + "value": "active" + }, + { + "name": "core:OccupancyActivationState", + "type": 3, + "value": "active" + }, + { + "name": "modbuslink:TargetHeatingLevelState", + "type": 3, + "value": "comfort" + }, + { + "name": "core:TargetHeatingLevelState", + "type": 3, + "value": "comfort" + }, + { + "name": "core:OnOffState", + "type": 3, + "value": "off" + }, + { + "name": "core:OccupancySensorStatusState", + "type": 3, + "value": "active" + }, + { + "name": "core:VersionState", + "type": 1, + "value": 67 + }, + { + "name": "core:AbsenceEndDateState", + "type": 11, + "value": { + "month": 12, + "hour": 18, + "year": 2023, + "weekday": 4, + "day": 22, + "minute": 32, + "second": 39 + } + }, + { + "name": "core:AbsenceStartDateState", + "type": 11, + "value": { + "month": 12, + "hour": 20, + "year": 2023, + "weekday": 6, + "day": 17, + "minute": 22, + "second": 57 + } + } + ], + "available": true, + "enabled": true, + "widget": "AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint", + "type": 1, + "uiClass": "HeatingSystem" + }, + { + "creationTime": 1673778881000, + "lastUpdateTime": 1673778881000, + "label": "Living room temperature", + "deviceURL": "modbuslink://1234-5678-5643/1#2", + "shortcut": false, + "controllableName": "modbuslink:TemperatureInCelciusMBLSystemDeviceSensor", + "definition": { + "commands": [], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureState" + } + ], + "dataProperties": [], + "widgetName": "TemperatureSensor", + "uiProfiles": ["Temperature"], + "uiClass": "TemperatureSensor", + "qualifiedName": "modbuslink:TemperatureInCelciusMBLSystemDeviceSensor", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:TemperatureState", + "type": 2, + "value": 21.1 + } + ], + "attributes": [ + { + "name": "core:MeasuredValueType", + "type": 3, + "value": "core:TemperatureInCelcius" + }, + { + "name": "core:PowerSourceType", + "type": 3, + "value": "mainSupply" + } + ], + "available": true, + "enabled": true, + "widget": "TemperatureSensor", + "type": 2, + "uiClass": "TemperatureSensor" + }, + { + "creationTime": 1740209632000, + "lastUpdateTime": 1740209632000, + "label": "Bathroom Towel Dryer", + "deviceURL": "io://1234-5678-5643/5237136#1", + "shortcut": false, + "controllableName": "io:AtlanticElectricalTowelDryer_IC3_IOComponent", + "definition": { + "commands": [ + { + "commandName": "addLockLevel", + "nparams": 2 + }, + { + "commandName": "advancedRefresh", + "nparams": 2 + }, + { + "commandName": "cancelHeatingLevel", + "nparams": 1 + }, + { + "commandName": "delayedStopIdentify", + "nparams": 1 + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "off", + "nparams": 0 + }, + { + "commandName": "refreshComfortTemperature", + "nparams": 0 + }, + { + "commandName": "refreshDateTime", + "nparams": 0 + }, + { + "commandName": "refreshDerogatedTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshEcoTemperature", + "nparams": 0 + }, + { + "commandName": "refreshHeatingLevel", + "nparams": 0 + }, + { + "commandName": "refreshManufacturerName", + "nparams": 0 + }, + { + "commandName": "refreshMaximumHeatingTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshMaximumTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshOperatingMode", + "nparams": 0 + }, + { + "commandName": "refreshTargetTemperature", + "nparams": 0 + }, + { + "commandName": "refreshTemperature", + "nparams": 0 + }, + { + "commandName": "removeLockLevel", + "nparams": 1 + }, + { + "commandName": "resetLockLevels", + "nparams": 0 + }, + { + "commandName": "setComfortTemperature", + "nparams": 1 + }, + { + "commandName": "setDateTime", + "nparams": 1 + }, + { + "commandName": "setDerogatedTargetTemperature", + "nparams": 1 + }, + { + "commandName": "setEcoTemperature", + "nparams": 1 + }, + { + "commandName": "setHeatingLevel", + "nparams": 1 + }, + { + "commandName": "setHeatingLevelWithTimer", + "nparams": 2 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "setPreviousTargetTemperature", + "nparams": 1 + }, + { + "commandName": "setSchedulingType", + "nparams": 1 + }, + { + "commandName": "setTargetTemperature", + "nparams": 1 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "wink", + "nparams": 1 + }, + { + "commandName": "pairOneWayController", + "nparams": 2 + }, + { + "commandName": "refreshAutoProgram", + "nparams": 0 + }, + { + "commandName": "refreshBoostModeDuration", + "nparams": 0 + }, + { + "commandName": "refreshBoostModeParameters", + "nparams": 0 + }, + { + "commandName": "refreshControllerAddress", + "nparams": 0 + }, + { + "commandName": "refreshCumulatedLowering", + "nparams": 0 + }, + { + "commandName": "refreshCurrentWorkingRate", + "nparams": 0 + }, + { + "commandName": "refreshDeletionCancelation", + "nparams": 0 + }, + { + "commandName": "refreshDryingDuration", + "nparams": 0 + }, + { + "commandName": "refreshDryingParameters", + "nparams": 0 + }, + { + "commandName": "refreshEffectiveTemperatureSetpoint", + "nparams": 0 + }, + { + "commandName": "refreshLocalLeadTime", + "nparams": 0 + }, + { + "commandName": "refreshModel", + "nparams": 0 + }, + { + "commandName": "refreshNativeFunctionalLevel", + "nparams": 0 + }, + { + "commandName": "refreshOccupancy", + "nparams": 0 + }, + { + "commandName": "refreshPeakNotice", + "nparams": 0 + }, + { + "commandName": "refreshPeakWarning", + "nparams": 0 + }, + { + "commandName": "refreshPowerAndTension", + "nparams": 0 + }, + { + "commandName": "refreshRoomDeletionThreshold", + "nparams": 0 + }, + { + "commandName": "refreshSSVError6", + "nparams": 0 + }, + { + "commandName": "refreshSetpointLoweringTemperatureInProgMode", + "nparams": 0 + }, + { + "commandName": "refreshSynchronisationRequest", + "nparams": 0 + }, + { + "commandName": "refreshTemperatureProbeCalibrationOffset", + "nparams": 0 + }, + { + "commandName": "refreshTowelDryerTemporaryState", + "nparams": 0 + }, + { + "commandName": "refreshTowelDryerTimeProgram", + "nparams": 0 + }, + { + "commandName": "setCommunicationTest", + "nparams": 1 + }, + { + "commandName": "setDeletionCancelation", + "nparams": 1 + }, + { + "commandName": "setDryingDuration", + "nparams": 1 + }, + { + "commandName": "setExpectedPresence", + "nparams": 1 + }, + { + "commandName": "setPeakNotice", + "nparams": 1 + }, + { + "commandName": "setPeakWarning", + "nparams": 1 + }, + { + "commandName": "setRoomDeletionThreshold", + "nparams": 1 + }, + { + "commandName": "setSetpointLoweringTemperatureInProgMode", + "nparams": 1 + }, + { + "commandName": "setTemperatureProbeCalibrationOffset", + "nparams": 1 + }, + { + "commandName": "setTowelDryerBoostModeDuration", + "nparams": 1 + }, + { + "commandName": "setTowelDryerOperatingMode", + "nparams": 1 + }, + { + "commandName": "setTowelDryerTemporaryState", + "nparams": 1 + }, + { + "commandName": "setTowelDryerTimeProgram", + "nparams": 1 + }, + { + "commandName": "unpairAllOneWayControllers", + "nparams": 0 + }, + { + "commandName": "unpairOneWayController", + "nparams": 2 + } + ], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:BoostModeDurationState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ComfortRoomTemperatureState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:CommandLockLevelsState" + }, + { + "type": "DataState", + "qualifiedName": "core:DateTimeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:DerogatedTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:EcoRoomTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:FilterFanCloggingErrorState" + }, + { + "type": "DataState", + "qualifiedName": "core:IdentifierState" + }, + { + "type": "DataState", + "qualifiedName": "core:ManufacturerNameState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:MaximumHeatingTargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:MaximumTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "eventBased": true, + "type": "DiscreteState", + "values": ["noPersonInside", "personInside"], + "qualifiedName": "core:OccupancyState" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "core:OnOffState" + }, + { + "type": "DiscreteState", + "values": [ + "antifreeze", + "auto", + "away", + "eco", + "frostprotection", + "manual", + "max", + "normal", + "off", + "on", + "prog", + "program", + "boost" + ], + "qualifiedName": "core:OperatingModeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:PreviousTargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:PriorityLockTimerState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["increase", "none", "standby"], + "qualifiedName": "core:RegulationModeState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgramState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:VersionState" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "core:WinterModeState" + }, + { + "type": "DataState", + "qualifiedName": "io:AutoProgramState" + }, + { + "type": "DataState", + "qualifiedName": "io:BoostDurationMaxState" + }, + { + "type": "DataState", + "qualifiedName": "io:BoostDurationUserParameterState" + }, + { + "type": "DataState", + "qualifiedName": "io:ControllerAddressState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:CumulatedLoweringState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:CurrentWorkingRateState" + }, + { + "type": "DiscreteState", + "values": ["deletion cancelation", "no deletion cancelation"], + "qualifiedName": "io:DeletionCancelationState" + }, + { + "type": "DataState", + "qualifiedName": "io:DryingDurationMaxState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:DryingDurationState" + }, + { + "type": "DataState", + "qualifiedName": "io:DryingDurationUserParameterState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:EffectiveTemperatureSetpointState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:ExpectedPresenceState" + }, + { + "type": "DiscreteState", + "values": ["external", "internal"], + "qualifiedName": "io:InternalExternalSchedulingTypeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:LocalLeadTimeState" + }, + { + "type": "DiscreteState", + "values": [ + "boost", + "comfort", + "comfort-1", + "comfort-2", + "eco", + "frostprotection", + "off", + "secured" + ], + "qualifiedName": "io:MaximumHeatingLevelState" + }, + { + "type": "DataState", + "qualifiedName": "io:ModelState" + }, + { + "type": "DiscreteState", + "values": ["base", "medium", "top"], + "qualifiedName": "io:NativeFunctionalLevelState" + }, + { + "type": "DiscreteState", + "values": ["long peak", "no peak", "short peak"], + "qualifiedName": "io:PeakNoticeState" + }, + { + "type": "DiscreteState", + "values": ["long peak warning", "no warning", "short peak warning"], + "qualifiedName": "io:PeakWarningState" + }, + { + "type": "DataState", + "qualifiedName": "io:PowerState" + }, + { + "type": "DiscreteState", + "values": [ + "comfortLevel1", + "comfortLevel2", + "comfortLevel3", + "comfortLevel4", + "environmentProtection", + "humanProtection", + "userLevel1", + "userLevel2" + ], + "qualifiedName": "io:PriorityLockLevelState" + }, + { + "type": "DiscreteState", + "values": [ + "LSC", + "SAAC", + "SFC", + "UPS", + "externalGateway", + "localUser", + "myself", + "rain", + "security", + "temperature", + "timer", + "user", + "wind" + ], + "qualifiedName": "io:PriorityLockOriginatorState" + }, + { + "type": "DataState", + "qualifiedName": "io:RoomDeletionThresholdState" + }, + { + "type": "DiscreteState", + "values": ["kept", "lost"], + "qualifiedName": "io:RunningState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:SetpointLoweringTemperatureInProgModeState" + }, + { + "type": "DiscreteState", + "values": [ + "boost", + "comfort", + "comfort-1", + "comfort-2", + "eco", + "frostprotection", + "off", + "secured" + ], + "qualifiedName": "io:TargetHeatingLevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:TemperatureProbeCalibrationOffsetState" + }, + { + "type": "DataState", + "qualifiedName": "io:TensionState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:TimerForTransitoryStateState" + }, + { + "type": "DiscreteState", + "values": ["boost", "drying", "permanentHeating"], + "qualifiedName": "io:TowelDryerTemporaryStateState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:UptimeState" + }, + { + "type": "DataState", + "qualifiedName": "io:WinterModeSupportedState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "AtlanticElectricalTowelDryer", + "uiProfiles": [ + "HeatingLevel", + "StatefulThermostat", + "Thermostat", + "OccupancyDetector" + ], + "uiClass": "HeatingSystem", + "uiClassifiers": ["emitter"], + "qualifiedName": "io:AtlanticElectricalTowelDryer_IC3_IOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "*" + }, + { + "name": "core:VersionState", + "type": 3, + "value": "45373235303038202020" + }, + { + "name": "core:CommandLockLevelsState", + "type": 3, + "value": "[]", + "lastUpdateTime": 1773159693000 + }, + { + "name": "core:OnOffState", + "type": 3, + "value": "on" + }, + { + "name": "io:TargetHeatingLevelState", + "type": 3, + "value": "comfort" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "normal" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 74.0 + }, + { + "name": "core:IdentifierState", + "type": 3, + "value": "00000000" + }, + { + "name": "io:MaximumHeatingLevelState", + "type": 3, + "value": "unknown" + }, + { + "name": "io:TimerForTransitoryStateState", + "type": 1, + "value": 0 + }, + { + "name": "core:ComfortRoomTemperatureState", + "type": 1, + "value": 7 + }, + { + "name": "core:EcoRoomTemperatureState", + "type": 1, + "value": 3 + }, + { + "name": "io:SetpointLoweringTemperatureInProgModeState", + "type": 2, + "value": 3.0 + }, + { + "name": "io:InternalExternalSchedulingTypeState", + "type": 3, + "value": "external" + }, + { + "name": "core:DateTimeState", + "type": 11, + "value": { + "month": 7, + "hour": 11, + "year": 2026, + "weekday": 3, + "day": 2, + "minute": 20, + "second": 58 + } + }, + { + "name": "io:LocalLeadTimeState", + "type": 1, + "value": 3549 + }, + { + "name": "core:RegulationModeState", + "type": 3, + "value": "none" + }, + { + "name": "core:ManufacturerNameState", + "type": 3, + "value": "Sauter" + }, + { + "name": "io:ModelState", + "type": 3, + "value": "ASAMA" + }, + { + "name": "io:PowerState", + "type": 1, + "value": 500 + }, + { + "name": "io:TensionState", + "type": 1, + "value": 230 + }, + { + "name": "core:DerogatedTargetTemperatureState", + "type": 2, + "value": 0.0 + }, + { + "name": "io:NativeFunctionalLevelState", + "type": 3, + "value": "Base" + }, + { + "name": "io:TemperatureProbeCalibrationOffsetState", + "type": 2, + "value": 0.0 + }, + { + "name": "io:CumulatedLoweringState", + "type": 2, + "value": 0.0 + }, + { + "name": "io:ControllerAddressState", + "type": 1, + "value": 8926985 + }, + { + "name": "core:OperatingModeState", + "type": 3, + "value": "external" + }, + { + "name": "io:EffectiveTemperatureSetpointState", + "type": 2, + "value": 0.2 + }, + { + "name": "core:TargetTemperatureState", + "type": 2, + "value": 7.0 + }, + { + "name": "core:BoostModeDurationState", + "type": 1, + "value": 0 + }, + { + "name": "io:BoostDurationUserParameterState", + "type": 1, + "value": 35 + }, + { + "name": "io:BoostDurationMaxState", + "type": 1, + "value": 60 + }, + { + "name": "io:DryingDurationState", + "type": 1, + "value": 27 + }, + { + "name": "io:DryingDurationUserParameterState", + "type": 1, + "value": 60 + }, + { + "name": "io:DryingDurationMaxState", + "type": 1, + "value": 120 + }, + { + "name": "io:TowelDryerTemporaryStateState", + "type": 3, + "value": "drying" + }, + { + "name": "core:FilterFanCloggingErrorState", + "type": 2, + "value": 60.0 + }, + { + "name": "io:AutoProgramState", + "type": 11, + "value": { + "sunday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "saturday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "tuesday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "wednesday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "thursday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "friday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ], + "anticipTime": 3549, + "anticipNb": 20, + "monday": [ + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1", + "CONF_NIV1" + ] + } + }, + { + "name": "core:TimeProgramState", + "type": 10, + "value": [ + { + "monday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "tuesday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "wednesday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "thursday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "friday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "saturday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + }, + { + "sunday": [ + { + "start": "06:45", + "end": "07:30" + }, + { + "start": "00:00", + "end": "00:00" + }, + { + "start": "00:00", + "end": "00:00" + } + ] + } + ] + }, + { + "name": "io:CurrentWorkingRateState", + "type": 2, + "value": 100.0 + } + ], + "attributes": [ + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "E725008" + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Atlantic Group" + } + ], + "available": true, + "enabled": true, + "placeOID": "61435d6a-4ba6-4c96-b7b5-105bb31d3b87", + "widget": "AtlanticElectricalTowelDryer", + "type": 1, + "oid": "260c6316-213c-45ee-9c38-46850a698bf2", + "uiClass": "HeatingSystem" } ], "features": [], diff --git a/tests/components/overkiz/fixtures/setup/cloud_hi_kumo_europe.json b/tests/components/overkiz/fixtures/setup/cloud_hi_kumo_europe.json new file mode 100644 index 000000000000..c9efa255e289 --- /dev/null +++ b/tests/components/overkiz/fixtures/setup/cloud_hi_kumo_europe.json @@ -0,0 +1,2088 @@ +{ + "creationTime": 1681567761000, + "lastUpdateTime": 1681567761000, + "id": "SETUP-1234-5678-2284", + "location": { + "creationTime": 1681567761000, + "lastUpdateTime": 1681567761000, + "city": "**", + "country": "**", + "postalCode": "**", + "addressLine1": "**", + "addressLine2": "**", + "timezone": "Europe/Paris", + "longitude": "**", + "latitude": "**", + "twilightMode": 2, + "twilightAngle": "CIVIL", + "twilightCity": "paris", + "summerSolsticeDuskMinutes": 1290, + "winterSolsticeDuskMinutes": 990, + "twilightOffsetEnabled": false, + "dawnOffset": 0, + "duskOffset": 0, + "countryCode": "FR" + }, + "gateways": [ + { + "gatewayId": "1234-5678-2284", + "type": 44, + "subType": 0, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "autoUpdateEnabled": false, + "alive": true, + "timeReliable": true, + "connectivity": { + "status": "OK", + "protocolVersion": "2024.4.3" + }, + "upToDate": true, + "updateStatus": "UP_TO_DATE", + "syncInProgress": false, + "mode": "ACTIVE", + "functions": "INTERNET_AUTHORIZATION,SCENARIO_DOWNLOAD,SCENARIO_AUTO_LAUNCHING,SCENARIO_TELECO_LAUNCHING,INTERNET_UPLOAD,INTERNET_UPDATE,TRIGGERS_SENSORS" + } + ], + "devices": [ + { + "creationTime": 1681567761000, + "lastUpdateTime": 1681567761000, + "label": "Hub", + "deviceURL": "internal://1234-5678-2284/pod/0", + "shortcut": false, + "controllableName": "internal:PodMiniComponent", + "definition": { + "commands": [ + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "update", + "nparams": 0 + }, + { + "commandName": "setCountryCode", + "nparams": 1 + }, + { + "commandName": "activateCalendar", + "nparams": 0 + }, + { + "commandName": "deactivateCalendar", + "nparams": 0 + }, + { + "commandName": "refreshPodMode", + "nparams": 0 + }, + { + "commandName": "refreshUpdateStatus", + "nparams": 0 + }, + { + "commandName": "setCalendar", + "nparams": 1 + }, + { + "commandName": "setLightingLedPodMode", + "nparams": 1 + }, + { + "commandName": "setPodLedOff", + "nparams": 0 + }, + { + "commandName": "setPodLedOn", + "nparams": 0 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["offline", "online"], + "qualifiedName": "core:ConnectivityState" + }, + { + "type": "DataState", + "qualifiedName": "core:CountryCodeState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:LocalAccessProofState" + }, + { + "type": "DataState", + "qualifiedName": "core:LocalIPv4AddressState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": [ + "doublePress", + "longPress", + "simplePress", + "triplePress", + "veryLongPress" + ], + "qualifiedName": "internal:LastActionConfigButtonState" + }, + { + "type": "ContinuousState", + "qualifiedName": "internal:LightingLedPodModeState" + } + ], + "dataProperties": [], + "widgetName": "Pod", + "uiProfiles": ["UpdatableComponent"], + "uiClass": "Pod", + "qualifiedName": "internal:PodMiniComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "Hub" + }, + { + "name": "internal:LightingLedPodModeState", + "type": 2, + "value": 1.0 + }, + { + "name": "core:LocalIPv4AddressState", + "type": 3, + "value": "192.168.17.78" + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "Pod", + "type": 1, + "oid": "2b24e929-3fbc-40d4-be50-22720f5edc18", + "uiClass": "Pod" + }, + { + "creationTime": 1681567908000, + "lastUpdateTime": 1681567908000, + "label": "Yutaki", + "deviceURL": "modbus://1234-5678-2284/5416194/1#1", + "shortcut": false, + "controllableName": "modbus:YutakiMainComponent", + "definition": { + "commands": [ + { + "commandName": "refreshEcoModeOffset", + "nparams": 0 + }, + { + "commandName": "refreshEcoModeOffsetTarget", + "nparams": 0 + }, + { + "commandName": "setEcoModeOffset", + "nparams": 1 + }, + { + "commandName": "refreshCentralSetting1", + "nparams": 0 + }, + { + "commandName": "refreshCentralSetting2", + "nparams": 0 + }, + { + "commandName": "refreshCentralSetting3", + "nparams": 0 + }, + { + "commandName": "refreshControlBlockMenu", + "nparams": 0 + }, + { + "commandName": "refreshControlCommunicationAlarmBit", + "nparams": 0 + }, + { + "commandName": "refreshControlUnit", + "nparams": 0 + }, + { + "commandName": "refreshControlUnitMode", + "nparams": 0 + }, + { + "commandName": "refreshHLinkCommunicationAlarm", + "nparams": 0 + }, + { + "commandName": "refreshHardwareVersion", + "nparams": 0 + }, + { + "commandName": "refreshLCDCentralMode", + "nparams": 0 + }, + { + "commandName": "refreshLCDSoftwareNumber", + "nparams": 0 + }, + { + "commandName": "refreshOperationState", + "nparams": 0 + }, + { + "commandName": "refreshOutdoorAmbientTemperature", + "nparams": 0 + }, + { + "commandName": "refreshPCB1SoftwareNumber", + "nparams": 0 + }, + { + "commandName": "refreshRoomThermostatSetTemperatureC1", + "nparams": 0 + }, + { + "commandName": "refreshRoomThermostatSetTemperatureC2", + "nparams": 0 + }, + { + "commandName": "refreshRoomThermostatTemperatureC1", + "nparams": 0 + }, + { + "commandName": "refreshRoomThermostatTemperatureC2", + "nparams": 0 + }, + { + "commandName": "refreshSoftwareVersion", + "nparams": 0 + }, + { + "commandName": "refreshSpaceMode", + "nparams": 0 + }, + { + "commandName": "refreshStatusBlockMenu", + "nparams": 0 + }, + { + "commandName": "refreshStatusCommunicationAlarmBit", + "nparams": 0 + }, + { + "commandName": "refreshStatusUnitMode", + "nparams": 0 + }, + { + "commandName": "refreshSystemConfiguration", + "nparams": 0 + }, + { + "commandName": "refreshTahomaRoomThermostatAvailable", + "nparams": 0 + }, + { + "commandName": "refreshWaterInletUnitTemperature", + "nparams": 0 + }, + { + "commandName": "refreshWaterOutletUnitTemperature", + "nparams": 0 + }, + { + "commandName": "setCentralSetting1", + "nparams": 1 + }, + { + "commandName": "setCentralSetting2", + "nparams": 1 + }, + { + "commandName": "setCentralSetting3", + "nparams": 1 + }, + { + "commandName": "setControlBlockMenu", + "nparams": 1 + }, + { + "commandName": "setControlCommunicationAlarmBit", + "nparams": 1 + }, + { + "commandName": "setControlUnit", + "nparams": 1 + }, + { + "commandName": "setControlUnitMode", + "nparams": 1 + }, + { + "commandName": "setEcoComfortMode", + "nparams": 4 + }, + { + "commandName": "setGlobalAutoManuMode", + "nparams": 1 + }, + { + "commandName": "setSpaceMode", + "nparams": 1 + }, + { + "commandName": "setTahomaRoomThermostatAvailable", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["auto", "manu"], + "qualifiedName": "core:AutoManuModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CauseOfStoppageState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CentralSetting1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CentralSetting2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CentralSetting3State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CompressorCurrentValueState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CompressorFrequencyState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:CompressorRunningCurrentState" + }, + { + "type": "DiscreteState", + "values": ["block", "no"], + "qualifiedName": "modbus:ControlBlockMenuState" + }, + { + "type": "DiscreteState", + "values": ["alarm", "no"], + "qualifiedName": "modbus:ControlCommunicationAlarmBitState" + }, + { + "type": "DiscreteState", + "values": ["cool", "heat"], + "qualifiedName": "modbus:ControlUnitModeState" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlUnitState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:DefrostingState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:DischargeGasTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:DischargePressureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:DischargeTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "modbus:EcoModeOffsetState" + }, + { + "type": "ContinuousState", + "qualifiedName": "modbus:EcoModeOffsetTargetState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:EvaporatingTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:EvaporationTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:GasTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["alarm", "noAlarm"], + "qualifiedName": "modbus:HLinkCommunicationAlarmState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:HardwareVersionState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:IndoorExpansionValveOpeningState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:IndoorExpansionValveState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:InverterOperationFrequencyState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:LCDCentralModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:LCDSoftwareNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:LiquidTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:LiquidTemperatureTHMIState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:MixingValvePositionState" + }, + { + "type": "DiscreteState", + "values": [ + "alarm", + "cool demand-off", + "cool thermo-off", + "cool thermo-on", + "dhw-off", + "heat demand-off", + "heat thermo-off", + "heat thermo-on", + "off", + "swp-off" + ], + "qualifiedName": "modbus:OperationState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:OutdoorAmbientTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:OutdoorExpansionValveState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:OutdoorUnitAmbientState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:PCB1SoftwareNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ProductSpecCodeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RetryCodeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomThermostatSetTemperatureC1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomThermostatSetTemperatureC2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomThermostatTemperatureC1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomThermostatTemperatureC2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SecondAmbientAverageTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SecondAmbientTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SoftwareNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SoftwareVersionState" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:SpaceModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusBlockMenuState" + }, + { + "type": "DiscreteState", + "values": ["alarm", "no"], + "qualifiedName": "modbus:StatusCommunicationAlarmBitState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusUnitModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SuctionPressureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SuctionTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SystemConfigurationState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:SystemStatus2State" + }, + { + "type": "DiscreteState", + "values": ["available", "not available"], + "qualifiedName": "modbus:TahomaRoomThermostatAvailableState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:UnitModelState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:VirtualEcoComfortModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterFlowLevelState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterInletUnitTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterOutletHpTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterOutletTemperature2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterOutletTemperature3State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterOutletUnitTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterPumpSpeedState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterTemperatureSettingState" + }, + { + "type": "DiscreteState", + "values": ["auto", "holidays", "normal", "timer"], + "qualifiedName": "modbus:YutakiVirtualOperatingModeState" + } + ], + "dataProperties": [], + "widgetName": "HitachiAirToWaterMainComponent", + "uiProfiles": ["Specific"], + "uiClass": "HitachiHeatingSystem", + "qualifiedName": "modbus:YutakiMainComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbus:ControlUnitState", + "type": 3, + "value": "run" + }, + { + "name": "modbus:ControlUnitModeState", + "type": 3, + "value": "heat" + }, + { + "name": "modbus:ControlBlockMenuState", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ControlCommunicationAlarmBitState", + "type": 3, + "value": "no" + }, + { + "name": "modbus:CentralSetting1State", + "type": 3, + "value": "Unit run/stop : stop -- Unit mode : cool -- Circuit 1 run/stop : stop -- Circuit 2 run/stop : stop -- Circuit 1 room ambient change : false -- Circuit 2 room ambient change : false -- Circuit 1 thermostat setting change : false -- Circuit 2 thermostat setting change : false" + }, + { + "name": "modbus:CentralSetting2State", + "type": 3, + "value": "Circuit 1 Water setting heat : false -- Circuit 2 Water setting heat : false -- Circuit 1 Water setting cool : false -- Circuit 2 Water setting cool : false -- DHWT : off -- Swimming pool : off -- Anti legionella : off" + }, + { + "name": "modbus:CentralSetting3State", + "type": 3, + "value": "Heating OTC 1 : off -- Heating OTC 2 : off -- Cooling OTC 1 : off -- Cooling OTC 2 : off -- DHWT : comfort -- Space mode : standard" + }, + { + "name": "modbus:SpaceModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:TahomaRoomThermostatAvailableState", + "type": 3, + "value": "not available" + }, + { + "name": "modbus:EcoModeOffsetTargetState", + "type": 1, + "value": 5 + }, + { + "name": "modbus:StatusUnitModeState", + "type": 3, + "value": "heat" + }, + { + "name": "modbus:StatusBlockMenuState", + "type": 3, + "value": "no" + }, + { + "name": "modbus:StatusCommunicationAlarmBitState", + "type": 3, + "value": "no" + }, + { + "name": "modbus:LCDCentralModeState", + "type": 1, + "value": 1 + }, + { + "name": "modbus:SystemConfigurationState", + "type": 3, + "value": "Zone 1 heating : available -- Zone 2 heating : available -- Zone 1 cooling : unavailable -- Zone 2 cooling : unavailable -- DHWT : available -- Swimming pool : unavailable -- Room thermostat zone 1 : available -- Room thermostat zone 2 : available" + }, + { + "name": "modbus:OperationState", + "type": 3, + "value": "heat thermo-on" + }, + { + "name": "modbus:OutdoorAmbientTemperatureState", + "type": 1, + "value": -1 + }, + { + "name": "modbus:WaterInletUnitTemperatureState", + "type": 1, + "value": 32 + }, + { + "name": "modbus:WaterOutletUnitTemperatureState", + "type": 1, + "value": 34 + }, + { + "name": "modbus:HardwareVersionState", + "type": 1, + "value": 1 + }, + { + "name": "modbus:SoftwareVersionState", + "type": 1, + "value": 104 + }, + { + "name": "modbus:HLinkCommunicationAlarmState", + "type": 3, + "value": "noAlarm" + }, + { + "name": "modbus:LCDSoftwareNumberState", + "type": 1, + "value": 258 + }, + { + "name": "modbus:PCB1SoftwareNumberState", + "type": 1, + "value": 222 + }, + { + "name": "modbus:RoomThermostatSetTemperatureC1State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomThermostatSetTemperatureC2State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomThermostatTemperatureC1State", + "type": 2, + "value": 18.5 + }, + { + "name": "modbus:RoomThermostatTemperatureC2State", + "type": 2, + "value": 20.5 + }, + { + "name": "modbus:EcoModeOffsetState", + "type": 1, + "value": 5 + }, + { + "name": "modbus:WaterOutletHpTemperatureState", + "type": 1, + "value": 34 + }, + { + "name": "modbus:OutdoorUnitAmbientState", + "type": 1, + "value": -1 + }, + { + "name": "modbus:SecondAmbientTemperatureState", + "type": 1, + "value": -127 + }, + { + "name": "modbus:SecondAmbientAverageTemperatureState", + "type": 1, + "value": -127 + }, + { + "name": "modbus:WaterOutletTemperature2State", + "type": 1, + "value": 32 + }, + { + "name": "modbus:WaterOutletTemperature3State", + "type": 1, + "value": -127 + }, + { + "name": "modbus:GasTemperatureState", + "type": 1, + "value": 38 + }, + { + "name": "modbus:LiquidTemperatureTHMIState", + "type": 1, + "value": 29 + }, + { + "name": "modbus:DischargeGasTemperatureState", + "type": 1, + "value": 47 + }, + { + "name": "modbus:EvaporationTemperatureState", + "type": 1, + "value": -11 + }, + { + "name": "modbus:IndoorExpansionValveState", + "type": 1, + "value": 35 + }, + { + "name": "modbus:OutdoorExpansionValveState", + "type": 1, + "value": 8 + }, + { + "name": "modbus:InverterOperationFrequencyState", + "type": 1, + "value": 50 + }, + { + "name": "modbus:CauseOfStoppageState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:CompressorRunningCurrentState", + "type": 1, + "value": 3 + }, + { + "name": "modbus:ProductSpecCodeState", + "type": 1, + "value": 40 + }, + { + "name": "modbus:MixingValvePositionState", + "type": 1, + "value": 100 + }, + { + "name": "modbus:DefrostingState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:UnitModelState", + "type": 1, + "value": 1 + }, + { + "name": "modbus:WaterTemperatureSettingState", + "type": 1, + "value": 45 + }, + { + "name": "modbus:WaterFlowLevelState", + "type": 2, + "value": 1.9 + }, + { + "name": "modbus:WaterPumpSpeedState", + "type": 1, + "value": 80 + }, + { + "name": "modbus:SystemStatus2State", + "type": 1, + "value": 44 + }, + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:DischargeTemperatureState", + "type": 1, + "value": 45 + }, + { + "name": "modbus:SuctionTemperatureState", + "type": 1, + "value": 45 + }, + { + "name": "modbus:LiquidTemperatureState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:EvaporatingTemperatureState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:DischargePressureState", + "type": 1, + "value": 90 + }, + { + "name": "modbus:SuctionPressureState", + "type": 1, + "value": 66 + }, + { + "name": "modbus:CompressorFrequencyState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:IndoorExpansionValveOpeningState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:CompressorCurrentValueState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:SoftwareNumberState", + "type": 1, + "value": 0 + }, + { + "name": "modbus:RetryCodeState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiAirToWaterMainComponent", + "type": 1, + "oid": "1bbc9636-4347-4c71-be89-2164466c92c2", + "uiClass": "HitachiHeatingSystem" + }, + { + "creationTime": 1685604455000, + "lastUpdateTime": 1685604455000, + "label": "Domestic Hot Water Energy", + "deviceURL": "modbus://1234-5678-2284/5416194/1#10", + "shortcut": false, + "controllableName": "modbus:YutakiV2DHWElectricalEnergyConsumptionComponent", + "definition": { + "commands": [], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:ElectricEnergyConsumptionState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ElectricPowerConsumptionState" + } + ], + "dataProperties": [], + "widgetName": "CumulativeElectricPowerConsumptionSensor", + "uiProfiles": [ + "ElectricEnergyAndPower", + "ElectricPowerMeter", + "ElectricEnergyConsumption" + ], + "uiClass": "ElectricitySensor", + "qualifiedName": "modbus:YutakiV2DHWElectricalEnergyConsumptionComponent", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:ElectricEnergyConsumptionState", + "type": 1, + "value": 27857 + }, + { + "name": "core:ElectricPowerConsumptionState", + "type": 1, + "value": 5885 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "CumulativeElectricPowerConsumptionSensor", + "type": 2, + "oid": "57f30fb2-0c35-4bed-9f36-3f84d0c00b3e", + "uiClass": "ElectricitySensor" + }, + { + "creationTime": 1683653416000, + "lastUpdateTime": 1683653416000, + "label": "Yutaki Zone 1", + "deviceURL": "modbus://1234-5678-2284/5416194/1#2", + "shortcut": false, + "controllableName": "modbus:YutakiV2Zone1Component", + "definition": { + "commands": [ + { + "commandName": "refreshMode", + "nparams": 0 + }, + { + "commandName": "globalControl", + "nparams": 3 + }, + { + "commandName": "refreshControlCircuit1", + "nparams": 0 + }, + { + "commandName": "refreshControlCoolingOTCZone1", + "nparams": 0 + }, + { + "commandName": "refreshControlHeatingOTCZone1", + "nparams": 0 + }, + { + "commandName": "refreshRoomAmbientTemperatureControlZone1", + "nparams": 0 + }, + { + "commandName": "refreshRoomAmbientTemperatureStatusZone1", + "nparams": 0 + }, + { + "commandName": "refreshStatusCircuit1", + "nparams": 0 + }, + { + "commandName": "refreshStatusCoolingOTCZone1", + "nparams": 0 + }, + { + "commandName": "refreshStatusHeatingOTCZone1", + "nparams": 0 + }, + { + "commandName": "refreshThermostatSettingControlZone1", + "nparams": 0 + }, + { + "commandName": "refreshThermostatSettingStatusZone1", + "nparams": 0 + }, + { + "commandName": "refreshWaterCoolingSettingTemperatureControlZone1", + "nparams": 0 + }, + { + "commandName": "refreshWaterCoolingSettingTemperatureStatusZone1", + "nparams": 0 + }, + { + "commandName": "refreshWaterHeatingSettingTemperatureControlZone1", + "nparams": 0 + }, + { + "commandName": "refreshWaterHeatingSettingTemperatureStatusZone1", + "nparams": 0 + }, + { + "commandName": "setAutoManuMode", + "nparams": 1 + }, + { + "commandName": "setControlCircuit1", + "nparams": 1 + }, + { + "commandName": "setControlCoolingOTCZone1", + "nparams": 1 + }, + { + "commandName": "setControlHeatingOTCZone1", + "nparams": 1 + }, + { + "commandName": "setHolidayMode", + "nparams": 1 + }, + { + "commandName": "setRoomAmbientTemperatureControlZone1", + "nparams": 1 + }, + { + "commandName": "setTargetMode", + "nparams": 1 + }, + { + "commandName": "setThermostatSettingControlZone1", + "nparams": 1 + }, + { + "commandName": "setWaterCoolingSettingTemperatureControlZone1", + "nparams": 1 + }, + { + "commandName": "setWaterHeatingSettingTemperatureControlZone1", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + }, + { + "type": "DiscreteState", + "values": ["auto", "manu"], + "qualifiedName": "modbus:AutoManuModeZone1State" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlCircuit1State" + }, + { + "type": "DiscreteState", + "values": ["fix", "no", "points"], + "qualifiedName": "modbus:ControlCoolingOTCZone1State" + }, + { + "type": "DiscreteState", + "values": ["fix", "gradient", "no", "points"], + "qualifiedName": "modbus:ControlHeatingOTCZone1State" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "modbus:HolidayModeZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomAmbientTemperatureControlZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomAmbientTemperatureStatusZone1State" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:StatusCircuit1State" + }, + { + "type": "DiscreteState", + "values": ["fix", "no", "points"], + "qualifiedName": "modbus:StatusCoolingOTCZone1State" + }, + { + "type": "DiscreteState", + "values": ["fix", "gradient", "no", "points"], + "qualifiedName": "modbus:StatusHeatingOTCZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ThermostatSettingControlZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ThermostatSettingStatusZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterCoolingSettingTemperatureControlZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterCoolingSettingTemperatureStatusZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterHeatingSettingTemperatureControlZone1State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterHeatingSettingTemperatureStatusZone1State" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:YutakiModeState" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:YutakiTargetModeState" + } + ], + "dataProperties": [], + "widgetName": "HitachiAirToWaterHeatingZone", + "uiProfiles": ["Specific"], + "uiClass": "HitachiHeatingSystem", + "qualifiedName": "modbus:YutakiV2Zone1Component", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbus:AutoManuModeZone1State", + "type": 3, + "value": "auto" + }, + { + "name": "modbus:HolidayModeZone1State", + "type": 3, + "value": "off" + }, + { + "name": "modbus:ControlCircuit1State", + "type": 3, + "value": "run" + }, + { + "name": "modbus:ControlHeatingOTCZone1State", + "type": 3, + "value": "points" + }, + { + "name": "modbus:ControlCoolingOTCZone1State", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ThermostatSettingControlZone1State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomAmbientTemperatureControlZone1State", + "type": 2, + "value": 18.5 + }, + { + "name": "modbus:WaterHeatingSettingTemperatureControlZone1State", + "type": 1, + "value": 40 + }, + { + "name": "modbus:WaterCoolingSettingTemperatureControlZone1State", + "type": 1, + "value": 19 + }, + { + "name": "modbus:StatusCircuit1State", + "type": 3, + "value": "run" + }, + { + "name": "modbus:StatusHeatingOTCZone1State", + "type": 3, + "value": "points" + }, + { + "name": "modbus:StatusCoolingOTCZone1State", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ThermostatSettingStatusZone1State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomAmbientTemperatureStatusZone1State", + "type": 2, + "value": 18.5 + }, + { + "name": "modbus:WaterHeatingSettingTemperatureStatusZone1State", + "type": 1, + "value": 40 + }, + { + "name": "modbus:WaterCoolingSettingTemperatureStatusZone1State", + "type": 1, + "value": 19 + }, + { + "name": "modbus:YutakiTargetModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:YutakiModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiAirToWaterHeatingZone", + "type": 1, + "oid": "d50e4e55-316d-4372-9c62-682d04a0c932", + "uiClass": "HitachiHeatingSystem" + }, + { + "creationTime": 1683653416000, + "lastUpdateTime": 1683653416000, + "label": "Yutaki Zone 2", + "deviceURL": "modbus://1234-5678-2284/5416194/1#3", + "shortcut": false, + "controllableName": "modbus:YutakiV2Zone2Component", + "definition": { + "commands": [ + { + "commandName": "refreshMode", + "nparams": 0 + }, + { + "commandName": "globalControl", + "nparams": 3 + }, + { + "commandName": "refreshControlCircuit2", + "nparams": 0 + }, + { + "commandName": "refreshControlCoolingOTCZone2", + "nparams": 0 + }, + { + "commandName": "refreshControlHeatingOTCZone2", + "nparams": 0 + }, + { + "commandName": "refreshRoomAmbientTemperatureControlZone2", + "nparams": 0 + }, + { + "commandName": "refreshRoomAmbientTemperatureStatusZone2", + "nparams": 0 + }, + { + "commandName": "refreshStatusCircuit2", + "nparams": 0 + }, + { + "commandName": "refreshStatusCoolingOTCZone2", + "nparams": 0 + }, + { + "commandName": "refreshStatusHeatingOTCZone2", + "nparams": 0 + }, + { + "commandName": "refreshThermostatSettingControlZone2", + "nparams": 0 + }, + { + "commandName": "refreshThermostatSettingStatusZone2", + "nparams": 0 + }, + { + "commandName": "refreshWaterCoolingSettingTemperatureControlZone2", + "nparams": 0 + }, + { + "commandName": "refreshWaterCoolingSettingTemperatureStatusZone2", + "nparams": 0 + }, + { + "commandName": "refreshWaterHeatingSettingTemperatureControlZone2", + "nparams": 0 + }, + { + "commandName": "refreshWaterHeatingSettingTemperatureStatusZone2", + "nparams": 0 + }, + { + "commandName": "setAutoManuMode", + "nparams": 1 + }, + { + "commandName": "setControlCircuit2", + "nparams": 1 + }, + { + "commandName": "setControlCoolingOTCZone2", + "nparams": 1 + }, + { + "commandName": "setControlHeatingOTCZone2", + "nparams": 1 + }, + { + "commandName": "setHolidayMode", + "nparams": 1 + }, + { + "commandName": "setRoomAmbientTemperatureControlZone2", + "nparams": 1 + }, + { + "commandName": "setTargetMode", + "nparams": 1 + }, + { + "commandName": "setThermostatSettingControlZone2", + "nparams": 1 + }, + { + "commandName": "setWaterCoolingSettingTemperatureControlZone2", + "nparams": 1 + }, + { + "commandName": "setWaterHeatingSettingTemperatureControlZone2", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + }, + { + "type": "DiscreteState", + "values": ["auto", "manu"], + "qualifiedName": "modbus:AutoManuModeZone2State" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlCircuit2State" + }, + { + "type": "DiscreteState", + "values": ["fix", "no", "points"], + "qualifiedName": "modbus:ControlCoolingOTCZone2State" + }, + { + "type": "DiscreteState", + "values": ["fix", "gradient", "no", "points"], + "qualifiedName": "modbus:ControlHeatingOTCZone2State" + }, + { + "type": "DiscreteState", + "values": ["off", "on"], + "qualifiedName": "modbus:HolidayModeZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomAmbientTemperatureControlZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:RoomAmbientTemperatureStatusZone2State" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:StatusCircuit2State" + }, + { + "type": "DiscreteState", + "values": ["fix", "no", "points"], + "qualifiedName": "modbus:StatusCoolingOTCZone2State" + }, + { + "type": "DiscreteState", + "values": ["fix", "gradient", "no", "points"], + "qualifiedName": "modbus:StatusHeatingOTCZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ThermostatSettingControlZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ThermostatSettingStatusZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterCoolingSettingTemperatureControlZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterCoolingSettingTemperatureStatusZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterHeatingSettingTemperatureControlZone2State" + }, + { + "type": "DataState", + "qualifiedName": "modbus:WaterHeatingSettingTemperatureStatusZone2State" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:YutakiModeState" + }, + { + "type": "DiscreteState", + "values": ["comfort", "eco"], + "qualifiedName": "modbus:YutakiTargetModeState" + } + ], + "dataProperties": [], + "widgetName": "HitachiAirToWaterHeatingZone", + "uiProfiles": ["Specific"], + "uiClass": "HitachiHeatingSystem", + "qualifiedName": "modbus:YutakiV2Zone2Component", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbus:AutoManuModeZone2State", + "type": 3, + "value": "auto" + }, + { + "name": "modbus:HolidayModeZone2State", + "type": 3, + "value": "off" + }, + { + "name": "modbus:ControlCircuit2State", + "type": 3, + "value": "run" + }, + { + "name": "modbus:ControlHeatingOTCZone2State", + "type": 3, + "value": "points" + }, + { + "name": "modbus:ControlCoolingOTCZone2State", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ThermostatSettingControlZone2State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomAmbientTemperatureControlZone2State", + "type": 2, + "value": 20.5 + }, + { + "name": "modbus:WaterHeatingSettingTemperatureControlZone2State", + "type": 1, + "value": 38 + }, + { + "name": "modbus:WaterCoolingSettingTemperatureControlZone2State", + "type": 1, + "value": 19 + }, + { + "name": "modbus:StatusCircuit2State", + "type": 3, + "value": "run" + }, + { + "name": "modbus:StatusHeatingOTCZone2State", + "type": 3, + "value": "points" + }, + { + "name": "modbus:StatusCoolingOTCZone2State", + "type": 3, + "value": "no" + }, + { + "name": "modbus:ThermostatSettingStatusZone2State", + "type": 2, + "value": 21.0 + }, + { + "name": "modbus:RoomAmbientTemperatureStatusZone2State", + "type": 2, + "value": 20.5 + }, + { + "name": "modbus:WaterHeatingSettingTemperatureStatusZone2State", + "type": 1, + "value": 38 + }, + { + "name": "modbus:WaterCoolingSettingTemperatureStatusZone2State", + "type": 1, + "value": 19 + }, + { + "name": "modbus:YutakiTargetModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:YutakiModeState", + "type": 3, + "value": "comfort" + }, + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiAirToWaterHeatingZone", + "type": 1, + "oid": "f7acbaff-8c94-423b-8822-b62876771481", + "uiClass": "HitachiHeatingSystem" + }, + { + "creationTime": 1685604455000, + "lastUpdateTime": 1685604455000, + "label": "Domestic Hot Water", + "deviceURL": "modbus://1234-5678-2284/5416194/1#4", + "shortcut": false, + "controllableName": "modbus:YutakiV2DHWTComponent", + "definition": { + "commands": [ + { + "commandName": "refreshBoostMode", + "nparams": 0 + }, + { + "commandName": "globalControlDHW", + "nparams": 3 + }, + { + "commandName": "refreshControlAntiLegionella", + "nparams": 0 + }, + { + "commandName": "refreshControlAntiLegionellaSettingTemperature", + "nparams": 0 + }, + { + "commandName": "refreshControlDHW", + "nparams": 0 + }, + { + "commandName": "refreshControlDHWSettingTemperature", + "nparams": 0 + }, + { + "commandName": "refreshDHWMode", + "nparams": 0 + }, + { + "commandName": "refreshDHWTTemperature", + "nparams": 0 + }, + { + "commandName": "refreshStatusAntiLegionella", + "nparams": 0 + }, + { + "commandName": "refreshStatusAntiLegionellaSettingTemperature", + "nparams": 0 + }, + { + "commandName": "refreshStatusDHW", + "nparams": 0 + }, + { + "commandName": "refreshStatusDHWSettingTemperature", + "nparams": 0 + }, + { + "commandName": "setControlAntiLegionella", + "nparams": 1 + }, + { + "commandName": "setControlAntiLegionellaSettingTemperature", + "nparams": 1 + }, + { + "commandName": "setControlDHW", + "nparams": 1 + }, + { + "commandName": "setControlDHWSettingTemperature", + "nparams": 1 + }, + { + "commandName": "setDHWMode", + "nparams": 1 + }, + { + "commandName": "setTargetBoostMode", + "nparams": 1 + } + ], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:DHWTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ControlAntiLegionellaSettingTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlAntiLegionellaState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:ControlDHWSettingTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:ControlDHWState" + }, + { + "type": "DiscreteState", + "values": ["high demand", "standard"], + "qualifiedName": "modbus:DHWModeState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusAntiLegionellaSettingTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["run", "stop"], + "qualifiedName": "modbus:StatusAntiLegionellaState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusDHWSettingTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:StatusDHWState" + }, + { + "type": "DataState", + "qualifiedName": "modbus:VirtualGlobalControlState" + }, + { + "type": "DiscreteState", + "values": ["disabled", "enabled"], + "qualifiedName": "modbus:YutakiBoostModeState" + }, + { + "type": "DiscreteState", + "values": ["high demand", "off", "standard"], + "qualifiedName": "modbus:YutakiDHWVirtualOperatingModeState" + }, + { + "type": "DiscreteState", + "values": ["disabled", "enabled"], + "qualifiedName": "modbus:YutakiTargetBoostModeState" + } + ], + "dataProperties": [], + "widgetName": "HitachiDHW", + "uiProfiles": ["DHWTemperature"], + "uiClass": "HitachiHeatingSystem", + "qualifiedName": "modbus:YutakiV2DHWTComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "modbus:ControlDHWState", + "type": 3, + "value": "stop" + }, + { + "name": "modbus:ControlDHWSettingTemperatureState", + "type": 1, + "value": 30 + }, + { + "name": "modbus:ControlAntiLegionellaState", + "type": 3, + "value": "stop" + }, + { + "name": "modbus:ControlAntiLegionellaSettingTemperatureState", + "type": 1, + "value": 55 + }, + { + "name": "modbus:DHWModeState", + "type": 3, + "value": "standard" + }, + { + "name": "modbus:StatusDHWState", + "type": 3, + "value": "stop" + }, + { + "name": "modbus:StatusDHWSettingTemperatureState", + "type": 1, + "value": 30 + }, + { + "name": "modbus:StatusAntiLegionellaState", + "type": 3, + "value": "stop" + }, + { + "name": "modbus:StatusAntiLegionellaSettingTemperatureState", + "type": 1, + "value": 55 + }, + { + "name": "core:DHWTemperatureState", + "type": 1, + "value": 45 + }, + { + "name": "modbus:YutakiTargetBoostModeState", + "type": 3, + "value": "no request" + }, + { + "name": "modbus:YutakiBoostModeState", + "type": 3, + "value": "disabled" + }, + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiDHW", + "type": 1, + "oid": "c121ba66-a938-42c4-b0df-e4ad3b199047", + "uiClass": "HitachiHeatingSystem" + }, + { + "creationTime": 1685604455000, + "lastUpdateTime": 1685604455000, + "label": "Room Thermostat Zone 1", + "deviceURL": "modbus://1234-5678-2284/5416194/1#6", + "shortcut": false, + "controllableName": "modbus:YutakiRoomThermostatZone1Component", + "definition": { + "commands": [], + "states": [ + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + } + ], + "dataProperties": [], + "widgetName": "HitachiThermostat", + "uiProfiles": ["Specific"], + "uiClass": "HeatingSystem", + "qualifiedName": "modbus:YutakiRoomThermostatZone1Component", + "type": "SENSOR" + }, + "states": [ + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiThermostat", + "type": 2, + "oid": "90ecec6f-6e7d-4b75-9d86-8eafc77aad8c", + "uiClass": "HeatingSystem" + }, + { + "creationTime": 1685604455000, + "lastUpdateTime": 1685604455000, + "label": "Room Thermostat Zone 2", + "deviceURL": "modbus://1234-5678-2284/5416194/1#7", + "shortcut": false, + "controllableName": "modbus:YutakiRoomThermostatZone2Component", + "definition": { + "commands": [], + "states": [ + { + "type": "DataState", + "qualifiedName": "modbus:AlarmNumberState" + } + ], + "dataProperties": [], + "widgetName": "HitachiThermostat", + "uiProfiles": ["Specific"], + "uiClass": "HeatingSystem", + "qualifiedName": "modbus:YutakiRoomThermostatZone2Component", + "type": "SENSOR" + }, + "states": [ + { + "name": "modbus:AlarmNumberState", + "type": 1, + "value": 0 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "HitachiThermostat", + "type": 2, + "oid": "8f85079d-ae96-4246-b9e4-d59f60f41a12", + "uiClass": "HeatingSystem" + }, + { + "creationTime": 1683653416000, + "lastUpdateTime": 1683653416000, + "label": "Space Heating Energy", + "deviceURL": "modbus://1234-5678-2284/5416194/1#9", + "shortcut": false, + "controllableName": "modbus:YutakiV2SpaceHeatingElectricalEnergyConsumptionComponent", + "definition": { + "commands": [], + "states": [ + { + "type": "ContinuousState", + "qualifiedName": "core:ElectricEnergyConsumptionState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ElectricPowerConsumptionState" + } + ], + "dataProperties": [], + "widgetName": "CumulativeElectricPowerConsumptionSensor", + "uiProfiles": [ + "ElectricEnergyAndPower", + "ElectricPowerMeter", + "ElectricEnergyConsumption" + ], + "uiClass": "ElectricitySensor", + "qualifiedName": "modbus:YutakiV2SpaceHeatingElectricalEnergyConsumptionComponent", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:ElectricEnergyConsumptionState", + "type": 1, + "value": 41668 + }, + { + "name": "core:ElectricPowerConsumptionState", + "type": 1, + "value": 5554 + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "CumulativeElectricPowerConsumptionSensor", + "type": 2, + "oid": "20a30518-2348-474b-8877-800945e253c0", + "uiClass": "ElectricitySensor" + }, + { + "creationTime": 1681567906000, + "lastUpdateTime": 1681567906000, + "label": "Modbus Controller", + "deviceURL": "ovp://1234-5678-2284/5416194", + "shortcut": false, + "controllableName": "ovp:ModbusMainController", + "definition": { + "commands": [ + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "unknown", + "uiProfiles": ["Specific"], + "uiClass": "Generic", + "qualifiedName": "ovp:ModbusMainController", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:NameState", + "type": 3, + "value": "Modbus Controller" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + } + ], + "available": true, + "enabled": true, + "placeOID": "39502b85-28fa-4bab-9535-7dccae4589ac", + "widget": "unknown", + "type": 1, + "oid": "6e64bc4d-3bfe-4f7f-8170-ee0426ab4eb9", + "uiClass": "Generic" + } + ], + "zones": [], + "resellerDelegationType": "NEVER", + "oid": "1680fbe4-2a2f-44f9-a37b-18398e1dc4de", + "rootPlace": { + "creationTime": 1681567761000, + "lastUpdateTime": 1681567761000, + "label": "My House", + "type": 0, + "oid": "39502b85-28fa-4bab-9535-7dccae4589ac", + "subPlaces": [] + }, + "features": [] +} diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json index 9f6ed2f2a18e..6d18e6b9eecd 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_connexoon_rts_asia.json @@ -544,6 +544,73 @@ "type": 1, "oid": "c198bcdd-8b8b-4dc6-a2b0-f86f7dc7c001", "uiClass": "VenetianBlind" + }, + { + "creationTime": 1613676720000, + "lastUpdateTime": 1613676720000, + "label": "Living Room Screen", + "deviceURL": "rts://1234-1234-6362/16718220", + "shortcut": false, + "controllableName": "rts:GenericRTSComponent", + "definition": { + "commands": [ + { + "commandName": "down", + "nparams": 1 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "rest", + "nparams": 1 + }, + { + "commandName": "stop", + "nparams": 1 + }, + { + "commandName": "test", + "nparams": 0 + }, + { + "commandName": "up", + "nparams": 1 + }, + { + "commandName": "openConfiguration", + "nparams": 1 + } + ], + "states": [], + "dataProperties": [ + { + "value": "0", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "RTSGeneric", + "uiProfiles": ["UpDown"], + "uiClass": "Generic", + "qualifiedName": "rts:GenericRTSComponent", + "type": "ACTUATOR" + }, + "states": [], + "attributes": [ + { + "name": "rts:diy", + "type": 6, + "value": true + } + ], + "available": true, + "enabled": true, + "placeOID": "6133b4a0-f514-4553-b635-d1b7beb7e7b2", + "widget": "RTSGeneric", + "type": 1, + "oid": "97f85fd1-53f7-4cdf-8c73-9b2c172dbd62", + "uiClass": "Generic" } ], "zones": [], diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json index 4c127ecb54d5..5582c6aff293 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_switch_sc_europe.json @@ -3483,6 +3483,1116 @@ "widget": "ZigbeeStack", "oid": "08fa5c0f-95ba-410a-8a66-4cb55c0d508c", "uiClass": "ProtocolGateway" + }, + { + "label": "Thermostat", + "uiClass": "HeatingSystem", + "deviceURL": "io://1234-5678-5010/386310#1", + "shortcut": false, + "controllableName": "io:HeatingThermostatIOComponent", + "creationTime": 1759678031000, + "lastUpdateTime": 1759678031000, + "definition": { + "commands": [ + { + "commandName": "addLockLevel", + "nparams": 2 + }, + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "delayedStopIdentify", + "nparams": 1 + }, + { + "commandName": "getName", + "nparams": 0 + }, + { + "commandName": "identify", + "nparams": 0 + }, + { + "commandName": "removeLockLevel", + "nparams": 1 + }, + { + "commandName": "resetLockLevels", + "nparams": 0 + }, + { + "commandName": "setName", + "nparams": 1 + }, + { + "commandName": "setTimeProgramById", + "nparams": 2 + }, + { + "commandName": "startIdentify", + "nparams": 0 + }, + { + "commandName": "stopIdentify", + "nparams": 0 + }, + { + "commandName": "wink", + "nparams": 1 + }, + { + "commandName": "exitDerogation", + "nparams": 0 + }, + { + "commandName": "setAllModeTemperatures", + "nparams": 4 + }, + { + "commandName": "setDerogation", + "nparams": 2 + }, + { + "commandName": "setThermostatSettings", + "nparams": 1 + } + ], + "states": [ + { + "type": "DataState", + "qualifiedName": "core:ActiveTimeProgramState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:BatteryLevelState" + }, + { + "type": "DiscreteState", + "values": ["full", "low", "normal", "verylow"], + "qualifiedName": "core:BatteryState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:ComfortRoomTemperatureState" + }, + { + "eventBased": true, + "type": "DataState", + "qualifiedName": "core:CommandLockLevelsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:DerogatedTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:DerogationEndDateTimeState" + }, + { + "type": "DataState", + "qualifiedName": "core:DerogationStartDateTimeState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:EcoTargetTemperatureState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:FrostProtectionRoomTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["enable", "disable"], + "qualifiedName": "core:HeatingAnticipationState" + }, + { + "type": "DataState", + "qualifiedName": "core:MaxSetpointState" + }, + { + "type": "DataState", + "qualifiedName": "core:MinSetpointState" + }, + { + "type": "DataState", + "qualifiedName": "core:NameState" + }, + { + "type": "DiscreteState", + "values": ["closed", "open"], + "qualifiedName": "core:OpenClosedValveState" + }, + { + "type": "DiscreteState", + "values": ["active", "inactive"], + "qualifiedName": "core:OpenWindowDetectionActivationState" + }, + { + "type": "DiscreteState", + "values": [ + "antifreeze", + "auto", + "away", + "eco", + "frostprotection", + "manual", + "max", + "normal", + "off", + "on", + "prog", + "program", + "boost" + ], + "qualifiedName": "core:OperatingModeState" + }, + { + "type": "DiscreteState", + "values": ["enable", "disable"], + "qualifiedName": "core:PermanentDisplayState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["dead", "lowBattery", "maintenanceRequired", "noDefect"], + "qualifiedName": "core:SensorDefectState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetRoomTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureHysteresisState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureOffsetConfigurationState" + }, + { + "type": "DiscreteState", + "values": ["cooling", "heating", "heatingAndCooling"], + "qualifiedName": "core:ThermalConfigurationState" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgram1State" + }, + { + "type": "DataState", + "qualifiedName": "core:TimeProgram2State" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:AwayModeTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": [ + "awayMode", + "comfort", + "eco", + "frostprotection", + "geofencingMode", + "manual", + "suddenDropMode" + ], + "qualifiedName": "io:CurrentHeatingModeState" + }, + { + "type": "DiscreteState", + "values": [ + "awayMode", + "comfort", + "eco", + "frostprotection", + "geofencingMode", + "manual", + "suddenDropMode" + ], + "qualifiedName": "io:DerogationHeatingModeState" + }, + { + "type": "DiscreteState", + "values": ["date", "furtherNotice", "nextMode"], + "qualifiedName": "io:DerogationTypeState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:GeofencingModeTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": ["disabled", "enabled"], + "qualifiedName": "io:LockKeyActivationState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:ManualModeTargetTemperatureState" + }, + { + "type": "ContinuousState", + "qualifiedName": "io:OpenWindowDetectedTargetTemperatureState" + }, + { + "type": "DiscreteState", + "values": [ + "adjustment", + "finished", + "full_closed", + "full_open", + "pairing", + "reset" + ], + "qualifiedName": "io:ValveInstallationModeState" + } + ], + "dataProperties": [ + { + "value": "500", + "qualifiedName": "core:identifyInterval" + } + ], + "widgetName": "ThermostatHeatingTemperatureInterface", + "uiProfiles": ["ThermostatTargetReader"], + "uiClass": "HeatingSystem", + "uiClassifiers": ["emitter"], + "qualifiedName": "io:HeatingThermostatIOComponent", + "type": "ACTUATOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 96.0 + }, + { + "name": "io:DerogationTypeState", + "type": 3, + "value": "further_notice" + }, + { + "name": "io:DerogationHeatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:DerogatedTargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "io:ManualModeTargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:DerogationStartDateTimeState", + "type": 5, + "value": 1779390409000 + }, + { + "name": "core:DerogationEndDateTimeState", + "type": 5, + "value": 4294967295000 + }, + { + "name": "core:ComfortRoomTemperatureState", + "type": 2, + "value": 21.0 + }, + { + "name": "io:AwayModeTargetTemperatureState", + "type": 2, + "value": 17.0 + }, + { + "name": "core:EcoTargetTemperatureState", + "type": 2, + "value": 19.0 + }, + { + "name": "io:GeofencingModeTargetTemperatureState", + "type": 2, + "value": 20.0 + }, + { + "name": "core:FrostProtectionRoomTemperatureState", + "type": 2, + "value": 8.0 + }, + { + "name": "io:OpenWindowDetectedTargetTemperatureState", + "type": 2, + "value": 17.0 + }, + { + "name": "io:ValveInstallationModeState", + "type": 3, + "value": "finished" + }, + { + "name": "core:BatteryLevelState", + "type": 2, + "value": 100.0 + }, + { + "name": "core:TimeProgram1State", + "type": 11, + "value": { + "sunday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "saturday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "tuesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "wednesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "friday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "thursday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "monday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + } + } + }, + { + "name": "core:TimeProgram2State", + "type": 11, + "value": { + "sunday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "saturday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "tuesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "wednesday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "friday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "thursday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + }, + "monday": { + "timeslots": [ + { + "mode": "eco", + "from": { + "hour": 0, + "minute": 0 + }, + "to": { + "hour": 6, + "minute": 0 + } + }, + { + "mode": "comfort", + "from": { + "hour": 6, + "minute": 0 + }, + "to": { + "hour": 21, + "minute": 0 + } + }, + { + "mode": "eco", + "from": { + "hour": 21, + "minute": 0 + }, + "to": { + "hour": 24, + "minute": 0 + } + } + ] + } + } + }, + { + "name": "core:OpenClosedValveState", + "type": 3, + "value": "open" + }, + { + "name": "core:OperatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "io:CurrentHeatingModeState", + "type": 3, + "value": "manual" + }, + { + "name": "core:TargetRoomTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:TargetTemperatureState", + "type": 2, + "value": 16.5 + }, + { + "name": "core:OpenWindowDetectionActivationState", + "type": 3, + "value": "active" + }, + { + "name": "io:LockKeyActivationState", + "type": 3, + "value": "disable" + }, + { + "name": "core:PermanentDisplayState", + "type": 3, + "value": "enable" + }, + { + "name": "core:ThermalConfigurationState", + "type": 3, + "value": "heating" + }, + { + "name": "core:HeatingAnticipationState", + "type": 3, + "value": "disable" + }, + { + "name": "core:ActiveTimeProgramState", + "type": 3, + "value": "none" + }, + { + "name": "core:MaxSetpointState", + "type": 2, + "value": 26.0 + }, + { + "name": "core:MinSetpointState", + "type": 2, + "value": 5.0 + }, + { + "name": "core:TemperatureOffsetConfigurationState", + "type": 2, + "value": 0.0 + }, + { + "name": "core:TargetTemperatureHysteresisState", + "type": 2, + "value": 0.3 + } + ], + "available": true, + "enabled": true, + "placeOID": "8ba89c86-a590-4a3c-b352-4b95e906e9c9", + "oid": "d241a2c8-713a-428a-9911-0f8226af676e", + "widget": "ThermostatHeatingTemperatureInterface", + "type": 1 + }, + { + "label": "Thermostat Temperature", + "uiClass": "TemperatureSensor", + "deviceURL": "io://1234-5678-5010/386310#2", + "shortcut": false, + "controllableName": "io:TemperatureIOSystemSensor", + "creationTime": 1759678031000, + "lastUpdateTime": 1759678031000, + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 1 + } + ], + "states": [ + { + "type": "DiscreteState", + "values": ["full", "low", "normal", "verylow"], + "qualifiedName": "core:BatteryState" + }, + { + "type": "DiscreteState", + "values": ["good", "low", "normal", "verylow"], + "qualifiedName": "core:DiscreteRSSILevelState" + }, + { + "type": "DataState", + "qualifiedName": "core:ErrorsState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:RSSILevelState" + }, + { + "type": "DiscreteState", + "values": ["dead", "lowBattery", "maintenanceRequired", "noDefect"], + "qualifiedName": "core:SensorDefectState" + }, + { + "type": "DiscreteState", + "values": ["available", "unavailable"], + "qualifiedName": "core:StatusState" + }, + { + "type": "ContinuousState", + "qualifiedName": "core:TemperatureState" + } + ], + "dataProperties": [], + "widgetName": "TemperatureSensor", + "uiProfiles": ["Temperature"], + "uiClass": "TemperatureSensor", + "qualifiedName": "io:TemperatureIOSystemSensor", + "type": "SENSOR" + }, + "states": [ + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 2, + "value": 96.0 + }, + { + "name": "core:TemperatureState", + "type": 2, + "value": 26.6 + } + ], + "attributes": [ + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5155003A14" + }, + { + "name": "core:MinSensedValue", + "type": 1, + "value": 0 + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:MaxSensedValue", + "type": 2, + "value": 655.35 + }, + { + "name": "core:PowerSourceType", + "type": 3, + "value": "battery" + } + ], + "available": true, + "enabled": true, + "placeOID": "8ba89c86-a590-4a3c-b352-4b95e906e9c9", + "oid": "c32eb2cd-06de-4827-95fb-51ae49acf467", + "widget": "TemperatureSensor", + "type": 2 } ], "zones": [], diff --git a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json index ca8245b58efb..ead1a7538e24 100644 --- a/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json +++ b/tests/components/overkiz/fixtures/setup/cloud_somfy_tahoma_v2_europe.json @@ -9473,6 +9473,495 @@ "type": 1, "oid": "e01dc448-51ec-4b7e-b538-b8fd0e4a8375", "uiClass": "DoorLock" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "Balcony Window", + "deviceURL": "io://1234-1234-6233/8059108#1", + "shortcut": false, + "controllableName": "io:SomfyWindowStateSensor", + "definition": { + "commands": [ + { + "commandName": "executeManufacturerProcedure", + "nparams": 1 + }, + { + "commandName": "writeManufacturerData", + "nparams": 1 + }, + { + "commandName": "readManufacturerData", + "nparams": 1 + }, + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "runManufacturerSettingsCommand", + "nparams": 2 + } + ], + "type": "SENSOR", + "uiClass": "ContactSensor", + "states": [ + { + "name": "core:BatteryState" + }, + { + "name": "core:SensorDefectState" + }, + { + "name": "core:ErrorsState" + }, + { + "name": "core:StatusState" + }, + { + "name": "core:ManufacturerDiagnosticsState" + }, + { + "name": "core:ManufacturerSettingsState" + }, + { + "name": "core:NameState" + }, + { + "name": "core:TiltedState" + }, + { + "name": "core:OpenClosedState" + }, + { + "name": "core:LockedUnlockedState" + }, + { + "name": "core:DiscreteRSSILevelState" + }, + { + "name": "core:RSSILevelState" + } + ], + "widgetName": "IntrusionEventSensor" + }, + "states": [ + { + "value": "good", + "type": 3, + "name": "core:DiscreteRSSILevelState" + }, + { + "value": -48, + "type": 1, + "name": "core:RSSILevelState" + }, + { + "value": "available", + "type": 3, + "name": "core:StatusState" + }, + { + "value": "unlocked", + "type": 3, + "name": "core:LockedUnlockedState" + }, + { + "value": "IntelliTAG air", + "type": 3, + "name": "core:NameState" + }, + { + "value": false, + "type": 6, + "name": "core:TiltedState" + }, + { + "value": "open", + "type": 3, + "name": "core:OpenClosedState" + } + ], + "attributes": [ + { + "value": [ + "cde_nb", + "customer_production_date", + "eom_name", + "integrated_sensor_position", + "intrusion_detection_level_threshold", + "max_detected_temperature", + "nb_intrusion_detected_per_threshold_of_vibration", + "nb_of_oem_factory_mode_due_to_local_stimuli", + "number_of_battery_door_openings", + "number_of_buttons_pressed", + "number_of_locks", + "number_of_tilt_openings", + "number_of_turn_openings", + "number_of_unknown_positions", + "of_nb", + "production_date", + "window_depth", + "window_exposure", + "window_exterior_appearance", + "window_fitting_range", + "window_glazing_range", + "window_height", + "window_interior_appearance", + "window_model_name", + "window_number_of_panels", + "window_sensed_panel", + "window_type", + "window_type_of_material", + "window_width" + ], + "type": 10, + "name": "core:SupportedReadableManufacturerData" + }, + { + "value": "Somfy", + "type": 3, + "name": "core:Manufacturer" + }, + { + "value": [], + "type": 10, + "name": "core:SupportedManufacturerProcedures" + }, + { + "value": "5151415A15", + "type": 3, + "name": "core:FirmwareRevision" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "IntrusionEventSensor", + "type": 2, + "oid": "intellitag-0", + "uiClass": "ContactSensor" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "Garage Sliding Window", + "deviceURL": "io://1234-1234-6233/10538479#1", + "shortcut": false, + "controllableName": "io:SomfySlidingWindowStateSensor", + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "executeManufacturerProcedure", + "nparams": 1 + }, + { + "commandName": "readManufacturerData", + "nparams": 1 + }, + { + "commandName": "writeManufacturerData", + "nparams": 1 + } + ], + "type": "SENSOR", + "uiClass": "ContactSensor", + "states": [ + { + "name": "core:BatteryState" + }, + { + "name": "core:DiscreteRSSILevelState" + }, + { + "name": "core:ErrorsState" + }, + { + "name": "core:LockedUnlockedState" + }, + { + "name": "core:NameState" + }, + { + "name": "core:OpenClosedState" + }, + { + "name": "core:RSSILevelState" + }, + { + "name": "core:SensorDefectState" + }, + { + "name": "core:StatusState" + } + ], + "widgetName": "IntrusionEventSensor" + }, + "states": [ + { + "name": "core:OpenClosedState", + "type": 3, + "value": "closed" + }, + { + "name": "core:LockedUnlockedState", + "type": 3, + "value": "locked" + }, + { + "name": "core:NameState", + "type": 3, + "value": "IntelliTAG air" + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 1, + "value": -55 + } + ], + "attributes": [ + { + "name": "core:SupportedManufacturerProcedures", + "type": 10, + "value": [] + }, + { + "name": "core:SupportedReadableManufacturerData", + "type": 10, + "value": [ + "cde_nb", + "customer_production_date", + "eom_name", + "integrated_sensor_position", + "intrusion_detection_level_threshold", + "max_detected_temperature", + "nb_intrusion_detected_per_threshold_of_vibration", + "nb_of_oem_factory_mode_due_to_local_stimuli", + "number_of_buttons_pressed", + "number_of_locks", + "number_of_sliding_openings", + "number_of_unknown_positions", + "of_nb", + "production_date", + "window_depth", + "window_exposure", + "window_exterior_appearance", + "window_fitting_range", + "window_glazing_range", + "window_height", + "window_interior_appearance", + "window_model_name", + "window_number_of_panels", + "window_sensed_panel", + "window_type", + "window_type_of_material", + "window_width" + ] + }, + { + "name": "core:MaxSensedValue", + "type": 1, + "value": 65535 + }, + { + "name": "core:MinSensedValue", + "type": 1, + "value": 0 + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5155011A08" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "IntrusionEventSensor", + "type": 2, + "oid": "intellitag-1", + "uiClass": "ContactSensor" + }, + { + "creationTime": 1665238630000, + "lastUpdateTime": 1665238630000, + "label": "Garage Sliding Window", + "deviceURL": "io://1234-1234-6233/10538479#2", + "shortcut": false, + "controllableName": "io:SomfyIDEOIIntrusionSensor", + "definition": { + "commands": [ + { + "commandName": "advancedRefresh", + "nparams": 1 + }, + { + "commandName": "executeManufacturerProcedure", + "nparams": 1 + }, + { + "commandName": "readManufacturerData", + "nparams": 1 + }, + { + "commandName": "setVibrationLevelThreshold", + "nparams": 1 + }, + { + "commandName": "writeManufacturerData", + "nparams": 1 + } + ], + "type": "SENSOR", + "uiClass": "ContactSensor", + "states": [ + { + "name": "core:BatteryState" + }, + { + "name": "core:DiscreteRSSILevelState" + }, + { + "name": "core:ErrorsState" + }, + { + "name": "core:IntrusionDetectedState" + }, + { + "name": "core:RSSILevelState" + }, + { + "name": "core:SensorDefectState" + }, + { + "name": "core:StatusState" + }, + { + "name": "core:VibrationLevelThresholdState" + } + ], + "widgetName": "IntrusionSensor" + }, + "states": [ + { + "name": "core:VibrationLevelThresholdState", + "type": 1, + "value": 1 + }, + { + "name": "core:IntrusionDetectedState", + "type": 6, + "value": true, + "lastUpdateTime": 1778583613000 + }, + { + "name": "core:StatusState", + "type": 3, + "value": "available" + }, + { + "name": "core:DiscreteRSSILevelState", + "type": 3, + "value": "good" + }, + { + "name": "core:RSSILevelState", + "type": 1, + "value": -55 + } + ], + "attributes": [ + { + "name": "core:SupportedManufacturerProcedures", + "type": 10, + "value": [] + }, + { + "name": "core:SupportedReadableManufacturerData", + "type": 10, + "value": [ + "cde_nb", + "customer_production_date", + "eom_name", + "integrated_sensor_position", + "intrusion_detection_level_threshold", + "max_detected_temperature", + "nb_intrusion_detected_per_threshold_of_vibration", + "nb_of_oem_factory_mode_due_to_local_stimuli", + "number_of_buttons_pressed", + "number_of_locks", + "number_of_sliding_openings", + "number_of_unknown_positions", + "of_nb", + "production_date", + "window_depth", + "window_exposure", + "window_exterior_appearance", + "window_fitting_range", + "window_glazing_range", + "window_height", + "window_interior_appearance", + "window_model_name", + "window_number_of_panels", + "window_sensed_panel", + "window_type", + "window_type_of_material", + "window_width" + ] + }, + { + "name": "core:Manufacturer", + "type": 3, + "value": "Somfy" + }, + { + "name": "core:MaxSensedValue", + "type": 1, + "value": 65535 + }, + { + "name": "core:MinSensedValue", + "type": 1, + "value": 0 + }, + { + "name": "core:FirmwareRevision", + "type": 3, + "value": "5155011A08" + }, + { + "name": "core:PowerSourceType", + "type": 3, + "value": "battery" + } + ], + "available": true, + "enabled": true, + "placeOID": "bcbb34ef-2241-43a1-9c5b-523aa0563ec3", + "widget": "IntrusionSensor", + "type": 2, + "oid": "intellitag-2", + "uiClass": "ContactSensor" } ], "zones": [], diff --git a/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_switch_europe_2.json b/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_switch_europe_2.json index 96c5eec23791..5ba8b46a82b4 100644 --- a/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_switch_europe_2.json +++ b/tests/components/overkiz/fixtures/setup/local_somfy_tahoma_switch_europe_2.json @@ -1647,7 +1647,7 @@ { "type": 1, "name": "core:TargetClosureState", - "value": 0 + "value": 100 }, { "type": 6, diff --git a/tests/components/overkiz/snapshots/test_binary_sensor.ambr b/tests/components/overkiz/snapshots/test_binary_sensor.ambr index 0df8fad4896f..b3b9e08a0490 100644 --- a/tests/components/overkiz/snapshots/test_binary_sensor.ambr +++ b/tests/components/overkiz/snapshots/test_binary_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_binary_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][binary_sensor.my_home_bathroom_towel_dryer_occupancy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.my_home_bathroom_towel_dryer_occupancy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Occupancy', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Occupancy', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:OccupancyState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][binary_sensor.my_home_bathroom_towel_dryer_occupancy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'occupancy', + : 'Bathroom Towel Dryer Occupancy', + }), + 'context': , + 'entity_id': 'binary_sensor.my_home_bathroom_towel_dryer_occupancy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][binary_sensor.my_home_patio_water_heating_energy_demand_status-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -305,6 +356,159 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_balcony_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.living_room_balcony_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/8059108#1-core:OpenClosedState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_balcony_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'window', + : 'Balcony Window', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_balcony_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_balcony_window_tilt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.living_room_balcony_window_tilt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tilt', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:angle-acute', + 'original_name': 'Tilt', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/8059108#1-core:TiltedState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_balcony_window_tilt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Balcony Window Tilt', + : 'mdi:angle-acute', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_balcony_window_tilt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_garage_sliding_window-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.living_room_garage_sliding_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-1234-6233/10538479#1-core:OpenClosedState', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_garage_sliding_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'window', + : 'Garage Sliding Window', + }), + 'context': , + 'entity_id': 'binary_sensor.living_room_garage_sliding_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][binary_sensor.living_room_hallway_occupancy-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/snapshots/test_climate.ambr b/tests/components/overkiz/snapshots/test_climate.ambr new file mode 100644 index 000000000000..91e4b14aa5d9 --- /dev/null +++ b/tests/components/overkiz/snapshots/test_climate.ambr @@ -0,0 +1,740 @@ +# serializer version: 1 +# name: test_climate_entities_snapshot[cloud_atlantic_cozytouch.json][climate.living_room_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + ]), + : 35, + : 7, + : list([ + 'none', + 'frost_protection', + 'eco', + 'comfort', + 'comfort-1', + 'comfort-2', + 'auto', + 'boost', + 'external', + 'prog', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'modbuslink://1234-5678-5643/1#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_atlantic_cozytouch.json][climate.living_room_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 21.1, + : 'Living room heater', + : , + : list([ + , + , + , + ]), + : 35, + : 7, + : None, + : list([ + 'none', + 'frost_protection', + 'eco', + 'comfort', + 'comfort-1', + 'comfort-2', + 'auto', + 'boost', + 'external', + 'prog', + ]), + : , + : 7.0, + }), + 'context': , + 'entity_id': 'climate.living_room_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_atlantic_cozytouch.json][climate.my_home_bathroom_towel_dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + ]), + : 35, + : 7, + : list([ + 'none', + 'prog', + 'boost', + 'drying', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.my_home_bathroom_towel_dryer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-5643/5237136#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_atlantic_cozytouch.json][climate.my_home_bathroom_towel_dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'Bathroom Towel Dryer', + : list([ + , + , + , + ]), + : 35, + : 7, + : 'drying', + : list([ + 'none', + 'prog', + 'boost', + 'drying', + ]), + : , + : 7.0, + }), + 'context': , + 'entity_id': 'climate.my_home_bathroom_towel_dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + ]), + : 35.0, + : 5.0, + : list([ + 'comfort', + 'eco', + ]), + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.somfy_tahoma_switch_yutaki_zone_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Yutaki Zone 1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Yutaki Zone 1', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'modbus://1234-5678-2284/5416194/1#2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 18.5, + : 'Somfy TaHoma Switch Yutaki Zone 1', + : list([ + , + , + ]), + : 35.0, + : 5.0, + : 'comfort', + : list([ + 'comfort', + 'eco', + ]), + : , + : 0.5, + : 21.0, + }), + 'context': , + 'entity_id': 'climate.somfy_tahoma_switch_yutaki_zone_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + ]), + : 35.0, + : 5.0, + : list([ + 'comfort', + 'eco', + ]), + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.somfy_tahoma_switch_yutaki_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Yutaki Zone 2', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Yutaki Zone 2', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'modbus://1234-5678-2284/5416194/1#3', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_hi_kumo_europe.json][climate.somfy_tahoma_switch_yutaki_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 20.5, + : 'Somfy TaHoma Switch Yutaki Zone 2', + : list([ + , + , + ]), + : 35.0, + : 5.0, + : 'comfort', + : list([ + 'comfort', + 'eco', + ]), + : , + : 0.5, + : 21.0, + }), + 'context': , + 'entity_id': 'climate.somfy_tahoma_switch_yutaki_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_garden_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.maple_residence_garden_radiator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-1698/15702199#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_garden_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 24.4, + : 'Garden Radiator', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'comfort', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 21.0, + }), + 'context': , + 'entity_id': 'climate.maple_residence_garden_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_living_room_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.maple_residence_living_room_radiator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-1698/9253412#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_living_room_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 21.5, + : 'Living Room Radiator', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'comfort', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 21.0, + }), + 'context': , + 'entity_id': 'climate.maple_residence_living_room_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_study_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.maple_residence_study_radiator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-1698/9187218#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_study_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 20.6, + : 'Study Radiator', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'comfort', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 21.0, + }), + 'context': , + 'entity_id': 'climate.maple_residence_study_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_terrace_radiator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + ]), + : 26.0, + : 15.0, + : list([ + 'away', + 'eco', + 'comfort', + 'none', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.maple_residence_terrace_radiator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ovp://1234-5678-1698/374762#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_nexity_rail_din_europe.json][climate.maple_residence_terrace_radiator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 23.3, + : 'Terrace Radiator', + : , + : list([ + , + , + , + ]), + : 26.0, + : 15.0, + : 'comfort', + : list([ + 'away', + 'eco', + 'comfort', + 'none', + ]), + : , + : 23.0, + }), + 'context': , + 'entity_id': 'climate.maple_residence_terrace_radiator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_climate_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][climate.study_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + : 26.0, + : 5.0, + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.study_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'overkiz', + 'unique_id': 'io://1234-5678-5010/386310#1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entities_snapshot[cloud_somfy_tahoma_switch_sc_europe.json][climate.study_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 26.6, + : 'Thermostat', + : , + : list([ + , + ]), + : 26.0, + : 5.0, + : 'manual', + : list([ + 'none', + 'away', + 'comfort', + 'eco', + 'frost_protection', + 'manual', + ]), + : , + : 16.5, + }), + 'context': , + 'entity_id': 'climate.study_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/overkiz/snapshots/test_cover.ambr b/tests/components/overkiz/snapshots/test_cover.ambr index a7bdf6b33a2b..f2bf58ab2ec1 100644 --- a/tests/components/overkiz/snapshots/test_cover.ambr +++ b/tests/components/overkiz/snapshots/test_cover.ambr @@ -485,6 +485,59 @@ 'state': 'unknown', }) # --- +# name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_living_room_screen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.palm_court_living_room_screen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'rts://1234-1234-6362/16718220', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_living_room_screen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'Living Room Screen', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.palm_court_living_room_screen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_entities_snapshot[cloud_somfy_connexoon_rts_asia.json][cover.palm_court_office_venetian_blind-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3339,10 +3392,10 @@ # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.back_door_shutter-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - : 100, + : 0, : 'shutter', : 'Back Door Shutter', - : False, + : True, : , }), 'context': , @@ -3350,7 +3403,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'open', + 'state': 'closed', }) # --- # name: test_cover_entities_snapshot[local_somfy_tahoma_switch_europe_2.json][cover.front_door_shutter-entry] diff --git a/tests/components/overkiz/snapshots/test_number.ambr b/tests/components/overkiz/snapshots/test_number.ambr index 6fcac0d69d77..fc922663a26b 100644 --- a/tests/components/overkiz/snapshots/test_number.ambr +++ b/tests/components/overkiz/snapshots/test_number.ambr @@ -1,4 +1,252 @@ # serializer version: 1 +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_boost_mode_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_boost_mode_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Boost mode duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:radiator', + 'original_name': 'Boost mode duration', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-io:BoostDurationUserParameterState', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_boost_mode_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Bathroom Towel Dryer Boost mode duration', + : 'mdi:radiator', + : 60, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_boost_mode_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_comfort_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30, + : 7, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_comfort_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Comfort room temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:home-thermometer-outline', + 'original_name': 'Comfort room temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:ComfortRoomTemperatureState', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_comfort_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Bathroom Towel Dryer Comfort room temperature', + : 'mdi:home-thermometer-outline', + : 30, + : 7, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_comfort_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_drying_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 120, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_drying_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Drying duration', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:tumble-dryer', + 'original_name': 'Drying duration', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-io:DryingDurationUserParameterState', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_drying_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Bathroom Towel Dryer Drying duration', + : 'mdi:tumble-dryer', + : 120, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_drying_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_eco_room_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 29, + : 6, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_eco_room_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Eco room temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer', + 'original_name': 'Eco room temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:EcoRoomTemperatureState', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_bathroom_towel_dryer_eco_room_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Bathroom Towel Dryer Eco room temperature', + : 'mdi:thermometer', + : 29, + : 6, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.my_home_bathroom_towel_dryer_eco_room_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- # name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_patio_water_heating_away_mode_duration-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -183,68 +431,6 @@ 'state': '4', }) # --- -# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_patio_water_heating_freeze_protection_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 15, - : 5, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.my_home_patio_water_heating_freeze_protection_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Freeze protection temperature', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sun-thermometer-outline', - 'original_name': 'Freeze protection temperature', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-5643/109286#1-core:SecuredPositionTemperatureState', - 'unit_of_measurement': , - }) -# --- -# name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_patio_water_heating_freeze_protection_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Patio Water Heating Freeze protection temperature', - : 'mdi:sun-thermometer-outline', - : 15, - : 5, - : , - : 1.0, - : , - }), - 'context': , - 'entity_id': 'number.my_home_patio_water_heating_freeze_protection_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_number_entities_snapshot[cloud_atlantic_cozytouch.json][number.my_home_patio_water_heating_target_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -365,68 +551,6 @@ 'state': '60.0', }) # --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_garden_radiator_comfort_room_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 30, - : 7, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_garden_radiator_comfort_room_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Comfort room temperature', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:home-thermometer-outline', - 'original_name': 'Comfort room temperature', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/15702199#1-core:ComfortRoomTemperatureState', - 'unit_of_measurement': , - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_garden_radiator_comfort_room_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Garden Radiator Comfort room temperature', - : 'mdi:home-thermometer-outline', - : 30, - : 7, - : , - : 1.0, - : , - }), - 'context': , - 'entity_id': 'number.maple_residence_garden_radiator_comfort_room_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.0', - }) -# --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_hallway_shutter_my_position-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -484,249 +608,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_inlet_my_position-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 100, - : 0, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_living_room_air_inlet_my_position', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'My position', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:content-save-cog', - 'original_name': 'My position', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/10001-core:Memorized1PositionState', - 'unit_of_measurement': None, - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_inlet_my_position-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Living Room Air Inlet My position', - : 'mdi:content-save-cog', - : 100, - : 0, - : , - : 1.0, - }), - 'context': , - 'entity_id': 'number.maple_residence_living_room_air_inlet_my_position', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_outlet_my_position-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 100, - : 0, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_living_room_air_outlet_my_position', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'My position', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:content-save-cog', - 'original_name': 'My position', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/10002-core:Memorized1PositionState', - 'unit_of_measurement': None, - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_outlet_my_position-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Living Room Air Outlet My position', - : 'mdi:content-save-cog', - : 100, - : 0, - : , - : 1.0, - }), - 'context': , - 'entity_id': 'number.maple_residence_living_room_air_outlet_my_position', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_transfer_my_position-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 100, - : 0, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_living_room_air_transfer_my_position', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'My position', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:content-save-cog', - 'original_name': 'My position', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/10003-core:Memorized1PositionState', - 'unit_of_measurement': None, - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_air_transfer_my_position-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'Living Room Air Transfer My position', - : 'mdi:content-save-cog', - : 100, - : 0, - : , - : 1.0, - }), - 'context': , - 'entity_id': 'number.maple_residence_living_room_air_transfer_my_position', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_radiator_comfort_room_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 30, - : 7, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_living_room_radiator_comfort_room_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Comfort room temperature', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:home-thermometer-outline', - 'original_name': 'Comfort room temperature', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/9253412#1-core:ComfortRoomTemperatureState', - 'unit_of_measurement': , - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_living_room_radiator_comfort_room_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Living Room Radiator Comfort room temperature', - : 'mdi:home-thermometer-outline', - : 30, - : 7, - : , - : 1.0, - : , - }), - 'context': , - 'entity_id': 'number.maple_residence_living_room_radiator_comfort_room_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.0', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_nursery_shutter_my_position-entry] @@ -786,7 +668,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_office_shutter_my_position-entry] @@ -846,69 +728,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_study_radiator_comfort_room_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : 30, - : 7, - : , - : 1.0, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.maple_residence_study_radiator_comfort_room_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Comfort room temperature', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:home-thermometer-outline', - 'original_name': 'Comfort room temperature', - 'platform': 'overkiz', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'io://1234-5678-1698/9187218#1-core:ComfortRoomTemperatureState', - 'unit_of_measurement': , - }) -# --- -# name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_study_radiator_comfort_room_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Study Radiator Comfort room temperature', - : 'mdi:home-thermometer-outline', - : 30, - : 7, - : , - : 1.0, - : , - }), - 'context': , - 'entity_id': 'number.maple_residence_study_radiator_comfort_room_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '21.0', + 'state': '15', }) # --- # name: test_number_entities_snapshot[cloud_nexity_rail_din_europe.json][number.maple_residence_terrace_radiator_comfort_room_temperature-entry] @@ -1154,7 +974,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.garage_dining_room_shutter_my_position-entry] @@ -1214,7 +1034,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '69', + 'state': '31', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.garage_guest_room_shutter_my_position-entry] @@ -1274,7 +1094,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '84', + 'state': '16', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.guest_bedroom_kids_room_shutter_my_position-entry] @@ -1334,7 +1154,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_kitchen_shutter_my_position-entry] @@ -1394,7 +1214,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '87', + 'state': '13', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_office_shutter_my_position-entry] @@ -1454,7 +1274,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '72', + 'state': '28', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.kids_room_patio_shutter_my_position-entry] @@ -1514,7 +1334,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '81', + 'state': '19', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.main_bedroom_bedroom_blinds_my_position-entry] @@ -1574,7 +1394,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.office_garden_house_shutter_my_position-entry] @@ -1634,7 +1454,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[cloud_somfy_tahoma_v2_europe.json][number.reading_nook_living_room_shutter_my_position-entry] @@ -1694,7 +1514,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '86', + 'state': '14', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.bathroom_blinds_my_position-entry] @@ -1754,7 +1574,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.bedroom_blinds_my_position-entry] @@ -1814,7 +1634,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.dining_room_blinds_my_position-entry] @@ -1874,7 +1694,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.garage_blinds_my_position-entry] @@ -1934,7 +1754,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.guest_room_blinds_my_position-entry] @@ -1994,7 +1814,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.hallway_blinds_my_position-entry] @@ -2054,7 +1874,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.kitchen_blinds_my_position-entry] @@ -2114,7 +1934,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.living_room_blinds_my_position-entry] @@ -2174,7 +1994,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.master_bedroom_blinds_my_position-entry] @@ -2234,7 +2054,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.nursery_blinds_my_position-entry] @@ -2294,7 +2114,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.office_blinds_my_position-entry] @@ -2354,7 +2174,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85', + 'state': '15', }) # --- # name: test_number_entities_snapshot[local_somfy_tahoma_switch_europe.json][number.study_blinds_my_position-entry] @@ -2414,6 +2234,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '0', }) # --- diff --git a/tests/components/overkiz/snapshots/test_sensor.ambr b/tests/components/overkiz/snapshots/test_sensor.ambr index 8f265049f32e..6d75dfd7d337 100644 --- a/tests/components/overkiz/snapshots/test_sensor.ambr +++ b/tests/components/overkiz/snapshots/test_sensor.ambr @@ -1,4 +1,285 @@ # serializer version: 1 +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.living_room_temperature_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.living_room_temperature_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Living room temperature Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Living room temperature Temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbuslink://1234-5678-5643/1#2-core:TemperatureState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.living_room_temperature_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living room temperature Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_room_temperature_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_discrete_rssi_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'verylow', + 'low', + 'normal', + 'good', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_discrete_rssi_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discrete RSSI level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:wifi', + 'original_name': 'Discrete RSSI level', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discrete_rssi_level', + 'unique_id': 'io://1234-5678-5643/5237136#1-core:DiscreteRSSILevelState', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_discrete_rssi_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Bathroom Towel Dryer Discrete RSSI level', + : 'mdi:wifi', + : list([ + 'verylow', + 'low', + 'normal', + 'good', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_discrete_rssi_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_priority_lock_originator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.my_home_bathroom_towel_dryer_priority_lock_originator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Priority lock originator', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock', + 'original_name': 'Priority lock originator', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'priority_lock_originator', + 'unique_id': 'io://1234-5678-5643/5237136#1-io:PriorityLockOriginatorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_priority_lock_originator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bathroom Towel Dryer Priority lock originator', + : 'mdi:lock', + }), + 'context': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_priority_lock_originator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_priority_lock_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.my_home_bathroom_towel_dryer_priority_lock_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Priority lock timer', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lock-clock', + 'original_name': 'Priority lock timer', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:PriorityLockTimerState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_priority_lock_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bathroom Towel Dryer Priority lock timer', + : 'mdi:lock-clock', + : , + }), + 'context': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_priority_lock_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_rssi_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_rssi_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'RSSI level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI level', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'io://1234-5678-5643/5237136#1-core:RSSILevelState', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_bathroom_towel_dryer_rssi_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'Bathroom Towel Dryer RSSI level', + : , + : 'dB', + }), + 'context': , + 'entity_id': 'sensor.my_home_bathroom_towel_dryer_rssi_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '74', + }) +# --- # name: test_sensor_entities_snapshot[cloud_atlantic_cozytouch.json][sensor.my_home_patio_water_heating_bottom_tank_water_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1486,6 +1767,354 @@ 'state': '44962', }) # --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_domestic_hot_water_energy_electric_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_domestic_hot_water_energy_electric_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Domestic Hot Water Energy Electric energy consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Domestic Hot Water Energy Electric energy consumption', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#10-core:ElectricEnergyConsumptionState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_domestic_hot_water_energy_electric_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Yutaki Domestic Hot Water Energy Electric energy consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_domestic_hot_water_energy_electric_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27857', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_domestic_hot_water_energy_electric_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_domestic_hot_water_energy_electric_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Domestic Hot Water Energy Electric power consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Domestic Hot Water Energy Electric power consumption', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#10-core:ElectricPowerConsumptionState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_domestic_hot_water_energy_electric_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Yutaki Domestic Hot Water Energy Electric power consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_domestic_hot_water_energy_electric_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5885', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_outdoor_ambient_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_outdoor_ambient_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor ambient temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outdoor ambient temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#1-modbus:OutdoorAmbientTemperatureState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_outdoor_ambient_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Yutaki Outdoor ambient temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_outdoor_ambient_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_space_heating_energy_electric_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_space_heating_energy_electric_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Space Heating Energy Electric energy consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Space Heating Energy Electric energy consumption', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#9-core:ElectricEnergyConsumptionState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_space_heating_energy_electric_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Yutaki Space Heating Energy Electric energy consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_space_heating_energy_electric_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41668', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_space_heating_energy_electric_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_space_heating_energy_electric_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Space Heating Energy Electric power consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Space Heating Energy Electric power consumption', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#9-core:ElectricPowerConsumptionState', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_space_heating_energy_electric_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Yutaki Space Heating Energy Electric power consumption', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_space_heating_energy_electric_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5554', + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_yutaki_zone_1_room_ambient_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_house_yutaki_yutaki_zone_1_room_ambient_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Yutaki Zone 1 Room ambient temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yutaki Zone 1 Room ambient temperature', + 'platform': 'overkiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'modbus://1234-5678-2284/5416194/1#2-modbus:RoomAmbientTemperatureStatusZone1State', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities_snapshot[cloud_hi_kumo_europe.json][sensor.my_house_yutaki_yutaki_zone_1_room_ambient_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Yutaki Yutaki Zone 1 Room ambient temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.my_house_yutaki_yutaki_zone_1_room_ambient_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- # name: test_sensor_entities_snapshot[cloud_nexity_rail_din_europe.json][sensor.maple_residence_garage_ceiling_light_discrete_rssi_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/overkiz/test_binary_sensor.py b/tests/components/overkiz/test_binary_sensor.py index 330e54a04002..6675e6aa5006 100644 --- a/tests/components/overkiz/test_binary_sensor.py +++ b/tests/components/overkiz/test_binary_sensor.py @@ -37,6 +37,12 @@ CONTACT_SENSOR = FixtureDevice( "rtds://1234-1234-6233/394781", "binary_sensor.family_wing_porte_contact", ) +# Somfy IntelliTAG air tilt sensor (io:SomfyWindowStateSensor) +TILT_SENSOR = FixtureDevice( + "setup/cloud_somfy_tahoma_v2_europe.json", + "io://1234-1234-6233/8059108#1", + "binary_sensor.living_room_balcony_window_tilt", +) SNAPSHOT_FIXTURES = [ SMOKE_SENSOR, @@ -107,6 +113,42 @@ async def test_binary_sensor_smoke_state_update( assert state.state == STATE_ON +async def test_binary_sensor_tilt_state_update( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test event-driven state update for a tilt sensor (clear → tilted).""" + await setup_overkiz_integration(fixture=TILT_SENSOR.fixture) + + state = hass.states.get(TILT_SENSOR.entity_id) + assert state + assert state.state == STATE_OFF + + await async_deliver_events( + hass, + freezer, + mock_client, + [ + device_state_changed_event( + TILT_SENSOR.device_url, + [ + { + "name": OverkizState.CORE_TILTED.value, + "type": 6, + "value": True, + }, + ], + ) + ], + ) + + state = hass.states.get(TILT_SENSOR.entity_id) + assert state + assert state.state == STATE_ON + + async def test_binary_sensor_unavailability( hass: HomeAssistant, setup_overkiz_integration: SetupOverkizIntegration, diff --git a/tests/components/overkiz/test_climate.py b/tests/components/overkiz/test_climate.py index b4e780d9b9f4..8d11067804c8 100644 --- a/tests/components/overkiz/test_climate.py +++ b/tests/components/overkiz/test_climate.py @@ -1,19 +1,29 @@ """Tests for the Overkiz climate platform.""" from collections.abc import Generator +from pathlib import Path from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from pyoverkiz.enums import OverkizState from pyoverkiz.models import Event import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.climate import ATTR_HVAC_ACTION, HVACAction -from homeassistant.const import Platform +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration from .helpers import ( + assert_command_call, async_deliver_events, device_available_event, device_removed_event, @@ -21,11 +31,45 @@ from .helpers import ( device_unavailable_event, ) +from tests.common import snapshot_platform + +# io:HeatingValveIOComponent VALVE = FixtureDevice( "setup/cloud_nexity_rail_din_europe.json", "io://1234-5678-1698/15702199#1", "climate.maple_residence_garden_radiator", ) +# modbuslink:AtlanticElectricalHeaterWithAdjustableTemperatureSetpointMBLComponent +COZYTOUCH = FixtureDevice( + "setup/cloud_atlantic_cozytouch.json", + "modbuslink://1234-5678-5643/1#1", + "climate.living_room_heater", +) + +# Hitachi Yutaki 2-zone air-to-water heat pump +YUTAKI_ZONE_1 = FixtureDevice( + "setup/cloud_hi_kumo_europe.json", + "modbus://1234-5678-2284/5416194/1#2", + "climate.somfy_tahoma_switch_yutaki_zone_1", +) +YUTAKI_ZONE_2 = FixtureDevice( + "setup/cloud_hi_kumo_europe.json", + "modbus://1234-5678-2284/5416194/1#3", + "climate.somfy_tahoma_switch_yutaki_zone_2", +) +# io:HeatingThermostatIOComponent +THERMOSTAT_HEATING = FixtureDevice( + "setup/cloud_somfy_tahoma_switch_sc_europe.json", + "io://1234-5678-5010/386310#1", + "climate.study_thermostat", +) + +SNAPSHOT_FIXTURES = [ + VALVE, + COZYTOUCH, + YUTAKI_ZONE_1, + THERMOSTAT_HEATING, +] @pytest.fixture(autouse=True) @@ -35,6 +79,25 @@ def fixture_platforms() -> Generator[None]: yield +@pytest.mark.parametrize( + "device", + SNAPSHOT_FIXTURES, + ids=[Path(device.fixture).name for device in SNAPSHOT_FIXTURES], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_entities_snapshot( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + device: FixtureDevice, +) -> None: + """Test representative real setups via snapshot.""" + config_entry = await setup_overkiz_integration(fixture=device.fixture) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + async def test_valve_hvac_action_none_state( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -106,3 +169,79 @@ async def test_events_for_unknown_device_url( # Should not crash; valve entity should still be available state = hass.states.get(VALVE.entity_id) assert state is not None + + +async def test_hitachi_air_to_water_heating_zone_2( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """Test the second heating zone reads its own Zone2 states.""" + await setup_overkiz_integration(fixture=YUTAKI_ZONE_2.fixture) + + zone_1 = hass.states.get(YUTAKI_ZONE_1.entity_id) + assert zone_1 is not None + assert zone_1.attributes[ATTR_CURRENT_TEMPERATURE] == 18.5 + + zone_2 = hass.states.get(YUTAKI_ZONE_2.entity_id) + assert zone_2 is not None + assert zone_2.state == HVACMode.AUTO + assert zone_2.attributes[ATTR_CURRENT_TEMPERATURE] == 20.5 + assert zone_2.attributes[ATTR_TEMPERATURE] == 21.0 + + +async def test_thermostat_heating_set_temperature( + hass: HomeAssistant, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """Test setting a temperature issues setDerogation, not setComfortTemperature.""" + await setup_overkiz_integration(fixture=THERMOSTAT_HEATING.fixture) + + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": THERMOSTAT_HEATING.entity_id, ATTR_TEMPERATURE: 20.0}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=THERMOSTAT_HEATING.device_url, + command_name="setDerogation", + parameters=[20.0, "further_notice"], + ) + + +@pytest.mark.parametrize( + ("preset_mode", "parameters"), + [ + pytest.param("away", ["away", "further_notice"], id="away"), + pytest.param("comfort", ["comfort", "further_notice"], id="comfort"), + pytest.param("eco", ["eco", "further_notice"], id="eco"), + # Manual re-sends the current temperature to enter the derogation + pytest.param("manual", [26.6, "further_notice"], id="manual"), + ], +) +async def test_thermostat_heating_set_preset_mode( + hass: HomeAssistant, + mock_client: MockOverkizClient, + setup_overkiz_integration: SetupOverkizIntegration, + preset_mode: str, + parameters: list[str | float], +) -> None: + """Test selecting a preset issues setDerogation with the mapped parameter.""" + await setup_overkiz_integration(fixture=THERMOSTAT_HEATING.fixture) + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": THERMOSTAT_HEATING.entity_id, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=THERMOSTAT_HEATING.device_url, + command_name="setDerogation", + parameters=parameters, + ) diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index d1a2cb399a7a..a9aa5307e644 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -8,6 +8,7 @@ from pyoverkiz.client import GatewayCandidate from pyoverkiz.const import ( REXEL_OAUTH_AUTHORIZE_URL, REXEL_OAUTH_POLICY, + REXEL_OAUTH_SCOPE, REXEL_OAUTH_TOKEN_URL, ) from pyoverkiz.exceptions import ( @@ -1116,6 +1117,8 @@ async def test_rexel_full_flow_single_gateway( # Azure AD B2C needs the policy on the authorize URL; the helper rebuilds # the query string, so it must survive via extra_authorize_data. assert f"p={REXEL_OAUTH_POLICY}" in result["url"] + # offline_access is required for B2C to return a refresh token. + assert f"{REXEL_OAUTH_SCOPE}+offline_access" in result["url"] await _async_rexel_oauth_external_step( hass, hass_client_no_auth, aioclient_mock, result["flow_id"] @@ -1136,6 +1139,29 @@ async def test_rexel_full_flow_single_gateway( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") +async def test_rexel_flow_reimports_removed_credential( + hass: HomeAssistant, +) -> None: + """The Rexel flow re-imports its client credential if the user removed it.""" + assert await async_setup_component(hass, "application_credentials", {}) + + 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"], {"hub": "rexel"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_type": "cloud"} + ) + + # Reaching the OAuth2 external step proves an implementation was available, + # i.e. the credential was re-imported despite not being present beforehand. + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert REXEL_OAUTH_AUTHORIZE_URL in result["url"] + + @pytest.mark.usefixtures("current_request_with_host", "setup_rexel_credentials") async def test_rexel_full_flow_multiple_gateways( hass: HomeAssistant, diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 76633f29d74c..eaf0d21845df 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -122,6 +122,12 @@ UP_DOWN_SHEER_SCREEN = FixtureDevice( "rts://1234-1234-6362/16753206", "cover.palm_court_kitchen_sheer_screen", ) +# RTSGeneric only exposes raw up/down/stop commands (no open/close) +RTS_GENERIC = FixtureDevice( + "setup/cloud_somfy_connexoon_rts_asia.json", + "rts://1234-1234-6362/16718220", + "cover.palm_court_living_room_screen", +) DISCRETE_GARAGE_DOOR = FixtureDevice( "setup/local_somfy_tahoma_v2_europe.json", "io://1234-5678-3293/12745774", @@ -276,6 +282,7 @@ async def test_cover_entities_snapshot( ), (UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), (UP_DOWN_SHEER_SCREEN, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING), + (RTS_GENERIC, SERVICE_OPEN_COVER, "up", None, CoverState.OPENING), ( DYNAMIC_VENETIAN_BLIND, SERVICE_OPEN_COVER, @@ -334,6 +341,7 @@ async def test_cover_entities_snapshot( CoverState.CLOSING, ), (UP_DOWN_SHEER_SCREEN, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING), + (RTS_GENERIC, SERVICE_CLOSE_COVER, "down", None, CoverState.CLOSING), ( DYNAMIC_VENETIAN_BLIND, SERVICE_CLOSE_COVER, @@ -396,6 +404,7 @@ async def test_cover_entities_snapshot( ), (UP_DOWN_VENETIAN_BLIND, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), (UP_DOWN_SHEER_SCREEN, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), + (RTS_GENERIC, SERVICE_STOP_COVER, "stop", None, STATE_UNKNOWN), ( UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER_TILT, @@ -459,6 +468,7 @@ async def test_cover_entities_snapshot( "open-tilt-only-venetian-blind", "open-venetian-blind-rts", "open-sheer-screen-rts", + "open-rts-generic", "open-dynamic-venetian-blind", "close-roller-shutter", "close-awning", @@ -479,6 +489,7 @@ async def test_cover_entities_snapshot( "close-tilt-only-venetian-blind", "close-venetian-blind-rts", "close-sheer-screen-rts", + "close-rts-generic", "close-dynamic-venetian-blind", "stop-roller-shutter", "stop-awning", @@ -499,6 +510,7 @@ async def test_cover_entities_snapshot( "stop-tilt-tilt-only-venetian-blind", "stop-venetian-blind-rts", "stop-sheer-screen-rts", + "stop-rts-generic", "open-tilt-venetian-blind-rts", "close-tilt-venetian-blind-rts", "stop-tilt-venetian-blind-rts", diff --git a/tests/components/overkiz/test_number.py b/tests/components/overkiz/test_number.py index 55ee178ee763..45d06b5356fb 100644 --- a/tests/components/overkiz/test_number.py +++ b/tests/components/overkiz/test_number.py @@ -48,6 +48,16 @@ COMFORT_ROOM_TEMPERATURE = FixtureDevice( "ovp://1234-5678-1698/374762#1", "number.maple_residence_terrace_radiator_comfort_room_temperature", ) +TOWEL_DRYER_BOOST_MODE_DURATION = FixtureDevice( + "setup/cloud_atlantic_cozytouch.json", + "io://1234-5678-5643/5237136#1", + "number.my_home_bathroom_towel_dryer_boost_mode_duration", +) +TOWEL_DRYER_DRYING_DURATION = FixtureDevice( + "setup/cloud_atlantic_cozytouch.json", + "io://1234-5678-5643/5237136#1", + "number.my_home_bathroom_towel_dryer_drying_duration", +) SNAPSHOT_FIXTURES = [ MEMORIZED_POSITION, @@ -83,30 +93,72 @@ async def test_number_entities_snapshot( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize( + ("device", "value", "command_name"), + [ + pytest.param( + EXPECTED_NUMBER_OF_SHOWER, 3, "setExpectedNumberOfShower", id="shower" + ), + pytest.param( + TOWEL_DRYER_BOOST_MODE_DURATION, + 45, + "setTowelDryerBoostModeDuration", + id="towel_dryer_boost_mode_duration", + ), + pytest.param( + TOWEL_DRYER_DRYING_DURATION, + 90, + "setDryingDuration", + id="towel_dryer_drying_duration", + ), + ], +) async def test_number_set_value( hass: HomeAssistant, setup_overkiz_integration: SetupOverkizIntegration, mock_client: MockOverkizClient, + device: FixtureDevice, + value: int, + command_name: str, ) -> None: """Test setting a number value sends the correct command.""" - await setup_overkiz_integration(fixture=EXPECTED_NUMBER_OF_SHOWER.fixture) - - state = hass.states.get(EXPECTED_NUMBER_OF_SHOWER.entity_id) - assert state - assert state.state == "4" + await setup_overkiz_integration(fixture=device.fixture) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: EXPECTED_NUMBER_OF_SHOWER.entity_id, ATTR_VALUE: 3}, + {ATTR_ENTITY_ID: device.entity_id, ATTR_VALUE: value}, blocking=True, ) assert_command_call( mock_client, - device_url=EXPECTED_NUMBER_OF_SHOWER.device_url, - command_name="setExpectedNumberOfShower", - parameters=[3], + device_url=device.device_url, + command_name=command_name, + parameters=[value], + ) + + +async def test_number_inverted_memorized_position_set( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, +) -> None: + """Test that setting a cover's "My position" inverts before sending.""" + await setup_overkiz_integration(fixture=OFFICE_BLINDS_MEMORIZED_POSITION.fixture) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: OFFICE_BLINDS_MEMORIZED_POSITION.entity_id, ATTR_VALUE: 15}, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=OFFICE_BLINDS_MEMORIZED_POSITION.device_url, + command_name="setMemorized1Position", + parameters=[85], ) diff --git a/tests/components/overkiz/test_sensor.py b/tests/components/overkiz/test_sensor.py index 8d339f13ed48..63a104b81f75 100644 --- a/tests/components/overkiz/test_sensor.py +++ b/tests/components/overkiz/test_sensor.py @@ -48,12 +48,19 @@ COZYTOUCH_DHW = FixtureDevice( "io://1234-5678-5643/109286#2", "sensor.my_home_patio_water_heating_office_energy_meter_electric_energy_consumption", ) +# Hitachi Yutaki heat pump exposing energy and temperature sensors +YUTAKI = FixtureDevice( + "setup/cloud_hi_kumo_europe.json", + "modbus://1234-5678-2284/5416194/1#1", + "sensor.my_house_yutaki_outdoor_ambient_temperature", +) SNAPSHOT_FIXTURES = [ TEMPERATURE_SENSOR, TEMPERATURE_SENSOR_LOCAL, HOMEKIT_STACK, COZYTOUCH_DHW, + YUTAKI, ] diff --git a/tests/components/overseerr/conftest.py b/tests/components/overseerr/conftest.py index 5435aff659c5..8c9d45c99f58 100644 --- a/tests/components/overseerr/conftest.py +++ b/tests/components/overseerr/conftest.py @@ -67,6 +67,7 @@ def mock_overseerr_client() -> Generator[AsyncMock]: client.get_tv_details.return_value = TVDetails.from_json( load_fixture("tv.json", DOMAIN) ) + client.search.return_value = [] yield client diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index f861ccaa9ed0..6c8778536a63 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://overseerr.test', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Overseerr', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index 39df5760693d..07279dff3efd 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -1,18 +1,29 @@ """Tests for the Overseerr services.""" +import dataclasses from unittest.mock import AsyncMock import pytest from python_overseerr import OverseerrConnectionError +from python_overseerr.models import MediaType from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_QUERY, ATTR_REQUESTED_BY, + ATTR_SEASONS, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, ) -from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.components.overseerr.services import ( + SERVICE_GET_REQUESTS, + SERVICE_REQUEST_MEDIA, + SERVICE_SEARCH_MEDIA, + parse_seasons_input, +) from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -75,6 +86,65 @@ async def test_service_get_requests_no_meta( assert request["media"] == {} +async def test_service_search_media( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the search_media service.""" + # Mock the search method + mock_overseerr_client.search.return_value = [] + + await setup_integration(hass, mock_config_entry) + + # Test with a query containing spaces + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH_MEDIA, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_QUERY: "test query with spaces", + }, + blocking=True, + return_response=True, + ) + assert response == {"results": []} + mock_overseerr_client.search.assert_called_once_with("test query with spaces") + + +async def test_service_request_media( + hass: HomeAssistant, + mock_overseerr_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the request_media service.""" + + # Mock the create request method + @dataclasses.dataclass + class RequestWithMediaMock: + tmdb_id: str = "123456789" + media_type: MediaType = MediaType.TV + + mock_overseerr_client.create_request.return_value = RequestWithMediaMock() + + await setup_integration(hass, mock_config_entry) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_REQUEST_MEDIA, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_MEDIA_TYPE: "tv", + ATTR_MEDIA_ID: "123456789", + ATTR_SEASONS: "1", + }, + blocking=True, + return_response=True, + ) + + assert response == {"request": {"media_type": MediaType.TV, "tmdb_id": "123456789"}} + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [ @@ -85,7 +155,23 @@ async def test_service_get_requests_no_meta( OverseerrConnectionError("Timeout"), HomeAssistantError, "Error connecting to the Seerr instance: Timeout", - ) + ), + ( + SERVICE_SEARCH_MEDIA, + {ATTR_QUERY: "test"}, + "search", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Seerr instance: Timeout", + ), + ( + SERVICE_REQUEST_MEDIA, + {ATTR_MEDIA_TYPE: "tv", ATTR_MEDIA_ID: "123456789", ATTR_SEASONS: "1"}, + "create_request", + OverseerrConnectionError("Timeout"), + HomeAssistantError, + "Error connecting to the Seerr instance: Timeout", + ), ], ) async def test_services_connection_error( @@ -119,6 +205,11 @@ async def test_services_connection_error( ("service", "payload"), [ (SERVICE_GET_REQUESTS, {}), + (SERVICE_SEARCH_MEDIA, {ATTR_QUERY: "test"}), + ( + SERVICE_REQUEST_MEDIA, + {ATTR_MEDIA_TYPE: "tv", ATTR_MEDIA_ID: "123456789", ATTR_SEASONS: "1"}, + ), ], ) async def test_service_entry_availability( @@ -154,3 +245,29 @@ async def test_service_entry_availability( return_response=True, ) assert err.value.translation_key == "service_config_entry_not_found" + + +@pytest.mark.parametrize( + ("seasons_input", "expected_seasons"), + [ + ("1", [1]), + ("1,", [1]), + ("1,2,3", [1, 2, 3]), + ("1, 2, 3", [1, 2, 3]), + (" 1 , 2, 3 ", [1, 2, 3]), + ("[1]", [1]), + ("[1,2,3]", [1, 2, 3]), + ("[ 1 , 2 , 3]", [1, 2, 3]), + ("", "all"), + (" ", "all"), + (None, "all"), + ("all", "all"), + ("Not a valid input", "all"), + ("-", "all"), + ], +) +def test_parse_seasons_input( + seasons_input: str | None, expected_seasons: list[int] | str +) -> None: + """Test that all inputs are parsed correctly.""" + assert expected_seasons == parse_seasons_input(seasons_input) diff --git a/tests/components/paj_gps/snapshots/test_device_tracker.ambr b/tests/components/paj_gps/snapshots/test_device_tracker.ambr index 8648618b5398..fddb2122f281 100644 --- a/tests/components/paj_gps/snapshots/test_device_tracker.ambr +++ b/tests/components/paj_gps/snapshots/test_device_tracker.ambr @@ -46,8 +46,8 @@ : 'mdi:map-marker', : list([ ]), - : 52.0, - : 13.0, + : 52.0, + : 13.0, : , : , }), diff --git a/tests/components/palazzetti/snapshots/test_init.ambr b/tests/components/palazzetti/snapshots/test_init.ambr index 3fca1d851ce0..f1bf893c7483 100644 --- a/tests/components/palazzetti/snapshots/test_init.ambr +++ b/tests/components/palazzetti/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Stove', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0.0', 'via_device_id': None, diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr index 21edc32c6290..207bf037b748 100644 --- a/tests/components/peblar/snapshots/test_init.ambr +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_peblar_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.127', 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': '6004-2300-8002', 'name': 'Peblar EV Charger', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '23-45-A4O-MOF', 'sw_version': '1.6.1+1+WL-1', 'via_device_id': None, diff --git a/tests/components/permobil/__init__.py b/tests/components/permobil/__init__.py index 56e779eef4d7..1e170cf8edd2 100644 --- a/tests/components/permobil/__init__.py +++ b/tests/components/permobil/__init__.py @@ -1 +1 @@ -"""Tests for the MyPermobil integration.""" +"""Tests for the Permobil integration.""" diff --git a/tests/components/permobil/conftest.py b/tests/components/permobil/conftest.py deleted file mode 100644 index d3630d3f3665..000000000000 --- a/tests/components/permobil/conftest.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Common fixtures for the MyPermobil tests.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch - -from mypermobil import MyPermobil -import pytest - -from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL - - -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.permobil.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry - - -@pytest.fixture -def my_permobil() -> Mock: - """Mock spec for MyPermobilApi.""" - mock = Mock(spec=MyPermobil) - mock.request_region_names.return_value = {MOCK_REGION_NAME: MOCK_URL} - mock.request_application_token.return_value = MOCK_TOKEN - mock.region = "" - return mock diff --git a/tests/components/permobil/const.py b/tests/components/permobil/const.py deleted file mode 100644 index cb8a0c32f17c..000000000000 --- a/tests/components/permobil/const.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Test constants for Permobil.""" - -MOCK_URL = "https://example.com" -MOCK_REGION_NAME = "region_name" -MOCK_TOKEN = ("a" * 256, "date") diff --git a/tests/components/permobil/test_config_flow.py b/tests/components/permobil/test_config_flow.py deleted file mode 100644 index 9b591e2b9919..000000000000 --- a/tests/components/permobil/test_config_flow.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Test the MyPermobil config flow.""" - -from unittest.mock import Mock, patch - -from mypermobil import ( - MyPermobilAPIException, - MyPermobilClientException, - MyPermobilEulaException, -) -import pytest - -from homeassistant import config_entries -from homeassistant.components.permobil import config_flow -from homeassistant.components.permobil.const import DOMAIN -from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL - -from tests.common import MockConfigEntry - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - -MOCK_CODE = "012345" -MOCK_EMAIL = "valid@email.com" -INVALID_EMAIL = "this is not a valid email" -VALID_DATA = { - CONF_EMAIL: MOCK_EMAIL, - CONF_REGION: MOCK_URL, - CONF_CODE: MOCK_CODE, - CONF_TOKEN: MOCK_TOKEN[0], - CONF_TTL: MOCK_TOKEN[1], -} - - -async def test_sucessful_config_flow(hass: HomeAssistant, my_permobil: Mock) -> None: - """Test the config flow from start to finish with no errors.""" - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - # request region code - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == VALID_DATA - - -async def test_config_flow_incorrect_code( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test email code verification with API error. - - Test the config flow from start to until email code verification - and have the API return API error. - """ - my_permobil.request_application_token.side_effect = MyPermobilAPIException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request region code - # here the request_application_token raises a MyPermobilAPIException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "invalid_code" - - -async def test_config_flow_unsigned_eula( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test email code verification with unsigned eula error. - - Test the config flow from start to until email code verification - and have the API return that the eula is unsigned. - """ - my_permobil.request_application_token.side_effect = MyPermobilEulaException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request region code - # here the request_application_token raises a MyPermobilEulaException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "unsigned_eula" - - # Retry to submit the code again, but this time the user has signed the EULA - with patch.object( - my_permobil, - "request_application_token", - return_value=MOCK_TOKEN, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: MOCK_CODE}, - ) - - # Now the method should not raise an exception, and you can - # proceed with your assertions - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == VALID_DATA - - -async def test_config_flow_incorrect_region( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test when the user does not exist in the selected region. - - Test the config flow from start to until the request for email - code and have the API return error because there is not user for - that email. - """ - my_permobil.request_application_code.side_effect = MyPermobilAPIException - # init flow - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"] == {} - - # select region step - # here the request_application_code raises a MyPermobilAPIException - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_REGION: MOCK_REGION_NAME}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"]["base"] == "code_request_error" - - -async def test_config_flow_region_request_error( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test region request error. - - Test the config flow from start to until the request for regions - and have the API return an error. - """ - my_permobil.request_region_names.side_effect = MyPermobilAPIException - # init flow - # here the request_region_names raises a MyPermobilAPIException - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: MOCK_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "region" - assert result["errors"]["base"] == "region_fetch_error" - - -async def test_config_flow_invalid_email( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test an incorrectly formatted email. - - Test that the email must be formatted correctly. The schema for the - input should already check for this, but since the API does a - separate check that might not overlap 100% with the schema, - this test is still needed. - """ - my_permobil.set_email.side_effect = MyPermobilClientException() - # init flow - # here the set_email raises a MyPermobilClientException - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_EMAIL: INVALID_EMAIL}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"]["base"] == "invalid_email" - - -async def test_config_flow_reauth_success( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth make sure that the values are replaced.""" - # new token and code - reauth_token = ("b" * 256, "reauth_date") - reauth_code = "567890" - my_permobil.request_application_token.return_value = reauth_token - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request new token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: reauth_code}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert mock_entry.data == { - CONF_EMAIL: MOCK_EMAIL, - CONF_REGION: MOCK_URL, - CONF_CODE: reauth_code, - CONF_TOKEN: reauth_token[0], - CONF_TTL: reauth_token[1], - } - - -async def test_config_flow_reauth_fail_invalid_code( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth when the email code fails.""" - # new code - reauth_invalid_code = "567890" # pretend this code is invalid/incorrect - my_permobil.request_application_token.side_effect = MyPermobilAPIException - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"] == {} - - # request request new token but have the API return error - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_CODE: reauth_invalid_code}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "email_code" - assert result["errors"]["base"] == "invalid_code" - - -async def test_config_flow_reauth_fail_code_request( - hass: HomeAssistant, my_permobil: Mock -) -> None: - """Test the config flow reauth.""" - my_permobil.request_application_code.side_effect = MyPermobilAPIException - mock_entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_DATA, - ) - mock_entry.add_to_hass(hass) - # test the reauth and have request_application_code fail leading to an abort - with patch( - "homeassistant.components.permobil.config_flow.MyPermobil", - return_value=my_permobil, - ): - result = await mock_entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/permobil/test_init.py b/tests/components/permobil/test_init.py new file mode 100644 index 000000000000..57b2e69b4f3c --- /dev/null +++ b/tests/components/permobil/test_init.py @@ -0,0 +1,79 @@ +"""Tests for the Permobil integration.""" + +from homeassistant.components.permobil import DOMAIN +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_permobil_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Permobil configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + 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 + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.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) diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index 569d65df3872..fac10ec491bf 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -1,6 +1,7 @@ """Conftest for Picnic tests.""" from collections.abc import Awaitable, Callable +from datetime import timedelta import json from unittest.mock import MagicMock, patch @@ -9,12 +10,18 @@ import pytest from homeassistant.components.picnic import CONF_COUNTRY_CODE, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, load_fixture from tests.typing import WebSocketGenerator ENTITY_ID = "todo.mock_title_shopping_cart" +SetupDeliveryFixture = Callable[ + [str, tuple[timedelta, timedelta] | None, tuple[timedelta, timedelta]], + Awaitable[dict], +] + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -37,13 +44,48 @@ def mock_picnic_api(): client.session.auth_token = "3q29fpwhulzes" client.get_cart.return_value = json.loads(load_fixture("picnic/cart.json")) client.get_user.return_value = json.loads(load_fixture("picnic/user.json")) - client.get_deliveries.return_value = json.loads( - load_fixture("picnic/delivery.json") - ) + client.get_deliveries.return_value = [ + json.loads(load_fixture("picnic/delivery.json")) + ] client.get_delivery_position.return_value = {} yield client +@pytest.fixture +def setup_delivery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> SetupDeliveryFixture: + """Return a factory to set up the integration with the delivery in a given state.""" + + async def _setup( + status: str, + eta2: tuple[timedelta, timedelta] | None, + slot_window: tuple[timedelta, timedelta], + ) -> dict: + delivery = mock_picnic_api.get_deliveries.return_value[0] + delivery["status"] = status + delivery["delivery_time"] = None + # eta2 is the API's field name for the route-planning ETA + delivery["eta2"] = eta2 and { + "start": (dt_util.utcnow() + eta2[0]).isoformat(), + "end": (dt_util.utcnow() + eta2[1]).isoformat(), + } + delivery["slot"]["window_start"] = ( + dt_util.utcnow() + slot_window[0] + ).isoformat() + delivery["slot"]["window_end"] = (dt_util.utcnow() + slot_window[1]).isoformat() + + 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 delivery + + return _setup + + @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_picnic_api: MagicMock diff --git a/tests/components/picnic/test_coordinator.py b/tests/components/picnic/test_coordinator.py index 9279ec07b497..209fcedd29f8 100644 --- a/tests/components/picnic/test_coordinator.py +++ b/tests/components/picnic/test_coordinator.py @@ -1,11 +1,22 @@ """Tests for the Picnic coordinator.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.picnic.const import ( + DEFAULT_UPDATE_INTERVAL, + DELIVERY_UPDATE_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from .conftest import SetupDeliveryFixture + +from tests.common import MockConfigEntry, async_fire_time_changed async def test_timeout_failed_with_retry( @@ -21,3 +32,149 @@ async def test_timeout_failed_with_retry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("status", "eta2", "slot_window", "expected_interval"), + [ + pytest.param( + "COMPLETED", + None, + (timedelta(hours=-2), timedelta(hours=-1)), + DEFAULT_UPDATE_INTERVAL, + id="no_undelivered_order", + ), + pytest.param( + "CURRENT", + (timedelta(days=2), timedelta(days=2, hours=1)), + (timedelta(days=2), timedelta(days=2, hours=1)), + DEFAULT_UPDATE_INTERVAL, + id="delivery_days_away", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + DELIVERY_UPDATE_INTERVAL, + id="delivery_under_way", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=40), timedelta(minutes=60)), + (timedelta(minutes=40), timedelta(minutes=60)), + timedelta(minutes=10), + id="next_poll_capped_at_window_start", + ), + pytest.param( + "CURRENT", + (timedelta(minutes=30, seconds=30), timedelta(minutes=50)), + (timedelta(minutes=30, seconds=30), timedelta(minutes=50)), + DELIVERY_UPDATE_INTERVAL, + id="next_poll_never_sooner_than_delivery_interval", + ), + pytest.param( + "CURRENT", + (timedelta(hours=-4), timedelta(hours=-3)), + (timedelta(hours=-4), timedelta(hours=-3)), + DEFAULT_UPDATE_INTERVAL, + id="long_past_window_still_current", + ), + pytest.param( + "CURRENT", + None, + (timedelta(minutes=10), timedelta(minutes=70)), + DELIVERY_UPDATE_INTERVAL, + id="slot_window_fallback_without_eta", + ), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_update_interval( + mock_config_entry: MockConfigEntry, + setup_delivery: SetupDeliveryFixture, + status: str, + eta2: tuple[timedelta, timedelta] | None, + slot_window: tuple[timedelta, timedelta], + expected_interval: timedelta, +) -> None: + """Test the update interval for the various delivery states.""" + await setup_delivery(status, eta2, slot_window) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == expected_interval + + +@pytest.mark.usefixtures("freezer") +async def test_update_interval_with_malformed_eta( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, +) -> None: + """Test that a malformed ETA falls back to the slot window.""" + delivery = mock_picnic_api.get_deliveries.return_value[0] + delivery["status"] = "CURRENT" + delivery["delivery_time"] = None + delivery["eta2"] = {"start": "malformed", "end": "malformed"} + delivery["slot"]["window_start"] = ( + dt_util.utcnow() + timedelta(minutes=10) + ).isoformat() + delivery["slot"]["window_end"] = ( + dt_util.utcnow() + timedelta(minutes=70) + ).isoformat() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + +async def test_update_interval_relaxes_after_delivery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + setup_delivery: SetupDeliveryFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the update interval returns to the default once delivered.""" + delivery = await setup_delivery( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + ) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + delivery["status"] = "COMPLETED" + freezer.tick(DELIVERY_UPDATE_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert coordinator.update_interval == DEFAULT_UPDATE_INTERVAL + + +async def test_update_interval_relaxes_when_refresh_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_picnic_api: MagicMock, + setup_delivery: SetupDeliveryFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that failed refreshes still relax the interval past the window.""" + await setup_delivery( + "CURRENT", + (timedelta(minutes=10), timedelta(minutes=30)), + (timedelta(minutes=-15), timedelta(minutes=45)), + ) + + coordinator = mock_config_entry.runtime_data + assert coordinator.update_interval == DELIVERY_UPDATE_INTERVAL + + mock_picnic_api.get_cart.return_value = None + freezer.tick(timedelta(hours=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert coordinator.last_update_success is False + assert coordinator.update_interval == DEFAULT_UPDATE_INTERVAL diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 718f1ef292d6..a3d8a8281ec1 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -409,7 +409,8 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): "eta_window": { "start": "2021-03-05T10:19:20.452+00:00", "end": "2021-03-05T10:39:20.452+00:00", - } + }, + "eta": 1614941090000, } await self._coordinator.async_refresh() @@ -425,6 +426,20 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): "sensor.mock_title_expected_end_of_next_delivery", "2021-03-05T10:39:20+00:00", ) + self._assert_sensor( + "sensor.mock_title_estimated_arrival_of_next_delivery", + "2021-03-05T10:44:50+00:00", + ) + + # The live estimate is cleared again once position data disappears + self.picnic_mock().get_delivery_position.return_value = {} + async_fire_time_changed(self.hass, dt_util.utcnow() + timedelta(minutes=31)) + await self.hass.async_block_till_done(wait_background_tasks=True) + + self._assert_sensor( + "sensor.mock_title_estimated_arrival_of_next_delivery", + STATE_UNKNOWN, + ) async def test_sensors_no_data(self): """Test sensor states when the api only returns empty objects.""" diff --git a/tests/components/pooldose/snapshots/test_init.ambr b/tests/components/pooldose/snapshots/test_init.ambr index b4a76f55c83b..627c692efc2b 100644 --- a/tests/components/pooldose/snapshots/test_init.ambr +++ b/tests/components/pooldose/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.100/index.html', 'connections': set({ }), @@ -24,7 +24,6 @@ '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/portainer/conftest.py b/tests/components/portainer/conftest.py index 54cd66d7ca99..1bd89ba728e0 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -1,17 +1,20 @@ """Common fixtures for the portainer tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from pyportainer.models.docker import ( DockerContainer, DockerContainerStats, DockerSystemDF, DockerVolume, + LocalImageInformation, + PortainerImageUpdateStatus, ) -from pyportainer.models.docker_inspect import DockerInfo, DockerVersion +from pyportainer.models.docker_inspect import DockerInfo, DockerInspect, DockerVersion from pyportainer.models.portainer import Endpoint, PortainerSystemStatus from pyportainer.models.stacks import Stack +from pyportainer.watcher import PortainerImageWatcherResult import pytest from homeassistant.components.portainer.const import DOMAIN @@ -45,7 +48,32 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_portainer_client() -> Generator[AsyncMock]: +def mock_portainer_watcher() -> Generator[MagicMock]: + """Mock PortainerImageWatcher with no results by default.""" + with patch( + "homeassistant.components.portainer.PortainerImageWatcher", autospec=True + ) as mock_watcher_class: + watcher = mock_watcher_class.return_value + watcher.last_check = None + watcher.results = { + ( + 1, + "aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + ): PortainerImageWatcherResult( + endpoint_id=1, + container_id="aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + status=PortainerImageUpdateStatus( + update_available=True, + local_digest="sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96", + registry_digest="sha256:newdigest123456789", + ), + ) + } + yield watcher + + +@pytest.fixture +def mock_portainer_client(mock_portainer_watcher: MagicMock) -> Generator[AsyncMock]: """Mock Portainer client with dynamic exception injection support.""" with ( patch( @@ -77,6 +105,16 @@ def mock_portainer_client() -> Generator[AsyncMock]: client.docker_system_df.return_value = DockerSystemDF.from_dict( load_json_value_fixture("docker_system_df.json", DOMAIN) ) + client.inspect_container.return_value = DockerInspect.from_dict( + load_json_value_fixture("container_inspect.json", DOMAIN) + ) + client.get_image.return_value = LocalImageInformation.from_dict( + load_json_value_fixture("local_image_information.json", DOMAIN) + ) + + client.restart_container = AsyncMock(return_value=None) + client.images_prune = AsyncMock(return_value=None) + client.container_recreate = AsyncMock(return_value=None) client.get_stacks.return_value = [ Stack.from_dict(stack) for stack in load_json_array_fixture("stacks.json", DOMAIN) diff --git a/tests/components/portainer/fixtures/container_inspect.json b/tests/components/portainer/fixtures/container_inspect.json new file mode 100644 index 000000000000..9560314a4d5c --- /dev/null +++ b/tests/components/portainer/fixtures/container_inspect.json @@ -0,0 +1,472 @@ +{ + "Id": "aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Created": "2025-02-17T17:43:39.64001363Z", + "Path": "/bin/sh", + "Args": ["-c", "exit 9"], + "State": { + "Status": "running", + "Running": true, + "Paused": false, + "Restarting": false, + "OOMKilled": false, + "Dead": false, + "Pid": 1234, + "ExitCode": 0, + "Error": "string", + "StartedAt": "2020-01-06T09:06:59.461876391Z", + "FinishedAt": "2020-01-06T09:07:59.461876391Z", + "Health": { + "Status": "healthy", + "FailingStreak": 0, + "Log": [ + { + "Start": "2020-01-04T10:44:24.496525531Z", + "End": "2020-01-04T10:45:21.364524523Z", + "ExitCode": 0, + "Output": "string" + } + ] + } + }, + "Image": "sha256:72297848456d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "ResolvConfPath": "/var/lib/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf/resolv.conf", + "HostnamePath": "/var/lib/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf/hostname", + "HostsPath": "/var/lib/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf/hosts", + "LogPath": "/var/lib/docker/containers/5b7c7e2b992aa426584ce6c47452756066be0e503a08b4516a433a54d2f69e59/5b7c7e2b992aa426584ce6c47452756066be0e503a08b4516a433a54d2f69e59-json.log", + "Name": "/funny_chatelet", + "RestartCount": 0, + "Driver": "overlayfs", + "Platform": "linux", + "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 + }, + "MountLabel": "", + "ProcessLabel": "", + "AppArmorProfile": "", + "ExecIDs": [ + "b35395de42bc8abd327f9dd65d913b9ba28c74d2f0734eeeae84fa1c616a0fca", + "3fc1232e5cd20c8de182ed81178503dc6437f4e7ef12b52cc5e8de020652f1c4" + ], + "HostConfig": { + "CpuShares": 0, + "Memory": 0, + "CgroupParent": "string", + "BlkioWeight": 1000, + "BlkioWeightDevice": [ + { + "Path": "string", + "Weight": 0 + } + ], + "BlkioDeviceReadBps": [ + { + "Path": "string", + "Rate": 0 + } + ], + "BlkioDeviceWriteBps": [ + { + "Path": "string", + "Rate": 0 + } + ], + "BlkioDeviceReadIOps": [ + { + "Path": "string", + "Rate": 0 + } + ], + "BlkioDeviceWriteIOps": [ + { + "Path": "string", + "Rate": 0 + } + ], + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuRealtimePeriod": 0, + "CpuRealtimeRuntime": 0, + "CpusetCpus": "0-3", + "CpusetMems": "string", + "Devices": [ + { + "PathOnHost": "/dev/deviceName", + "PathInContainer": "/dev/deviceName", + "CgroupPermissions": "mrw" + } + ], + "DeviceCgroupRules": ["c 13:* rwm"], + "DeviceRequests": [ + { + "Driver": "nvidia", + "Count": -1, + "DeviceIDs": ["0", "1", "GPU-fef8089b-4820-abfc-e83e-94318197576e"], + "Capabilities": [["gpu", "nvidia", "compute"]], + "Options": { + "property1": "string", + "property2": "string" + } + } + ], + "KernelMemoryTCP": 0, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": 100, + "NanoCpus": 0, + "OomKillDisable": true, + "Init": true, + "PidsLimit": 0, + "Ulimits": [ + { + "Name": "string", + "Soft": 0, + "Hard": 0 + } + ], + "CpuCount": 0, + "CpuPercent": 0, + "IOMaximumIOps": 0, + "IOMaximumBandwidth": 0, + "Binds": ["string"], + "ContainerIDFile": "", + "LogConfig": { + "Type": "local", + "Config": { + "max-file": "5", + "max-size": "10m" + } + }, + "NetworkMode": "string", + "PortBindings": { + "443/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "4443" + } + ], + "80/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "80" + }, + { + "HostIp": "0.0.0.0", + "HostPort": "8080" + } + ], + "80/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "80" + } + ], + "53/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "53" + } + ] + }, + "RestartPolicy": { + "Name": "", + "MaximumRetryCount": 0 + }, + "AutoRemove": true, + "VolumeDriver": "string", + "VolumesFrom": ["string"], + "Mounts": [ + { + "Target": "string", + "Source": "string", + "Type": "bind", + "ReadOnly": true, + "Consistency": "string", + "BindOptions": { + "Propagation": "private", + "NonRecursive": false, + "CreateMountpoint": false, + "ReadOnlyNonRecursive": false, + "ReadOnlyForceRecursive": false + }, + "VolumeOptions": { + "NoCopy": false, + "Labels": { + "property1": "string", + "property2": "string" + }, + "DriverConfig": { + "Name": "string", + "Options": { + "property1": "string", + "property2": "string" + } + }, + "Subpath": "dir-inside-volume/subdirectory" + }, + "ImageOptions": { + "Subpath": "dir-inside-image/subdirectory" + }, + "TmpfsOptions": { + "SizeBytes": 0, + "Mode": 0, + "Options": [["noexec"]] + } + } + ], + "ConsoleSize": [80, 64], + "Annotations": { + "property1": "string", + "property2": "string" + }, + "CapAdd": ["string"], + "CapDrop": ["string"], + "CgroupnsMode": "private", + "Dns": ["string"], + "DnsOptions": ["string"], + "DnsSearch": ["string"], + "ExtraHosts": ["string"], + "GroupAdd": ["string"], + "IpcMode": "string", + "Cgroup": "string", + "Links": ["string"], + "OomScoreAdj": 500, + "PidMode": "string", + "Privileged": true, + "PublishAllPorts": true, + "ReadonlyRootfs": true, + "SecurityOpt": ["string"], + "StorageOpt": { + "property1": "string", + "property2": "string" + }, + "Tmpfs": { + "property1": "string", + "property2": "string" + }, + "UTSMode": "string", + "UsernsMode": "string", + "ShmSize": 0, + "Sysctls": { + "net.ipv4.ip_forward": "1" + }, + "Runtime": "string", + "Isolation": "default", + "MaskedPaths": [ + "/proc/asound", + "/proc/acpi", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/proc/scsi", + "/sys/firmware", + "/sys/devices/virtual/powercap" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + }, + "GraphDriver": { + "Name": "overlay2", + "Data": { + "MergedDir": "/var/lib/docker/overlay2/ef749362d13333e65fc95c572eb525abbe0052e16e086cb64bc3b98ae9aa6d74/merged", + "UpperDir": "/var/lib/docker/overlay2/ef749362d13333e65fc95c572eb525abbe0052e16e086cb64bc3b98ae9aa6d74/diff", + "WorkDir": "/var/lib/docker/overlay2/ef749362d13333e65fc95c572eb525abbe0052e16e086cb64bc3b98ae9aa6d74/work" + } + }, + "SizeRw": "122880", + "SizeRootFs": "1653948416", + "Mounts": [ + { + "Type": "volume", + "Name": "myvolume", + "Source": "/var/lib/docker/volumes/myvolume/_data", + "Destination": "/usr/share/nginx/html/", + "Driver": "local", + "Mode": "z", + "RW": true, + "Propagation": "" + } + ], + "Config": { + "Hostname": "439f4e91bd1d", + "Domainname": "string", + "User": "123:456", + "AttachStdin": false, + "AttachStdout": true, + "AttachStderr": true, + "ExposedPorts": { + "80/tcp": {}, + "443/tcp": {} + }, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ], + "Cmd": ["/bin/sh"], + "Healthcheck": { + "Test": ["string"], + "Interval": 0, + "Timeout": 0, + "Retries": 0, + "StartPeriod": 0, + "StartInterval": 0 + }, + "ArgsEscaped": false, + "Image": "example-image:1.0", + "Volumes": { + "property1": {}, + "property2": {} + }, + "WorkingDir": "/public/", + "Entrypoint": [], + "NetworkDisabled": true, + "MacAddress": "string", + "OnBuild": [], + "Labels": { + "com.example.some-label": "some-value", + "com.example.some-other-label": "some-other-value" + }, + "StopSignal": "SIGTERM", + "StopTimeout": 10, + "Shell": ["/bin/sh", "-c"] + }, + "NetworkSettings": { + "Bridge": "docker0", + "SandboxID": "9d12daf2c33f5959c8bf90aa513e4f65b561738661003029ec84830cd503a0c3", + "HairpinMode": false, + "LinkLocalIPv6Address": "", + "LinkLocalIPv6PrefixLen": "", + "Ports": { + "443/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "4443" + } + ], + "80/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "80" + }, + { + "HostIp": "0.0.0.0", + "HostPort": "8080" + } + ], + "80/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "80" + } + ], + "53/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "53" + } + ] + }, + "SandboxKey": "/var/run/docker/netns/8ab54b426c38", + "SecondaryIPAddresses": [ + { + "Addr": "string", + "PrefixLen": 0 + } + ], + "SecondaryIPv6Addresses": [ + { + "Addr": "string", + "PrefixLen": 0 + } + ], + "EndpointID": "b88f5b905aabf2893f3cbc4ee42d1ea7980bbc0a92e2c8922b1e1795298afb0b", + "Gateway": "172.17.0.1", + "GlobalIPv6Address": "2001:db8::5689", + "GlobalIPv6PrefixLen": 64, + "IPAddress": "172.17.0.4", + "IPPrefixLen": 16, + "IPv6Gateway": "2001:db8:2::100", + "MacAddress": "02:42:ac:11:00:04", + "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"] + }, + "property2": { + "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"] + } + } + } +} diff --git a/tests/components/portainer/fixtures/local_image_information.json b/tests/components/portainer/fixtures/local_image_information.json new file mode 100644 index 000000000000..dd8d2d7060c1 --- /dev/null +++ b/tests/components/portainer/fixtures/local_image_information.json @@ -0,0 +1,90 @@ +{ + "Id": "sha256:ec3f0931a6e6b6855d76b2d7b0be30e81860baccd891b2e243280bf1cd8ad710", + "RepoTags": [ + "example:1.0", + "example:latest", + "example:stable", + "internal.registry.example.com:5000/example:1.0" + ], + "RepoDigests": [ + "example@sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb", + "internal.registry.example.com:5000/example@sha256:b69959407d21e8a062e0416bf13405bb2b71ed7a84dde4158ebafacfa06f5578" + ], + "Parent": "", + "Comment": "", + "Created": "2022-02-04T21:20:12.497794809Z", + "Container": "65974bc86f1770ae4bff79f651ebdbce166ae9aada632ee3fa9af3a264911735", + "ContainerConfig": { + "Hostname": "439f4e91bd1d", + "Domainname": "string", + "User": "string", + "AttachStdin": false, + "AttachStdout": true, + "AttachStderr": true, + "ExposedPorts": { + "80/tcp": {}, + "443/tcp": {} + }, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [], + "Cmd": [], + "Healthcheck": {}, + "ArgsEscaped": false, + "Image": "example-image:1.0", + "Volumes": {}, + "WorkingDir": "/public/", + "Entrypoint": [], + "NetworkDisabled": true, + "MacAddress": "string", + "OnBuild": [], + "Labels": {}, + "StopSignal": "SIGTERM", + "StopTimeout": 10, + "Shell": [] + }, + "DockerVersion": "20.10.7", + "Author": "", + "Config": { + "Hostname": "", + "Domainname": "", + "User": "web:web", + "AttachStdin": false, + "AttachStdout": false, + "AttachStderr": false, + "ExposedPorts": {}, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [], + "Cmd": [], + "Healthcheck": {}, + "ArgsEscaped": true, + "Image": "", + "Volumes": {}, + "WorkingDir": "/public/", + "Entrypoint": [], + "OnBuild": [], + "Labels": {}, + "StopSignal": "SIGTERM", + "Shell": [] + }, + "Architecture": "arm", + "Variant": "v7", + "Os": "linux", + "OsVersion": "", + "Size": 1239828, + "VirtualSize": 1239828, + "GraphDriver": { + "Name": "overlay2", + "Data": {} + }, + "RootFS": { + "Type": "layers", + "Layers": [] + }, + "Metadata": { + "LastTagTime": "2022-02-28T14:40:02.623929178Z" + } +} diff --git a/tests/components/portainer/snapshots/test_init.ambr b/tests/components/portainer/snapshots/test_init.ambr index 47eceb891301..237c4b507149 100644 --- a/tests/components/portainer/snapshots/test_init.ambr +++ b/tests/components/portainer/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/dashboard', 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': None, 'name': 'my-environment', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/ff31facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': None, 'name': 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': None, 'name': 'focused_einstein', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -112,15 +109,14 @@ 'model_id': None, 'name': 'funny_chatelet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -141,15 +137,14 @@ 'model_id': None, 'name': 'practical_morse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -170,15 +165,14 @@ 'model_id': None, 'name': 'serene_banach', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/stacks/webstack', 'connections': set({ }), @@ -199,15 +193,14 @@ 'model_id': None, 'name': 'webstack', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/stacks/dashy', 'connections': set({ }), @@ -228,15 +221,14 @@ 'model_id': None, 'name': 'dashy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/containers/cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf', 'connections': set({ }), @@ -257,15 +249,14 @@ 'model_id': None, 'name': 'stoic_turing', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/dashy_config', 'connections': set({ }), @@ -286,15 +277,14 @@ 'model_id': None, 'name': 'dashy_config', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/db_data', 'connections': set({ }), @@ -315,15 +305,14 @@ 'model_id': None, 'name': 'db_data', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1:9000/#!/1/docker/volumes/myvolume', 'connections': set({ }), @@ -344,7 +333,6 @@ 'model_id': None, 'name': 'myvolume', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/portainer/snapshots/test_update.ambr b/tests/components/portainer/snapshots/test_update.ambr new file mode 100644 index 000000000000..7f3bb09835ff --- /dev/null +++ b/tests/components/portainer/snapshots/test_update.ambr @@ -0,0 +1,373 @@ +# serializer version: 1 +# name: test_update_entities[update.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05', + : None, + }), + 'context': , + 'entity_id': 'update.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_entities[update.focused_einstein_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.focused_einstein_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_focused_einstein_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.focused_einstein_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'focused_einstein Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'focused_einstein', + : None, + }), + 'context': , + 'entity_id': 'update.focused_einstein_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_entities[update.funny_chatelet_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.funny_chatelet_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.funny_chatelet_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'funny_chatelet Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : 'sha256:newdigest123456789', + : None, + : None, + : None, + : , + : 'funny_chatelet', + : None, + }), + 'context': , + 'entity_id': 'update.funny_chatelet_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_entities[update.practical_morse_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.practical_morse_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_practical_morse_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.practical_morse_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'practical_morse Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'practical_morse', + : None, + }), + 'context': , + 'entity_id': 'update.practical_morse_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_entities[update.serene_banach_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.serene_banach_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_serene_banach_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.serene_banach_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'serene_banach Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'serene_banach', + : None, + }), + 'context': , + 'entity_id': 'update.serene_banach_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_entities[update.stoic_turing_image_update_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.stoic_turing_image_update_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image update available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image update available', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'container_image_update', + 'unique_id': 'portainer_test_entry_123_stoic_turing_container_image_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities[update.stoic_turing_image_update_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/portainer/icon.png', + : 'stoic_turing Image update available', + : False, + : 'sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb', + : None, + : None, + : None, + : None, + : , + : 'stoic_turing', + : None, + }), + 'context': , + 'entity_id': 'update.stoic_turing_image_update_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 9ea93c1f9fa1..dab13e650cf2 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -22,6 +22,8 @@ from homeassistant.const import ( CONF_HOST, CONF_URL, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -51,7 +53,7 @@ async def test_setup_exceptions( expected_state: ConfigEntryState, ) -> None: """Test the _async_setup.""" - mock_portainer_client.get_endpoints.side_effect = exception + mock_portainer_client.portainer_system_status.side_effect = exception await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is expected_state @@ -181,6 +183,46 @@ async def test_migration_v3_to_v5( assert entity_after.unique_id == f"{entry.entry_id}_1_adguard_container" +async def test_unload_entry( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_unload_entry.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state == 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 == ConfigEntryState.NOT_LOADED + mock_portainer_watcher.stop.assert_called_once() + + +async def test_watcher_start_stop( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that watcher starts and stops on HA events.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + mock_portainer_watcher.start.assert_called_once() + + mock_portainer_watcher.stop.reset_mock() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_portainer_watcher.stop.assert_called_once() + + async def test_migration_v4_to_v5( hass: HomeAssistant, mock_portainer_client: AsyncMock, diff --git a/tests/components/portainer/test_services.py b/tests/components/portainer/test_services.py index e685e5036fd6..deeff7cdc98d 100644 --- a/tests/components/portainer/test_services.py +++ b/tests/components/portainer/test_services.py @@ -20,7 +20,7 @@ from homeassistant.components.portainer.services import ( ATTR_TIMEOUT, SERVICE_PRUNE_IMAGES, SERVICE_RECREATE_CONTAINER, - _get_endpoint_id, + _async_get_device, ) from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant @@ -162,15 +162,15 @@ async def test_service_recreate_container( [ ( PortainerAuthenticationError("auth"), - "invalid_auth_no_details", + "invalid_auth", ), ( PortainerConnectionError("conn"), - "cannot_connect_no_details", + "cannot_connect", ), ( PortainerTimeoutError("timeout"), - "timeout_connect_no_details", + "timeout_connect", ), ], ) @@ -297,17 +297,14 @@ async def test_service_prune_images_device_gone( mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test _get_endpoint_id raises when the device ID no longer exists in the registry.""" + """Test _async_get_device raises when the device ID no longer exists in the registry.""" await setup_integration(hass, mock_config_entry) - loaded_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) - assert loaded_entry is not None mock_call = MagicMock() mock_call.hass = hass - mock_call.data = {ATTR_DEVICE_ID: "nonexistent_device_id"} with pytest.raises(ServiceValidationError): - await _get_endpoint_id(mock_call, loaded_entry) + _async_get_device(mock_call, "nonexistent_device_id") mock_portainer_client.images_prune.assert_not_called() diff --git a/tests/components/portainer/test_update.py b/tests/components/portainer/test_update.py new file mode 100644 index 000000000000..c46d8b4f4f63 --- /dev/null +++ b/tests/components/portainer/test_update.py @@ -0,0 +1,140 @@ +"""Tests for the Portainer update platform.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import 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 + +ENTITY_ID = "update.funny_chatelet_image_update_available" + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" + + +@pytest.mark.usefixtures("mock_portainer_client") +async def test_update_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test for all Portainer update entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, + entity_registry, + snapshot, + mock_config_entry.entry_id, + ) + + +async def test_update_install( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful container image update installation.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "update", + "install", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + mock_portainer_client.container_recreate.assert_called_once() + + +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + (PortainerAuthenticationError("auth"), "invalid_auth_no_details"), + (PortainerConnectionError("conn"), "cannot_connect_no_details"), + ], +) +async def test_update_install_errors( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + translation_key: str, +) -> None: + """Test container image update install error handling.""" + mock_portainer_client.container_recreate.side_effect = exception + + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "update", + "install", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + +async def test_update_using_cache( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the update entity uses the cache and doesn't call the API.""" + mock_portainer_watcher.last_check = 1234 + + with ( + patch( + "homeassistant.components.portainer.coordinator.time.monotonic", + return_value=1235.0, + ), + patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ), + ): + await setup_integration(hass, mock_config_entry) + + # Reset call counts, since it needs to be measured what happens in this sequence + mock_portainer_client.inspect_container.reset_mock() + mock_portainer_client.get_image.reset_mock() + + # Trigger a refresh, but it should use the cache + await hass.services.async_call( + "update", + "install", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + mock_portainer_client.inspect_container.assert_not_called() + mock_portainer_client.get_image.assert_not_called() diff --git a/tests/components/prana/snapshots/test_init.ambr b/tests/components/prana/snapshots/test_init.ambr index 8c4f89b6b5e9..33a2e69e5955 100644 --- a/tests/components/prana/snapshots/test_init.ambr +++ b/tests/components/prana/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info_registered DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'PRANA RECUPERATOR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ECC9FFE0E574', 'sw_version': '46', 'via_device_id': None, diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index f2d0befe630e..f450dd447425 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -13,7 +13,8 @@ from lru import LRU import objgraph import pytest -from homeassistant.components.profiler import ( +from homeassistant.components.profiler.const import DOMAIN +from homeassistant.components.profiler.services import ( _LRU_CACHE_WRAPPER_OBJECT, _SQLALCHEMY_LRU_OBJECT, CONF_SECONDS, @@ -31,7 +32,6 @@ from homeassistant.components.profiler import ( SERVICE_STOP_LOG_OBJECT_SOURCES, SERVICE_STOP_LOG_OBJECTS, ) -from homeassistant.components.profiler.const import DOMAIN from homeassistant.const import CONF_ENABLED, CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index 1cf65ea78746..07c70348383c 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -1,5 +1,7 @@ """Tests for Proxmox VE integration.""" +from copy import deepcopy + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -53,6 +55,11 @@ MERGED_PERMISSIONS = { | set(SNAPSHOT_PERMISSIONS) } +PVEVMUSER_PERMISSIONS = deepcopy(MERGED_PERMISSIONS) +# Remove node-level and root-level scopes entirely +PVEVMUSER_PERMISSIONS.pop("/", None) +PVEVMUSER_PERMISSIONS.pop("/nodes", None) + async def setup_integration( hass: HomeAssistant, diff --git a/tests/components/proxmoxve/conftest.py b/tests/components/proxmoxve/conftest.py index 1decc74ac46c..8eab09af9095 100644 --- a/tests/components/proxmoxve/conftest.py +++ b/tests/components/proxmoxve/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.proxmoxve.const import ( CONF_TOKEN_SECRET, CONF_VMS, DOMAIN, + ProxmoxPermission, ) from homeassistant.const import ( CONF_HOST, @@ -124,8 +125,12 @@ def mock_proxmox_client(): node_mock.storage.get.return_value = load_json_array_fixture( "nodes/storage.json", DOMAIN ) - node_mock.tasks.get.return_value = load_json_array_fixture( - "nodes/tasks.json", DOMAIN + + node_mock.tasks.get.side_effect = lambda **kwargs: ( + [] + if ProxmoxPermission.SYSAUDIT + not in mock_instance.access.permissions.get.return_value.get("/nodes", []) + else load_json_array_fixture("nodes/tasks.json", DOMAIN) ) qemu_by_vmid = {vm["vmid"]: vm for vm in qemu_list} diff --git a/tests/components/proxmoxve/snapshots/test_button.ambr b/tests/components/proxmoxve/snapshots/test_button.ambr index ef752b8a613e..ff74d97cd4f7 100644 --- a/tests/components/proxmoxve/snapshots/test_button.ambr +++ b/tests/components/proxmoxve/snapshots/test_button.ambr @@ -652,407 +652,6 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_db-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.vm_db', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'resume', - 'unique_id': '1234_101_resume', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db', - }), - 'context': , - 'entity_id': 'button.vm_db', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_create_snapshot-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.vm_db_create_snapshot', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Create snapshot', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Create snapshot', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'snapshot_create', - 'unique_id': '1234_101_snapshot_create', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_create_snapshot-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Create snapshot', - }), - 'context': , - 'entity_id': 'button.vm_db_create_snapshot', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_hibernate-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.vm_db_hibernate', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Hibernate', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Hibernate', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hibernate', - 'unique_id': '1234_101_hibernate', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_hibernate-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Hibernate', - }), - 'context': , - 'entity_id': 'button.vm_db_hibernate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_reset-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.vm_db_reset', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Reset', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Reset', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'reset', - 'unique_id': '1234_101_reset', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_reset-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Reset', - }), - 'context': , - 'entity_id': 'button.vm_db_reset', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_restart-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.vm_db_restart', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Restart', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Restart', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234_101_restart', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_restart-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'restart', - : 'vm-db Restart', - }), - 'context': , - 'entity_id': 'button.vm_db_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_shut_down-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.vm_db_shut_down', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Shut down', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Shut down', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'shutdown', - 'unique_id': '1234_101_shutdown', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_shut_down-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Shut down', - }), - 'context': , - 'entity_id': 'button.vm_db_shut_down', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_start-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.vm_db_start', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Start', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Start', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'start', - 'unique_id': '1234_101_start', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Start', - }), - 'context': , - 'entity_id': 'button.vm_db_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_button_entities[button.vm_db_stop-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - '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.vm_db_stop', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Stop', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Stop', - 'platform': 'proxmoxve', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'stop', - 'unique_id': '1234_101_stop', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_button_entities[button.vm_db_stop-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'vm-db Stop', - }), - 'context': , - 'entity_id': 'button.vm_db_stop', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_button_entities[button.vm_web-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/proxmoxve/test_binary_sensor.py b/tests/components/proxmoxve/test_binary_sensor.py index 4dd60e789f32..d1e2eb5c5983 100644 --- a/tests/components/proxmoxve/test_binary_sensor.py +++ b/tests/components/proxmoxve/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from . import setup_integration +from . import PVEVMUSER_PERMISSIONS, setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -80,3 +80,48 @@ async def test_refresh_exceptions( state = hass.states.get("binary_sensor.ct_nginx_status") assert state.state == STATE_UNAVAILABLE + + +async def test_binary_sensors_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that binary_sensors are created when allowed.""" + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "binary_sensor.pve1_status" in {e.entity_id for e in entries} + assert "binary_sensor.pve1_backup_status" in {e.entity_id for e in entries} + + +async def test_binary_sensors_absent_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that binary_sensors are not created when not allowed.""" + mock_proxmox_client.access.permissions.get.return_value = PVEVMUSER_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "binary_sensor.pve1_status" in {e.entity_id for e in entries} + assert "binary_sensor.pve1_backup_status" not in {e.entity_id for e in entries} diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index 2b8769949101..abf2bd171bdc 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -11,7 +11,7 @@ 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, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import AUDIT_PERMISSIONS, setup_integration @@ -362,61 +362,19 @@ async def test_container_buttons_exceptions( ) -@pytest.mark.parametrize( - ("entity_id", "translation_key"), - [ - ("button.pve1_shut_down", "no_permission_node_power"), - ("button.pve1_start_all", "no_permission_vm_lxc_power"), - ("button.ct_nginx_start", "no_permission_vm_lxc_power"), - ("button.vm_web_start", "no_permission_vm_lxc_power"), - ("button.vm_web_create_snapshot", "no_permission_snapshot"), - ], -) -async def test_node_buttons_permission_denied_for_auditor_role( +async def test_buttons_only_allowed_buttons( hass: HomeAssistant, mock_proxmox_client: MagicMock, mock_config_entry: MockConfigEntry, - entity_id: str, - translation_key: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test that buttons are raising accordingly for Auditor permissions.""" + """Test that ProxmoxVE button is not generated when not allowed.""" mock_proxmox_client.access.permissions.get.return_value = AUDIT_PERMISSIONS await setup_integration(hass, mock_config_entry) - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert exc_info.value.translation_key == translation_key + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) - -@pytest.mark.parametrize( - ("entity_id", "translation_key"), - [ - ("button.vm_db_start", "no_permission_vm_lxc_power"), - ("button.vm_db_create_snapshot", "no_permission_snapshot"), - ], -) -async def test_vm_buttons_denied_for_specific_vm( - hass: HomeAssistant, - mock_proxmox_client: MagicMock, - mock_config_entry: MockConfigEntry, - entity_id: str, - translation_key: str, -) -> None: - """Test that button only works on actual permissions.""" - await setup_integration(hass, mock_config_entry) - mock_proxmox_client._node_mock.qemu(101) - - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert exc_info.value.translation_key == translation_key + assert all(not entry.entity_id.startswith("button.") for entry in entries) diff --git a/tests/components/proxmoxve/test_config_flow.py b/tests/components/proxmoxve/test_config_flow.py index c6814ca95717..981332352929 100644 --- a/tests/components/proxmoxve/test_config_flow.py +++ b/tests/components/proxmoxve/test_config_flow.py @@ -9,7 +9,7 @@ import pytest import requests from requests.exceptions import ConnectTimeout, SSLError -from homeassistant.components.proxmoxve import CONF_AUTH_METHOD, CONF_HOST, CONF_REALM +from homeassistant.components.proxmoxve import CONF_AUTH_METHOD, CONF_REALM from homeassistant.components.proxmoxve.const import ( CONF_NODE, CONF_NODES, @@ -17,8 +17,9 @@ from homeassistant.components.proxmoxve.const import ( CONF_TOKEN_SECRET, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_TOKEN, @@ -424,82 +425,6 @@ async def test_duplicate_entry( assert result["reason"] == "already_configured" -async def test_import_flow( - hass: HomeAssistant, - mock_setup_entry: MagicMock, - mock_proxmox_client: MagicMock, -) -> None: - """Test importing from YAML creates a config entry and sets it up.""" - MOCK_IMPORT_CONFIG = { - DOMAIN: { - **MOCK_USER_STEP, - **MOCK_USER_AUTH_STEP_PASSWORD, - **MOCK_USER_SETUP, - } - } - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_CONFIG[DOMAIN] - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "127.0.0.1" - assert result["data"][CONF_HOST] == "127.0.0.1" - assert len(mock_setup_entry.mock_calls) == 1 - - assert result["result"].state is ConfigEntryState.LOADED - - -@pytest.mark.parametrize( - ("exception", "reason"), - [ - ( - AuthenticationError("Invalid credentials"), - "invalid_auth", - ), - ( - SSLError("SSL handshake failed"), - "ssl_error", - ), - ( - ConnectTimeout("Connection timed out"), - "connect_timeout", - ), - ( - ResourceException("404", "status_message", "content"), - "no_nodes_found", - ), - ( - requests.exceptions.ConnectionError("Connection error"), - "cannot_connect", - ), - ], -) -async def test_import_flow_exceptions( - hass: HomeAssistant, - mock_setup_entry: MagicMock, - mock_proxmox_client: MagicMock, - exception: Exception, - reason: str, -) -> None: - """Test importing from YAML creates a config entry and sets it up.""" - MOCK_IMPORT_CONFIG = { - DOMAIN: { - **MOCK_USER_STEP, - **MOCK_USER_AUTH_STEP_PASSWORD, - **MOCK_USER_SETUP, - } - } - mock_proxmox_client.nodes.get.side_effect = exception - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_CONFIG[DOMAIN] - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason - assert len(mock_setup_entry.mock_calls) == 0 - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - - def sanitize_config_entry(data: dict[str, Any]) -> dict[str, Any]: """Sanitize config entry data by removing unused auth keys.""" # Ignore unused keys (i.e. when switching from password to token or vice versa) diff --git a/tests/components/proxmoxve/test_init.py b/tests/components/proxmoxve/test_init.py index 7c3aa0fac2af..d55afccaa03c 100644 --- a/tests/components/proxmoxve/test_init.py +++ b/tests/components/proxmoxve/test_init.py @@ -11,11 +11,7 @@ from requests.exceptions import ConnectTimeout, SSLError from homeassistant.components.proxmoxve.const import ( AUTH_PAM, CONF_AUTH_METHOD, - CONF_CONTAINERS, - CONF_NODE, - CONF_NODES, CONF_REALM, - CONF_VMS, DOMAIN, ) from homeassistant.components.proxmoxve.coordinator import ( @@ -32,54 +28,14 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from . import setup_integration from tests.common import MockConfigEntry, async_load_json_array_fixture -@pytest.mark.usefixtures("mock_setup_entry") -async def test_config_import( - hass: HomeAssistant, - mock_proxmox_client: MagicMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test sensor initialization.""" - await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: [ - { - CONF_HOST: "127.0.0.1", - CONF_PORT: 8006, - CONF_REALM: "pam", - CONF_USERNAME: "test_user@pam", - CONF_PASSWORD: "test_password", - CONF_VERIFY_SSL: True, - CONF_NODES: [ - { - CONF_NODE: "pve1", - CONF_VMS: [100, 101], - CONF_CONTAINERS: [200, 201], - }, - ], - } - ] - }, - ) - - 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.parametrize( ("exception", "expected_state", "target"), [ diff --git a/tests/components/proxmoxve/test_sensor.py b/tests/components/proxmoxve/test_sensor.py index f4fc55cb97e5..a2109bbd0372 100644 --- a/tests/components/proxmoxve/test_sensor.py +++ b/tests/components/proxmoxve/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import PVEVMUSER_PERMISSIONS, setup_integration from tests.common import ( MockConfigEntry, @@ -68,3 +68,26 @@ async def test_storage_missing_used_fraction( state = hass.states.get("sensor.storage_local_storage_usage_percentage") assert state.state == STATE_UNKNOWN + + +async def test_sensors_according_to_permissions( + hass: HomeAssistant, + mock_proxmox_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that sensors are not created when not allowed.""" + mock_proxmox_client.access.permissions.get.return_value = PVEVMUSER_PERMISSIONS + + with patch( + "homeassistant.components.proxmoxve.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert "sensor.pve1_status" in {e.entity_id for e in entries} + assert "sensor.pve1_cpu" not in {e.entity_id for e in entries} diff --git a/tests/components/ps4/snapshots/test_media_player.ambr b/tests/components/ps4/snapshots/test_media_player.ambr index c4a9da3f2d56..df26937ecc9c 100644 --- a/tests/components/ps4/snapshots/test_media_player.ambr +++ b/tests/components/ps4/snapshots/test_media_player.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Fake PS4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '9.87', 'via_device_id': None, diff --git a/tests/components/ptdevices/fixtures/ptdevices_level.json b/tests/components/ptdevices/fixtures/ptdevices_level.json index c69e7049696d..402992d60a39 100644 --- a/tests/components/ptdevices/fixtures/ptdevices_level.json +++ b/tests/components/ptdevices/fixtures/ptdevices_level.json @@ -27,6 +27,7 @@ "battery_voltage": 5.69, "battery_status": "good", "battery_status_number": 1, + "external_power": 1, "volume_level": 2387.837753, "volume_level_oz": 80742.4, "max_volume": 1269, diff --git a/tests/components/ptdevices/snapshots/test_binary_sensor.ambr b/tests/components/ptdevices/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..354725850c9c --- /dev/null +++ b/tests/components/ptdevices/snapshots/test_binary_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.home_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.home_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_battery_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.home_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'Home Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.home_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.home_external_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.home_external_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External power', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External power', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_external_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.home_external_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Home External power', + }), + 'context': , + 'entity_id': 'binary_sensor.home_external_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ptdevices/test_binary_sensor.py b/tests/components/ptdevices/test_binary_sensor.py new file mode 100644 index 000000000000..d6ddeeb16e7e --- /dev/null +++ b/tests/components/ptdevices/test_binary_sensor.py @@ -0,0 +1,95 @@ +"""Test for PTDevices binary sensors.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ptdevices.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.ptdevices._PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_ptdevices_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_battery_status_sensor_states( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test battery status binary sensor state recognition.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the battery status is "normal" + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_OFF + + # Set the new battery status to low + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"][ + "battery_status" + ] = "low" + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the battery status is on (low) + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_ON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_add_remove_binary_sensor( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing and new binary sensors.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the battery status exists + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state != STATE_UNKNOWN + + # Remove the battery_status + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"].pop( + "battery_status" + ) + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the battery_status is no longer present + assert (state := hass.states.get("binary_sensor.home_battery")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/ptdevices/test_sensor.py b/tests/components/ptdevices/test_sensor.py index 494fc632e555..97fc9ab63439 100644 --- a/tests/components/ptdevices/test_sensor.py +++ b/tests/components/ptdevices/test_sensor.py @@ -2,16 +2,18 @@ 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.ptdevices.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_UNKNOWN, 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 +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -29,3 +31,31 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_add_remove_sensor( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling of missing and new sensors.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await setup_integration(hass, mock_ptdevices_config_entry) + + # Make sure the status exists + assert (state := hass.states.get("sensor.home_status")) + assert state.state != STATE_UNKNOWN + + # Remove the status + mock_ptdevices_interface.get_data.return_value["body"]["C0FFEEC0FFEE"].pop("status") + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Make sure the status is no longer present + assert (state := hass.states.get("sensor.home_status")) + assert state.state == STATE_UNKNOWN diff --git a/tests/components/rabbitair/snapshots/test_init.ambr b/tests/components/rabbitair/snapshots/test_init.ambr index dfa9712d58c1..5f11c7f10471 100644 --- a/tests/components/rabbitair/snapshots/test_init.ambr +++ b/tests/components/rabbitair/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Rabbit Air', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.3.17', 'via_device_id': None, diff --git a/tests/components/rainbird/snapshots/test_init.ambr b/tests/components/rainbird/snapshots/test_init.ambr index 594652e0c857..0c8ec24770de 100644 --- a/tests/components/rainbird/snapshots/test_init.ambr +++ b/tests/components/rainbird/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Rain Bird Controller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '9.12', 'via_device_id': None, diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index a8df4bf188c0..258ecd49fcab 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -557,3 +557,23 @@ async def test_reload_migration_with_leading_zero_mac( len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 1 ) + + +async def test_options_listener( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test options update listener reloading the config entry.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + # Changing options triggers reload + with patch( + "homeassistant.components.rainbird.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + hass.config_entries.async_update_entry(config_entry, options={"duration": 5}) + await hass.async_block_till_done() + + # The entry should have been reloaded + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/rainforest_raven/snapshots/test_init.ambr b/tests/components/rainforest_raven/snapshots/test_init.ambr index 9cc89cfcc9ea..2f6c9868e23f 100644 --- a/tests/components/rainforest_raven/snapshots/test_init.ambr +++ b/tests/components/rainforest_raven/snapshots/test_init.ambr @@ -7,8 +7,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -29,7 +29,6 @@ 'model_id': 'Z105-2-EMU2-LEDD_JM', 'name': 'RAVEn Device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.0 (7400)', 'via_device_id': None, diff --git a/tests/components/recorder/test_filters.py b/tests/components/recorder/test_filters.py index 2ba7bfbcd0b4..82cf2e786b82 100644 --- a/tests/components/recorder/test_filters.py +++ b/tests/components/recorder/test_filters.py @@ -150,13 +150,5 @@ async def test_an_empty_filter_raises() -> None: "No filter configuration provided, check" " has_config before calling this method" ), - ): - filters.states_entity_filter() - with pytest.raises( - RuntimeError, - match=( - "No filter configuration provided, check" - " has_config before calling this method" - ), ): filters.events_entity_filter() diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py index ea52d961414b..cf074cc1d77e 100644 --- a/tests/components/remote_calendar/test_calendar.py +++ b/tests/components/remote_calendar/test_calendar.py @@ -1,6 +1,6 @@ """Tests for calendar platform of Remote Calendar.""" -from datetime import datetime +from datetime import datetime, timedelta import pathlib import textwrap @@ -12,6 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import setup_integration from .conftest import ( @@ -530,3 +531,53 @@ async def test_event_edge_during_refresh_interval( assert state assert state.state == STATE_OFF assert state.attributes.get("message") == "Event Two" + + +@respx.mock +@pytest.mark.freeze_time("2026-05-18 06:00:00+00:00") +async def test_coordinator_refresh_updates_upcoming_event_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test a coordinator refresh updates the materialized upcoming event.""" + original_calendar = textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Wake up + DTSTART:20260518T064000 + DTEND:20260518T065500 + END:VEVENT + END:VCALENDAR + """ + ) + updated_calendar = textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Wake up + DTSTART:20260519T080000 + DTEND:20260519T081500 + END:VEVENT + END:VCALENDAR + """ + ) + route = respx.get(CALENDER_URL).mock( + return_value=Response(status_code=200, text=original_calendar) + ) + await setup_integration(hass, config_entry) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.attributes.get("start_time") == "2026-05-18 06:40:00" + + # Advance clock to trigger the next update interval + route.return_value = Response(status_code=200, text=updated_calendar) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.attributes.get("start_time") == "2026-05-19 08:00:00" diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index d30314d06c32..2d014c9079c9 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -154,8 +154,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), @@ -213,8 +213,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), @@ -272,8 +272,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), @@ -331,8 +331,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), @@ -390,8 +390,8 @@ : 0, : list([ ]), - : 48.1234567, - : 11.1234567, + : 48.1234567, + : 11.1234567, : , : , }), diff --git a/tests/components/renault/snapshots/test_init.ambr b/tests/components/renault/snapshots/test_init.ambr index 7b898e593c3d..1e1f48792dd1 100644 --- a/tests/components/renault/snapshots/test_init.ambr +++ b/tests/components/renault/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': 'XJB1SU', 'name': 'REG-CAPTUR-FUEL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -36,8 +35,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -58,7 +57,6 @@ 'model_id': 'XJB1SU', 'name': 'REG-CAPTUR_PHEV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -69,8 +67,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -91,7 +89,6 @@ 'model_id': 'XCB1VE', 'name': 'REG-MEG-0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -102,8 +99,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -124,7 +121,6 @@ 'model_id': 'X071VE', 'name': 'REG-TWINGO-III', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -135,8 +131,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +153,6 @@ 'model_id': 'X101VE', 'name': 'REG-ZOE-40', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -168,8 +163,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -190,7 +185,6 @@ 'model_id': 'X102VE', 'name': 'REG-ZOE-50', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/renson/snapshots/test_init.ambr b/tests/components/renson/snapshots/test_init.ambr index 291d90b9ef9e..18468daea908 100644 --- a/tests/components/renson/snapshots/test_init.ambr +++ b/tests/components/renson/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Ventilation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'Firmware version 4.9.1', 'via_device_id': None, diff --git a/tests/components/reolink/__init__.py b/tests/components/reolink/__init__.py index 45bcb2fab8c8..406f6f980715 100644 --- a/tests/components/reolink/__init__.py +++ b/tests/components/reolink/__init__.py @@ -1 +1,25 @@ """Tests for the Reolink integration.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the Reolink integration for testing and enable all entities.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ): + if entry.disabled_by is not None: + entity_registry.async_update_entity(entry.entity_id, disabled_by=None) + + await hass.async_block_till_done() diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 25677b8591e5..00a296b13d24 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.api import Chime -from reolink_aio.enums import ConnectionEnum +from reolink_aio.enums import BatteryEnum, ConnectionEnum from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL @@ -135,8 +135,45 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.broken_cmds = ["GetManualRec"] host_mock.baichuan_cmds = ["GetPtzCurPos"] host_mock.renewtimer.return_value = 600 - host_mock.wifi_connection.return_value = False + host_mock.wifi_connection.return_value = True host_mock.wifi_signal.return_value = -45 + host_mock.ptz_pan_position.return_value = 1200 + host_mock.ptz_tilt_position.return_value = 300 + host_mock.battery_percentage.return_value = 85 + host_mock.battery_temperature.return_value = 25 + host_mock.battery_status.return_value = BatteryEnum.charging.value + host_mock.cpu_usage = 5 + host_mock.hdd_list = [0] + host_mock.hdd_type.return_value = "HDD" + host_mock.hdd_storage.return_value = 95 + host_mock.hdd_available.return_value = True + host_mock.ai_delay.return_value = 5 + host_mock.ai_sensitivity.return_value = 50 + host_mock.auto_track_disappear_time.return_value = 10 + host_mock.auto_track_limit_left.return_value = -1 + host_mock.auto_track_limit_right.return_value = -1 + host_mock.auto_track_stop_time.return_value = 10 + host_mock.daynight_threshold.return_value = 10 + host_mock.get_focus.return_value = 10 + host_mock.get_zoom.return_value = 20 + host_mock.image_brightness.return_value = 128 + host_mock.image_contrast.return_value = 128 + host_mock.image_hue.return_value = 128 + host_mock.image_saturation.return_value = 128 + host_mock.image_sharpness.return_value = 128 + host_mock.md_sensitivity.return_value = 30 + host_mock.pir_interval.return_value = 5 + host_mock.pir_sensitivity.return_value = 50 + host_mock.ptz_guard_time.return_value = 60 + host_mock.quick_reply_time.return_value = 10 + host_mock.volume.return_value = 80 + host_mock.volume_doorbell.return_value = 80 + host_mock.volume_speak.return_value = 80 + host_mock.alarm_volume = 90 + host_mock.message_volume = 70 + host_mock.whiteled_event_brightness.return_value = 100 + host_mock.whiteled_event_flash_time.return_value = 5 + host_mock.whiteled_event_on_time.return_value = 10 host_mock.whiteled_mode_list.return_value = [] host_mock.post_recording_time_list.return_value = [] host_mock.zoom_range.return_value = { @@ -195,6 +232,13 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.smart_ai_type_list.return_value = ["people"] host_mock.baichuan.smart_ai_index.return_value = 1 host_mock.baichuan.smart_ai_name.return_value = "zone1" + host_mock.baichuan.smart_ai_delay.return_value = 5 + host_mock.baichuan.smart_ai_sensitivity.return_value = 50 + host_mock.baichuan.audio_noise_reduction.return_value = 50 + host_mock.baichuan.cry_sensitivity.return_value = 3 + host_mock.baichuan.ir_brightness.return_value = 100 + host_mock.baichuan.pre_record_time.return_value = 10 + host_mock.baichuan.pre_record_battery_stop.return_value = 10 host_mock.whiteled_brightness.return_value = None def ai_detect_type(channel: int, object_type: str) -> str | None: diff --git a/tests/components/reolink/snapshots/test_binary_sensor.ambr b/tests/components/reolink/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..07aab58eb3a0 --- /dev/null +++ b/tests/components/reolink/snapshots/test_binary_sensor.ambr @@ -0,0 +1,752 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.test_reolink_cam_animal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_animal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Animal', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Animal', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'animal', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pet', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_animal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Animal', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_animal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_baby_crying-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_baby_crying', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Baby crying', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Baby crying', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cry', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_cry', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_baby_crying-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Baby crying', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_baby_crying', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_bicycle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_bicycle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bicycle', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bicycle', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'non-motor_vehicle', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_non-motor_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_bicycle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Bicycle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_bicycle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_crossline_zone1_person-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_crossline_zone1_person', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Crossline zone1 person', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Crossline zone1 person', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'crossline_person', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_crossline_person_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_crossline_zone1_person-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Crossline zone1 person', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_crossline_zone1_person', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_face-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_face', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Face', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Face', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'face', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_face', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_face-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Face', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_face', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_intrusion_zone1_person-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_intrusion_zone1_person', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Intrusion zone1 person', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intrusion zone1 person', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intrusion_person', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_intrusion_person_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_intrusion_zone1_person-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Intrusion zone1 person', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_intrusion_zone1_person', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_item_forgotten_zone1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_item_forgotten_zone1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Item forgotten zone1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Item forgotten zone1', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forgotten_item', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_forgotten_item_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_item_forgotten_zone1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Item forgotten zone1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_item_forgotten_zone1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_item_taken_zone1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_item_taken_zone1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Item taken zone1', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Item taken zone1', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taken_item', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_taken_item_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_item_taken_zone1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Item taken zone1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_item_taken_zone1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_linger_zone1_person-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_linger_zone1_person', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Linger zone1 person', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Linger zone1 person', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'linger_person', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_linger_person_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_linger_zone1_person-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Linger zone1 person', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_linger_zone1_person', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Motion', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'motion', + : 'test_reolink_cam Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_package-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_package', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Package', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Package', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'package', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_package', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_package-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Package', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_package', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_person-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_person', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Person', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Person', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'person', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_person', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_person-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Person', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_person', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_sleep_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_sleep_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sleep status', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sleep status', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sleep', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sleep', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_sleep_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Sleep status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_sleep_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vehicle', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Vehicle', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Vehicle', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_visitor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_visitor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Visitor', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Visitor', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'visitor', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_visitor', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_reolink_cam_visitor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Visitor', + }), + 'context': , + 'entity_id': 'binary_sensor.test_reolink_cam_visitor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_button.ambr b/tests/components/reolink/snapshots/test_button.ambr new file mode 100644 index 000000000000..4ce6df9b29b3 --- /dev/null +++ b/tests/components/reolink/snapshots/test_button.ambr @@ -0,0 +1,914 @@ +# serializer version: 1 +# name: test_all_entities[button.test_reolink_cam_guard_go_to-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_guard_go_to', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard go to', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guard go to', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_go_to', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_guard_go_to', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_guard_go_to-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Guard go to', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_guard_go_to', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_guard_set_current_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_guard_set_current_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard set current position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guard set current position', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_set', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_guard_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_guard_set_current_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Guard set current position', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_guard_set_current_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_pre_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_pre_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-siren', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-siren', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_siren', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_pre_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-siren', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_pre_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_calibrate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ calibrate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ calibrate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_calibrate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ calibrate', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_continuous_rotation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_continuous_rotation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ continuous rotation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ continuous rotation', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_auto', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_auto', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_continuous_rotation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ continuous rotation', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_continuous_rotation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ down', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_down', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ down', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ left', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ left', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_left', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ left', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_left_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ left down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ left down', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_left_down', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_left_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ left down', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_left_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_left_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ left up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ left up', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_left_up', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_left_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_left_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ left up', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_left_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ right', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ right', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_right', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ right', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right_down-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_right_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ right down', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ right down', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_right_down', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_right_down', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ right down', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_right_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_right_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ right up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ right up', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_right_up', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_right_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_right_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ right up', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_right_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ stop', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ stop', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_stop', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ stop', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_up-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ up', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ up', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_up', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_up', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ up', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_zoom_in-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_zoom_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ zoom in', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ zoom in', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_zoom_in', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_zoom_in', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_zoom_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ zoom in', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_zoom_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_zoom_out-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_zoom_out', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ zoom out', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ zoom out', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'ptz_zoom_out', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_zoom_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_ptz_zoom_out-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ zoom out', + : , + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_ptz_zoom_out', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_cam_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_cam_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'test_reolink_cam Restart', + }), + 'context': , + 'entity_id': 'button.test_reolink_cam_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.test_reolink_name_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_name_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.test_reolink_name_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'restart', + : 'test_reolink_name Restart', + }), + 'context': , + 'entity_id': 'button.test_reolink_name_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_camera.ambr b/tests/components/reolink/snapshots/test_camera.ambr new file mode 100644 index 000000000000..2bad0463301f --- /dev/null +++ b/tests/components/reolink/snapshots/test_camera.ambr @@ -0,0 +1,425 @@ +# serializer version: 1 +# name: test_all_entities[camera.test_reolink_cam_clear-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_reolink_cam_clear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clear', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'main', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_clear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_clear?token=1caab5c3b3', + : 'test_reolink_cam Clear', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_clear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_fluent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_reolink_cam_fluent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fluent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fluent', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'sub', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_fluent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_fluent?token=1caab5c3b3', + : 'test_reolink_cam Fluent', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_fluent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_snapshots_clear-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_reolink_cam_snapshots_clear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Snapshots clear', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snapshots clear', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'snapshots_main', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_snapshots', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_snapshots_clear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_snapshots_clear?token=1caab5c3b3', + : 'test_reolink_cam Snapshots clear', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_snapshots_clear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_snapshots_fluent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_reolink_cam_snapshots_fluent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Snapshots fluent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snapshots fluent', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'snapshots_sub', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_snapshots_sub', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_snapshots_fluent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_snapshots_fluent?token=1caab5c3b3', + : 'test_reolink_cam Snapshots fluent', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_snapshots_fluent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_clear-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_reolink_cam_telephoto_clear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Telephoto clear', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Telephoto clear', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'telephoto_main', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_autotrack_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_clear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_telephoto_clear?token=1caab5c3b3', + : 'test_reolink_cam Telephoto clear', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_telephoto_clear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_fluent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_reolink_cam_telephoto_fluent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Telephoto fluent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Telephoto fluent', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'telephoto_sub', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_autotrack_sub', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_fluent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_telephoto_fluent?token=1caab5c3b3', + : 'test_reolink_cam Telephoto fluent', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_telephoto_fluent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_snapshots_clear-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_reolink_cam_telephoto_snapshots_clear', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Telephoto snapshots clear', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Telephoto snapshots clear', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'telephoto_snapshots_main', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_autotrack_snapshots_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_snapshots_clear-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_telephoto_snapshots_clear?token=1caab5c3b3', + : 'test_reolink_cam Telephoto snapshots clear', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_telephoto_snapshots_clear', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_snapshots_fluent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_reolink_cam_telephoto_snapshots_fluent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Telephoto snapshots fluent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Telephoto snapshots fluent', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'telephoto_snapshots_sub', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_autotrack_snapshots_sub', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[camera.test_reolink_cam_telephoto_snapshots_fluent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/camera_proxy/camera.test_reolink_cam_telephoto_snapshots_fluent?token=1caab5c3b3', + : 'test_reolink_cam Telephoto snapshots fluent', + : , + }), + 'context': , + 'entity_id': 'camera.test_reolink_cam_telephoto_snapshots_fluent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 6bbaa50ffa5c..77628e28f682 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -113,8 +113,8 @@ 'null': 1, }), 'GetAudioCfg': dict({ - '0': 4, - 'null': 4, + '0': 5, + 'null': 5, }), 'GetAutoFocus': dict({ '0': 1, diff --git a/tests/components/reolink/snapshots/test_light.ambr b/tests/components/reolink/snapshots/test_light.ambr new file mode 100644 index 000000000000..18c2105dbb72 --- /dev/null +++ b/tests/components/reolink/snapshots/test_light.ambr @@ -0,0 +1,197 @@ +# serializer version: 1 +# name: test_all_entities[light.test_reolink_cam_floodlight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 6000, + : 3000, + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_reolink_cam_floodlight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.test_reolink_cam_floodlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : , + : 3000, + : 'test_reolink_cam Floodlight', + : tuple( + 27.825, + 56.895, + ), + : 6000, + : 3000, + : tuple( + 255, + 177, + 110, + ), + : list([ + , + ]), + : , + : tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.test_reolink_cam_floodlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[light.test_reolink_cam_status_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.test_reolink_cam_status_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status LED', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status LED', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status_led', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_status_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.test_reolink_cam_status_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : , + : 'test_reolink_cam Status LED', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.test_reolink_cam_status_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[light.test_reolink_name_status_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.test_reolink_name_status_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status LED', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Status LED', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status_led', + 'unique_id': 'ABC1234567D89EFG_hub_status_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.test_reolink_name_status_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : , + : 'test_reolink_name Status LED', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.test_reolink_name_status_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_number.ambr b/tests/components/reolink/snapshots/test_number.ambr new file mode 100644 index 000000000000..e6746d5a002a --- /dev/null +++ b/tests/components/reolink/snapshots/test_number.ambr @@ -0,0 +1,3104 @@ +# serializer version: 1 +# name: test_all_entities[number.test_reolink_cam_ai_animal_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_animal_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI animal delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI animal delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_animal_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_pet_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_animal_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI animal delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_animal_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_animal_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_animal_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI animal sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI animal sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_animal_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_pet_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_animal_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI animal sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_animal_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_bicycle_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_bicycle_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI bicycle delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI bicycle delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_non_motor_vehicle_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_non_motor_vehicle_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_bicycle_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI bicycle delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_bicycle_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_bicycle_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_bicycle_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI bicycle sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI bicycle sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_non_motor_vehicle_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_non_motor_vehicle_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_bicycle_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI bicycle sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_bicycle_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_crossline_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_crossline_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI crossline zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI crossline zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'crossline_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_crossline_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_crossline_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI crossline zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_crossline_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_face_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_face_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI face delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI face delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_face_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_face_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_face_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI face delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_face_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_face_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_face_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI face sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI face sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_face_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_face_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_face_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI face sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_face_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_intrusion_zone1_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_intrusion_zone1_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI intrusion zone1 delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI intrusion zone1 delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intrusion_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_intrusion_delay_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_intrusion_zone1_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI intrusion zone1 delay', + : 10, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_intrusion_zone1_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_intrusion_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_intrusion_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI intrusion zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI intrusion zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'intrusion_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_intrusion_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_intrusion_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI intrusion zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_intrusion_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_forgotten_zone1_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_item_forgotten_zone1_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI item forgotten zone1 delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI item forgotten zone1 delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forgotten_item_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_forgotten_item_delay_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_forgotten_zone1_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI item forgotten zone1 delay', + : 30, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_item_forgotten_zone1_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_forgotten_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_item_forgotten_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI item forgotten zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI item forgotten zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'forgotten_item_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_forgotten_item_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_forgotten_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI item forgotten zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_item_forgotten_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_taken_zone1_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_item_taken_zone1_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI item taken zone1 delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI item taken zone1 delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taken_item_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_taken_item_delay_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_taken_zone1_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI item taken zone1 delay', + : 30, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_item_taken_zone1_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_taken_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_item_taken_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI item taken zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI item taken zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'taken_item_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_taken_item_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_item_taken_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI item taken zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_item_taken_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_linger_zone1_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_linger_zone1_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI linger zone1 delay', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI linger zone1 delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'linger_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_linger_delay_1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_linger_zone1_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI linger zone1 delay', + : 10, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_linger_zone1_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_linger_zone1_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_linger_zone1_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI linger zone1 sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI linger zone1 sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'linger_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_linger_sensitivity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_linger_zone1_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI linger zone1 sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_linger_zone1_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_package_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_package_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI package delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI package delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_package_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_package_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_package_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI package delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_package_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_package_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_package_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI package sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI package sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_package_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_package_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_package_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI package sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_package_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_person_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_person_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI person delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI person delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_person_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_person_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_person_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI person delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_person_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_person_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_person_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI person sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI person sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_person_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_person_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_person_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI person sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_person_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_vehicle_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 8, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_vehicle_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI vehicle delay', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AI vehicle delay', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_vehicle_delay', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_vehicle_delay', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_vehicle_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam AI vehicle delay', + : 8, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_vehicle_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_vehicle_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_ai_vehicle_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'AI vehicle sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AI vehicle sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ai_vehicle_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ai_vehicle_sensititvity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_ai_vehicle_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam AI vehicle sensitivity', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_ai_vehicle_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_audio_noise_reduction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_audio_noise_reduction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Audio noise reduction', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Audio noise reduction', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'audio_noise_reduction', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_audio_noise_reduction', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_audio_noise_reduction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Audio noise reduction', + : 5, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_audio_noise_reduction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_quick_reply_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_quick_reply_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto quick reply time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto quick reply time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_quick_reply_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_quick_reply_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_quick_reply_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Auto quick reply time', + : 60, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_quick_reply_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_disappear_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_track_disappear_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track disappear time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto track disappear time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_disappear_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_disappear_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_disappear_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Auto track disappear time', + : 60, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_track_disappear_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_limit_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 2700, + : -1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_track_limit_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track limit left', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto track limit left', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_limit_left', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_limit_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_limit_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto track limit left', + : 2700, + : -1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_track_limit_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_limit_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 2700, + : -1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_track_limit_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track limit right', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto track limit right', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_limit_right', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_limit_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_limit_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto track limit right', + : 2700, + : -1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_track_limit_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_stop_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 60, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_auto_track_stop_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track stop time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto track stop time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_stop_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_stop_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_auto_track_stop_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Auto track stop time', + : 60, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_auto_track_stop_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_baby_cry_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 5, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_baby_cry_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Baby cry sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Baby cry sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cry_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_cry_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_baby_cry_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Baby cry sensitivity', + : 5, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_baby_cry_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_day_night_switch_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_day_night_switch_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Day night switch threshold', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Day night switch threshold', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'day_night_switch_threshold', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_day_night_switch_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_day_night_switch_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Day night switch threshold', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_day_night_switch_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume_doorbell', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_volume_doorbell', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Doorbell volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight event brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight event brightness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_event_brightness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_event_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Floodlight event brightness', + : 100, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_flash_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 30, + : 10, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_flash_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight event flash time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Floodlight event flash time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_event_flash_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_event_flash_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_flash_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Floodlight event flash time', + : 30, + : 10, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_flash_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_on_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 900, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_on_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight event on time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Floodlight event on time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_event_on_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_event_on_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_event_on_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Floodlight event on time', + : 900, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_floodlight_event_on_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_turn_on_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_floodlight_turn_on_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight turn on brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight turn on brightness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_brightness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_floodlight_turn_on_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Floodlight turn on brightness', + : 100, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_floodlight_turn_on_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_focus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_reolink_cam_focus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Focus', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Focus', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'focus', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_focus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_focus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Focus', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_focus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_guard_return_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 300, + : 10, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_guard_return_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard return time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Guard return time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_return_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_guard_return_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_guard_return_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam Guard return time', + : 300, + : 10, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_guard_return_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image brightness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_brightness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image brightness', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_contrast-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_contrast', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image contrast', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image contrast', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_contrast', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_contrast', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_contrast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image contrast', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_contrast', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_hue-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_hue', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image hue', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image hue', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_hue', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_hue', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_hue-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image hue', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_hue', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_saturation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_saturation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image saturation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image saturation', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_saturation', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_saturation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_saturation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image saturation', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_saturation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_sharpness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_image_sharpness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image sharpness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image sharpness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image_sharpness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_image_sharpness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_image_sharpness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image sharpness', + : 255, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_image_sharpness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_infrared_light_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_infrared_light_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Infrared light brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Infrared light brightness', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ir_brightness', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ir_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_infrared_light_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Infrared light brightness', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_infrared_light_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_motion_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 50, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_motion_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Motion sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'motion_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_motion_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_motion_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Motion sensitivity', + : 50, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_motion_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pir_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 120, + : 5, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_pir_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR interval', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PIR interval', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_interval', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pir_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pir_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'test_reolink_cam PIR interval', + : 120, + : 5, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_pir_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pir_sensitivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_pir_sensitivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR sensitivity', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PIR sensitivity', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_sensitivity', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pir_sensitivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pir_sensitivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PIR sensitivity', + : 100, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_pir_sensitivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pre_recording_stop_battery_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 80, + : 10, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_pre_recording_stop_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording stop battery level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording stop battery level', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record_battery_stop', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_record_battery_stop', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pre_recording_stop_battery_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-recording stop battery level', + : 80, + : 10, + : , + : 1, + : '%', + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_pre_recording_stop_battery_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pre_recording_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10, + : 2, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_pre_recording_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_record_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_reolink_cam_pre_recording_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-recording time', + : 10, + : 2, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_pre_recording_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_speak_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_speak_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Speak volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Speak volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume_speak', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_volume_speak', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_speak_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Speak volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_speak_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_cam_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_all_entities[number.test_reolink_cam_zoom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_reolink_cam_zoom', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zoom', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zoom', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'zoom', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_zoom', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_cam_zoom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Zoom', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_cam_zoom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[number.test_reolink_name_alarm_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_name_alarm_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Alarm volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_volume', + 'unique_id': 'ABC1234567D89EFG_alarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_name_alarm_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Alarm volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_name_alarm_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_all_entities[number.test_reolink_name_message_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_reolink_name_message_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Message volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Message volume', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'message_volume', + 'unique_id': 'ABC1234567D89EFG_message_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.test_reolink_name_message_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Message volume', + : 100, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.test_reolink_name_message_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_select.ambr b/tests/components/reolink/snapshots/test_select.ambr new file mode 100644 index 000000000000..fb91a9be89ae --- /dev/null +++ b/tests/components/reolink/snapshots/test_select.ambr @@ -0,0 +1,1383 @@ +# serializer version: 1 +# name: test_all_entities[select.test_reolink_cam_auto_quick_reply_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_auto_quick_reply_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto quick reply message', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto quick reply message', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_quick_reply_message', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_quick_reply_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_auto_quick_reply_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto quick reply message', + : list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_auto_quick_reply_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_auto_track_method-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'digital', + 'digitalfirst', + 'pantiltfirst', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_auto_track_method', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto track method', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto track method', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_track_method', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_track_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_auto_track_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto track method', + : list([ + 'digital', + 'digitalfirst', + 'pantiltfirst', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_auto_track_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'digitalfirst', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_binning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'on', + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_binning_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Binning mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Binning mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'binning_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_binning_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_binning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Binning mode', + : list([ + 'off', + 'on', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_binning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_bit_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_clear_bit_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clear bit rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear bit rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_bit_rate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_main_bit_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_bit_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Clear bit rate', + : list([ + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_clear_bit_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_encoding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'h264', + 'h265', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_clear_encoding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clear encoding', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear encoding', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_encoding', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_main_encoding', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_encoding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Clear encoding', + : list([ + 'h264', + 'h265', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_clear_encoding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_frame_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_clear_frame_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Clear frame rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear frame rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'main_frame_rate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_main_frame_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_clear_frame_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Clear frame rate', + : list([ + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_clear_frame_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_day_night_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'color', + 'blackwhite', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_day_night_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Day night mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Day night mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'day_night_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_day_night_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_day_night_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Day night mode', + : list([ + 'auto', + 'color', + 'blackwhite', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_day_night_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'blackwhite', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_doorbell_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'stayoff', + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_doorbell_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell LED', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell LED', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_led', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_status_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_doorbell_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Doorbell LED', + : list([ + 'stayoff', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_doorbell_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_floodlight_event_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'on', + 'flash', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_floodlight_event_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight event mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight event mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_event_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_event_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_floodlight_event_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Floodlight event mode', + : list([ + 'off', + 'on', + 'flash', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_floodlight_event_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_floodlight_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_floodlight_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Floodlight mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Floodlight mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'floodlight_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_floodlight_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_floodlight_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Floodlight mode', + : list([ + 'off', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_floodlight_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_bit_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_fluent_bit_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fluent bit rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fluent bit rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sub_bit_rate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub_bit_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_bit_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Fluent bit rate', + : list([ + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_fluent_bit_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_encoding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'h264', + 'h265', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_fluent_encoding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fluent encoding', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fluent encoding', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sub_encoding', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub_encoding', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_encoding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Fluent encoding', + : list([ + 'h264', + 'h265', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_fluent_encoding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_frame_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_fluent_frame_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fluent frame rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fluent frame rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sub_frame_rate', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub_frame_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_fluent_frame_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Fluent frame rate', + : list([ + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_fluent_frame_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hdr-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'on', + 'auto', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_hdr', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HDR', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HDR', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hdr', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_hdr', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hdr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam HDR', + : list([ + 'off', + 'on', + 'auto', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_hdr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hub_alarm_ringtone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'alarm', + 'citybird', + 'originaltune', + 'pianokey', + 'loop', + 'attraction', + 'hophop', + 'goodday', + 'operetta', + 'moonlight', + 'waybackhome', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_hub_alarm_ringtone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub alarm ringtone', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub alarm ringtone', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_alarm_ringtone', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_hub_alarm_ringtone', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hub_alarm_ringtone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Hub alarm ringtone', + : list([ + 'alarm', + 'citybird', + 'originaltune', + 'pianokey', + 'loop', + 'attraction', + 'hophop', + 'goodday', + 'operetta', + 'moonlight', + 'waybackhome', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_hub_alarm_ringtone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'originaltune', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hub_visitor_ringtone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'alarm', + 'citybird', + 'originaltune', + 'pianokey', + 'loop', + 'attraction', + 'hophop', + 'goodday', + 'operetta', + 'moonlight', + 'waybackhome', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_hub_visitor_ringtone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub visitor ringtone', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub visitor ringtone', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_visitor_ringtone', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_hub_visitor_ringtone', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_hub_visitor_ringtone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Hub visitor ringtone', + : list([ + 'alarm', + 'citybird', + 'originaltune', + 'pianokey', + 'loop', + 'attraction', + 'hophop', + 'goodday', + 'operetta', + 'moonlight', + 'waybackhome', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_hub_visitor_ringtone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'originaltune', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_image_exposure_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'lownoise', + 'antismearing', + 'manual', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_image_exposure_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Image exposure mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image exposure mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'exposure', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_exposure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_image_exposure_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Image exposure mode', + : list([ + 'auto', + 'lownoise', + 'antismearing', + 'manual', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_image_exposure_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_play_quick_reply_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_reolink_cam_play_quick_reply_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Play quick reply message', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Play quick reply message', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'play_quick_reply_message', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_play_quick_reply_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_play_quick_reply_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Play quick reply message', + : list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_play_quick_reply_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_post_recording_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_post_recording_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Post-recording time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Post-recording time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'post_rec_time', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_post_rec_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_post_recording_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Post-recording time', + : list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_post_recording_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_pre_recording_frame_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + '1', + '2', + '5', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_cam_pre_recording_frame_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording frame rate', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording frame rate', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record_fps', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_record_fps', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[select.test_reolink_cam_pre_recording_frame_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-recording frame rate', + : list([ + '1', + '2', + '5', + ]), + : , + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_pre_recording_frame_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_cam_ptz_preset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_reolink_cam_ptz_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ preset', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ preset', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_preset', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_cam_ptz_preset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ preset', + : list([ + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_cam_ptz_preset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[select.test_reolink_name_recording_packing_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + '30 Minutes', + '60 Minutes', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_name_recording_packing_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Recording packing time', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Recording packing time', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'packing_time', + 'unique_id': 'ABC1234567D89EFG_packing_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_name_recording_packing_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Recording packing time', + : list([ + '30 Minutes', + '60 Minutes', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_name_recording_packing_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60 Minutes', + }) +# --- +# name: test_all_entities[select.test_reolink_name_scene_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'home', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_reolink_name_scene_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Scene mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scene mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'scene_mode', + 'unique_id': 'ABC1234567D89EFG_scene_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.test_reolink_name_scene_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Scene mode', + : list([ + 'off', + 'home', + ]), + }), + 'context': , + 'entity_id': 'select.test_reolink_name_scene_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_sensor.ambr b/tests/components/reolink/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..d98e409ba9c3 --- /dev/null +++ b/tests/components/reolink/snapshots/test_sensor.ambr @@ -0,0 +1,762 @@ +# serializer version: 1 +# name: test_all_entities[sensor.test_reolink_cam_animal_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'dog', + 'cat', + 'squirrel', + 'fox', + 'bear', + 'cow', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_reolink_cam_animal_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Animal type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Animal type', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'animal_type', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_animal_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_animal_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Animal type', + : list([ + 'dog', + 'cat', + 'squirrel', + 'fox', + 'bear', + 'cow', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_animal_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'dog', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery', + : 'test_reolink_cam Battery', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'discharging', + 'charging', + 'chargecomplete', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_battery_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery state', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_battery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Battery state', + : list([ + 'discharging', + 'charging', + 'chargecomplete', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'test_reolink_cam Battery temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_day_night_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'day', + 'night', + 'led_day', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_day_night_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Day night state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Day night state', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'day_night_state', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_day_night_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_day_night_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Day night state', + : list([ + 'day', + 'night', + 'led_day', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_day_night_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'day', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_person_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'man', + 'woman', + 'child', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_reolink_cam_person_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Person type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Person type', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'person_type', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_person_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_person_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Person type', + : list([ + 'man', + 'woman', + 'child', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_person_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_ptz_pan_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_ptz_pan_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ pan position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ pan position', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_pan_position', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_pan_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_ptz_pan_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ pan position', + : , + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_ptz_pan_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_ptz_tilt_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_ptz_tilt_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ tilt position', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ tilt position', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_tilt_position', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_tilt_position', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_ptz_tilt_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ tilt position', + : , + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_ptz_tilt_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '300', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_vehicle_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'sedan', + 'suv', + 'pickup_truck', + 'bus', + 'van', + 'truck', + 'motorcycle', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_reolink_cam_vehicle_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vehicle type', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vehicle type', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_type', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_vehicle_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_vehicle_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'test_reolink_cam Vehicle type', + : list([ + 'sedan', + 'suv', + 'pickup_truck', + 'bus', + 'van', + 'truck', + 'motorcycle', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_vehicle_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'motorcycle', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_wi_fi_signal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_cam_wi_fi_signal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_wifi_signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.test_reolink_cam_wi_fi_signal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'test_reolink_cam Wi-Fi signal', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_cam_wi_fi_signal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-45', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_name_cpu_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'CPU usage', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CPU usage', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cpu_usage', + 'unique_id': 'ABC1234567D89EFG_cpu_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_cpu_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name CPU usage', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_name_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_hdd_0_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_name_hdd_0_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HDD 0 storage', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HDD 0 storage', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hdd_storage', + 'unique_id': 'ABC1234567D89EFG_0_storage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_hdd_0_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name HDD 0 storage', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_name_hdd_0_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '95', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_wi_fi_signal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_reolink_name_wi_fi_signal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal', + 'unique_id': 'ABC1234567D89EFG_wifi_signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.test_reolink_name_wi_fi_signal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'signal_strength', + : 'test_reolink_name Wi-Fi signal', + : , + : 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_reolink_name_wi_fi_signal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-45', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_siren.ambr b/tests/components/reolink/snapshots/test_siren.ambr new file mode 100644 index 000000000000..d47d1b91c310 --- /dev/null +++ b/tests/components/reolink/snapshots/test_siren.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_all_entities[siren.test_reolink_cam_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test_reolink_cam_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Siren', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'siren', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[siren.test_reolink_cam_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Siren', + : , + }), + 'context': , + 'entity_id': 'siren.test_reolink_cam_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[siren.test_reolink_name_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test_reolink_name_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Siren', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'siren', + 'unique_id': 'ABC1234567D89EFG_siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[siren.test_reolink_name_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Siren', + : , + }), + 'context': , + 'entity_id': 'siren.test_reolink_name_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_switch.ambr b/tests/components/reolink/snapshots/test_switch.ambr new file mode 100644 index 000000000000..d74c73e86775 --- /dev/null +++ b/tests/components/reolink/snapshots/test_switch.ambr @@ -0,0 +1,1301 @@ +# serializer version: 1 +# name: test_all_entities[switch.test_reolink_cam_auto_focus-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_auto_focus', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto focus', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto focus', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_focus', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_focus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_auto_focus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto focus', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_auto_focus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_auto_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_auto_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Auto tracking', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto tracking', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'auto_tracking', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_auto_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_auto_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Auto tracking', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_auto_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_doorbell_button_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_doorbell_button_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell button sound', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell button sound', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_button_sound', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_doorbell_button_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_doorbell_button_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Doorbell button sound', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_doorbell_button_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_email_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_email_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Email on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Email on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'email', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_email', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_email_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Email on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_email_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_ftp_upload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_ftp_upload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'FTP upload', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FTP upload', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ftp_upload', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ftp_upload', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_ftp_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam FTP upload', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_ftp_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_guard_return-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_guard_return', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Guard return', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guard return', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'guard_return', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_gaurd_return', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_guard_return-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Guard return', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_guard_return', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_hardwired_chime_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_hardwired_chime_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hardwired chime enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hardwired chime enabled', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hardwired_chime_enabled', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_hardwired_chime_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_hardwired_chime_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Hardwired chime enabled', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_hardwired_chime_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_hub_ringtone_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_hub_ringtone_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub ringtone on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub ringtone on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_ringtone_on_event', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_buzzer', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_hub_ringtone_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Hub ringtone on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_hub_ringtone_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_infrared_lights_in_night_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_infrared_lights_in_night_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Infrared lights in night mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Infrared lights in night mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ir_lights', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ir_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_infrared_lights_in_night_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Infrared lights in night mode', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_infrared_lights_in_night_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_manual_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_manual_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Manual record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Manual record', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'manual_record', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_manual_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_manual_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Manual record', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_manual_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pir_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_pir_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PIR enabled', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_enabled', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pir_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pir_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PIR enabled', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_pir_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pir_reduce_false_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_pir_reduce_false_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PIR reduce false alarm', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PIR reduce false alarm', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pir_reduce_alarm', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pir_reduce_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pir_reduce_false_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PIR reduce false alarm', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_pir_reduce_false_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pre_recording-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_pre_recording', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-recording', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-recording', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_record', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pre_recording-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-recording', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_pre_recording', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pre_siren_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_pre_siren_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pre-siren on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre-siren on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pre_siren_on_event', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_pre_siren_on_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_pre_siren_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Pre-siren on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_pre_siren_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_privacy_mask-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_privacy_mask', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Privacy mask', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mask', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mask', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_privacy_mask', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_privacy_mask-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Privacy mask', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_privacy_mask', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_privacy_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_privacy_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Privacy mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Privacy mode', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'privacy_mode', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_privacy_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_privacy_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Privacy mode', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_privacy_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_ptz_patrol-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_ptz_patrol', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PTZ patrol', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PTZ patrol', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ptz_patrol', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_patrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_ptz_patrol-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam PTZ patrol', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_ptz_patrol', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_push_notifications-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_push_notifications', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Push notifications', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Push notifications', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'push_notifications', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_push_notifications', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_push_notifications-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Push notifications', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_push_notifications', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Record', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_record_audio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_record_audio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record audio', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record audio', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record_audio', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_record_audio', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_record_audio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Record audio', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_record_audio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_siren_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_cam_siren_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Siren on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_on_event', + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_siren_on_event', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_cam_siren_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_cam Siren on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_cam_siren_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_email_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_email_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Email on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Email on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'email', + 'unique_id': 'ABC1234567D89EFG_email', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_email_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Email on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_email_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_ftp_upload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_ftp_upload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'FTP upload', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FTP upload', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ftp_upload', + 'unique_id': 'ABC1234567D89EFG_ftp_upload', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_ftp_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name FTP upload', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_ftp_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_hub_ringtone_on_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_hub_ringtone_on_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hub ringtone on event', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hub ringtone on event', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hub_ringtone_on_event', + 'unique_id': 'ABC1234567D89EFG_buzzer', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_hub_ringtone_on_event-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Hub ringtone on event', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_hub_ringtone_on_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_push_notifications-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_push_notifications', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Push notifications', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Push notifications', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'push_notifications', + 'unique_id': 'ABC1234567D89EFG_push_notifications', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_push_notifications-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Push notifications', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_push_notifications', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.test_reolink_name_record-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_reolink_name_record', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Record', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Record', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'record', + 'unique_id': 'ABC1234567D89EFG_record', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.test_reolink_name_record-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'test_reolink_name Record', + }), + 'context': , + 'entity_id': 'switch.test_reolink_name_record', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/reolink/snapshots/test_update.ambr b/tests/components/reolink/snapshots/test_update.ambr new file mode 100644 index 000000000000..ac539af10e2d --- /dev/null +++ b/tests/components/reolink/snapshots/test_update.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_all_entities[update.test_reolink_cam_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_cam_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[update.test_reolink_cam_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/reolink/icon.png', + : 'test_reolink_cam Firmware', + : False, + : 'v1.1.0.0.0.0000', + : 'v1.1.0.0.0.0000', + : None, + : 'https://reolink.com/download-center/', + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.test_reolink_cam_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[update.test_reolink_name_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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_reolink_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'reolink', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABC1234567D89EFG_firmware', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[update.test_reolink_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/reolink/icon.png', + : 'test_reolink_name Firmware', + : False, + : 'v1.1.0.0.0.0000', + : 'v1.1.0.0.0.0000', + : None, + : 'https://reolink.com/download-center/', + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.test_reolink_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 1ca6442662f8..3bafa15d0680 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.reolink.const import DOMAIN from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN @@ -14,6 +15,7 @@ from homeassistant.const import 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 .conftest import ( TEST_CAM_NAME, TEST_DUO_MODEL, @@ -23,10 +25,26 @@ from .conftest import ( TEST_UID_CAM, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + async def test_motion_sensor( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index 8db5dc8b7c66..fa3d832c8fb1 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest from reolink_aio.exceptions import ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.reolink.const import DOMAIN @@ -12,10 +13,28 @@ from homeassistant.config_entries import ConfigEntryState 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 .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_button( diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 533729187df3..efcfd0c64c6e 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest from reolink_aio.exceptions import ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.camera import ( CameraState, @@ -14,13 +15,35 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_DUO_MODEL -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with ( + patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.CAMERA], + ), + # keep the camera access tokens deterministic for the snapshot + patch("random.SystemRandom.getrandbits", return_value=123123123123), + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + async def test_camera( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index 0896c1df87d5..0b181ca91ce6 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call, patch import pytest from reolink_aio.exceptions import InvalidParameterError, ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,10 +21,28 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.LIGHT], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 3e49a5dd4a78..7afcfab6f2dc 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest from reolink_aio.api import Chime from reolink_aio.exceptions import InvalidParameterError, ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.number import ( ATTR_VALUE, @@ -15,10 +16,28 @@ from homeassistant.config_entries import ConfigEntryState 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 .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.NUMBER], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_number( diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 36c19f1c3f73..920520aeefd3 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -6,6 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.api import Chime from reolink_aio.exceptions import InvalidParameterError, ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN @@ -20,9 +21,26 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SELECT], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_floodlight_mode_select( diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index 9049d5906fca..86ae86fed09c 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -3,14 +3,33 @@ from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion 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 . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -24,6 +43,7 @@ async def test_sensors( reolink_host.wifi_connection.return_value = True reolink_host.wifi_signal.return_value = -55 reolink_host.hdd_list = [0] + reolink_host.hdd_type.return_value = "SD" reolink_host.hdd_storage.return_value = 95 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index c3ed7708f526..e01b8c72d9e5 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.exceptions import InvalidParameterError, ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import ( ATTR_DURATION, @@ -22,10 +23,28 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SIREN], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_siren( diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index cd0c436bdea5..c55bdd688491 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -6,6 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.api import Chime from reolink_aio.exceptions import ReolinkError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -21,10 +22,28 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_switch( diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index f5c3420582b4..e41e51767a42 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -8,6 +8,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.exceptions import ApiError, ReolinkError from reolink_aio.software_version import NewSoftwareVersion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.reolink.update import POLL_AFTER_INSTALL, POLL_PROGRESS from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL @@ -15,13 +16,32 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow +from . import setup_integration from .conftest import TEST_CAM_NAME, TEST_NVR_NAME -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.typing import WebSocketGenerator + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "reolink_host") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.reolink.PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, config_entry) + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + TEST_DOWNLOAD_URL = "https://reolink.com/test" TEST_RELEASE_NOTES = "bugfix 1, bugfix 2" diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 2220755c8df8..8d3c1ca34993 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -126,10 +126,10 @@ async def test_rest_command_auth( assert len(aioclient_mock.mock_calls) == 1 +@pytest.mark.usefixtures("aioclient_mock") 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 = { @@ -144,11 +144,9 @@ async def test_rest_command_digest_auth( 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): + async def async_iter_chunks(self, chunk_size: int): yield b"success" mock_response = type( @@ -166,14 +164,15 @@ async def test_rest_command_digest_auth( )() mock_get.return_value.__aenter__.return_value = mock_response + await hass.services.async_call(DOMAIN, "digest_auth_test", {}, blocking=True) 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) + assert len(mock_get.call_args_list) == 2 + first_middleware = mock_get.call_args_list[0].kwargs["middlewares"][0] + second_middleware = mock_get.call_args_list[1].kwargs["middlewares"][0] + assert isinstance(first_middleware, aiohttp.DigestAuthMiddleware) + assert isinstance(second_middleware, aiohttp.DigestAuthMiddleware) + assert first_middleware is not second_middleware async def test_rest_command_form_data( diff --git a/tests/components/ring/snapshots/test_init.ambr b/tests/components/ring/snapshots/test_init.ambr index 8bdcd59d7c0e..50e4aaa793ff 100644 --- a/tests/components/ring/snapshots/test_init.ambr +++ b/tests/components/ring/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Front Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 061144a6a47c..43e29665ac29 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -168,6 +168,46 @@ def create_b01_q7_trait() -> Mock: return b01_trait +def attach_update_listeners(trait: Mock) -> Callable[[], None]: + """Give a mock Q10 trait working add_update_listener support. + + Returns a callback that notifies all currently registered listeners, + mimicking a push update from the device. + """ + listeners: list[Callable[[], None]] = [] + + def add_update_listener(cb: Callable[[], None]) -> Callable[[], None]: + listeners.append(cb) + return lambda: listeners.remove(cb) + + trait.add_update_listener = Mock(side_effect=add_update_listener) + + def notify() -> None: + for cb in listeners: + cb() + + return notify + + +def make_q10_switch_trait( + on_change: Callable[[bool], None] | None = None, +) -> AsyncMock: + """Create a mock Q10 switch-like trait (is_on / enable / disable).""" + trait = AsyncMock() + trait.is_on = True + notify = attach_update_listeners(trait) + + def _set(value: bool) -> None: + trait.is_on = value + if on_change is not None: + on_change(value) + notify() + + trait.enable = AsyncMock(side_effect=lambda: _set(True)) + trait.disable = AsyncMock(side_effect=lambda: _set(False)) + return trait + + def create_b01_q10_trait() -> Mock: """Create B01 Q10 trait for Q10 devices. @@ -188,32 +228,27 @@ def create_b01_q10_trait() -> Mock: q10_trait.vacuum = AsyncMock() q10_trait.command = AsyncMock() q10_trait.refresh = AsyncMock() - q10_trait.do_not_disturb = AsyncMock() - q10_trait.do_not_disturb.is_on = True - _dnd_listeners: list[Callable[[], None]] = [] + q10_trait.do_not_disturb = make_q10_switch_trait( + on_change=lambda value: setattr(q10_trait.status, "not_disturb", value) + ) + q10_trait.child_lock = make_q10_switch_trait() + q10_trait.dust_collection = make_q10_switch_trait() + # The button light trait is write-only (enable/disable, no is_on) + q10_trait.button_light = Mock(spec=["enable", "disable"]) + q10_trait.button_light.enable = AsyncMock() + q10_trait.button_light.disable = AsyncMock() - def _dnd_add_update_listener(cb: Callable[[], None]) -> Callable[[], None]: - _dnd_listeners.append(cb) - return lambda: _dnd_listeners.remove(cb) + q10_trait.volume = AsyncMock() + q10_trait.volume.volume = 50 + volume_notify = attach_update_listeners(q10_trait.volume) - q10_trait.do_not_disturb.add_update_listener = Mock( - side_effect=_dnd_add_update_listener - ) - q10_trait.do_not_disturb.enable = AsyncMock( - side_effect=lambda: ( - setattr(q10_trait.do_not_disturb, "is_on", True), - setattr(q10_trait.status, "not_disturb", True), - [cb() for cb in _dnd_listeners], - ) - ) - q10_trait.do_not_disturb.disable = AsyncMock( - side_effect=lambda: ( - setattr(q10_trait.do_not_disturb, "is_on", False), - setattr(q10_trait.status, "not_disturb", False), - [cb() for cb in _dnd_listeners], - ) - ) + async def _set_volume(volume: int) -> None: + q10_trait.volume.volume = volume + volume_notify() + + q10_trait.volume.set_volume = AsyncMock(side_effect=_set_volume) q10_trait.map = Mock() + q10_trait.map.image_content = b"\x89PNG-q10" q10_trait.map.rooms = [ Q10Room(id=9, raw_name="rr_bedroom", pixel_value=36, pixel_count=100), Q10Room(id=10, raw_name="rr_living_room", pixel_value=40, pixel_count=200), diff --git a/tests/components/roborock/snapshots/test_switch.ambr b/tests/components/roborock/snapshots/test_switch.ambr index 70eda195a840..addb6fdde78a 100644 --- a/tests/components/roborock/snapshots/test_switch.ambr +++ b/tests/components/roborock/snapshots/test_switch.ambr @@ -1,4 +1,54 @@ # serializer version: 1 +# name: test_switches[switch.roborock_q10_s5_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.roborock_q10_s5_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'child_lock_q10_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_q10_s5_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Roborock Q10 S5+ Child lock', + }), + 'context': , + 'entity_id': 'switch.roborock_q10_s5_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch.roborock_q10_s5_do_not_disturb-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -49,6 +99,107 @@ 'state': 'on', }) # --- +# name: test_switches[switch.roborock_q10_s5_dust_collection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.roborock_q10_s5_dust_collection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Dust collection', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dust collection', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dust_collection', + 'unique_id': 'dust_collection_q10_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_q10_s5_dust_collection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Roborock Q10 S5+ Dust collection', + }), + 'context': , + 'entity_id': 'switch.roborock_q10_s5_dust_collection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.roborock_q10_s5_indicator_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.roborock_q10_s5_indicator_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Indicator light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indicator light', + 'platform': 'roborock', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button_light', + 'unique_id': 'button_light_q10_duid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.roborock_q10_s5_indicator_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'Roborock Q10 S5+ Indicator light', + }), + 'context': , + 'entity_id': 'switch.roborock_q10_s5_indicator_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_switches[switch.roborock_s7_2_do_not_disturb-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index d1c2ba039b51..e2e5f0c28f45 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -1,12 +1,18 @@ """Test Roborock Binary Sensor.""" +import copy +from typing import Any + import pytest +from roborock.exceptions import RoborockException from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import FakeDevice + from tests.common import MockConfigEntry, snapshot_platform @@ -24,3 +30,95 @@ async def test_binary_sensors( ) -> None: """Test binary sensors and check test values are correctly set.""" await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) + + +def setup_coordinator_side_effect( + fake_devices: list[FakeDevice], side_effect: Any +) -> None: + """Set the query/refresh side effect on all fake devices to simulate failure or delay.""" + for device in fake_devices: + if device.v1_properties is not None: + device.v1_properties.status.refresh.side_effect = side_effect + if device.dyad is not None: + device.dyad.query_values.side_effect = side_effect + if device.zeo is not None: + device.zeo.query_values.side_effect = side_effect + if device.b01_q10_properties is not None: + device.b01_q10_properties.refresh.side_effect = side_effect + if device.b01_q7_properties is not None: + device.b01_q7_properties.query_values.side_effect = side_effect + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (RoborockException("Simulated failure"), STATE_UNAVAILABLE), + ], +) +async def test_binary_sensors_coordinator_state( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], + side_effect: Any, + expected_state: str, +) -> None: + """Test binary sensors state based on coordinator update success or delay.""" + setup_coordinator_side_effect(fake_devices, side_effect) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + # V1 binary sensors + state = hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached") + assert state is not None + assert state.state == expected_state + + # A01 (Dyad/Zeo) binary sensors + state = hass.states.get("binary_sensor.zeo_one_detergent") + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize("platforms", [[Platform.BINARY_SENSOR]]) +async def test_zeo_request_protocols_filtered_by_schema( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], +) -> None: + """Test that Zeo request protocols are filtered by the device's supported schema IDs, ensuring correct entities are created.""" + # Find the first Zeo device + zeo_device_1 = next( + (device for device in fake_devices if device.zeo is not None), + None, + ) + assert zeo_device_1 is not None + + # Create a second Zeo device without softener in its schema + zeo_device_2 = copy.deepcopy(zeo_device_1) + zeo_device_2.device_info.duid = "zeo_duid_2" + zeo_device_2._duid = "zeo_duid_2" + zeo_device_2.device_info.name = "Zeo Two" + zeo_device_2._name = "Zeo Two" + zeo_device_2.device_info.sn = "zeo_sn_2" + + # Exclude softener parameters: 214 (SOFTENER_TYPE) and 227 (SOFTENER_EMPTY) + zeo_device_2.product.schema = [ + schema + for schema in zeo_device_2.product.schema + if schema.id not in ("214", "227") + ] + + # Add the second device to the list of fake devices + fake_devices.append(zeo_device_2) + + # Now set up the integration + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + # Verify that the first Zeo device has both detergent and softener entities + assert hass.states.get("binary_sensor.zeo_one_detergent") is not None + assert hass.states.get("binary_sensor.zeo_one_softener") is not None + + # Verify that the second Zeo device has detergent entities but NOT softener entities + assert hass.states.get("binary_sensor.zeo_two_detergent") is not None + assert hass.states.get("binary_sensor.zeo_two_softener") is None diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 7ddd50de97ab..6168c04d7b20 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -6,6 +6,7 @@ from http import HTTPStatus import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from roborock import MultiMapsList, RoborockException from roborock.data import RoborockStateCode @@ -45,7 +46,7 @@ async def test_floorplan_image( fake_devices: list[FakeDevice], ) -> None: """Test floor plan map image is correctly set up.""" - assert len(hass.states.async_all("image")) == 4 + assert len(hass.states.async_all("image")) == 5 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None # Load the image on demand @@ -131,7 +132,7 @@ async def test_map_status_change( fake_vacuum: FakeDevice, ) -> None: """Test floor plan map image is correctly updated on status change.""" - assert len(hass.states.async_all("image")) == 4 + assert len(hass.states.async_all("image")) == 5 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None client = await hass_client() @@ -181,6 +182,7 @@ async def test_map_status_change( "image.roborock_s7_2_upstairs", "image.roborock_s7_maxv_downstairs", "image.roborock_s7_maxv_upstairs", + "image.roborock_q10_s5_map", }, ), ( @@ -191,6 +193,7 @@ async def test_map_status_change( # Expect default names based on map flags "image.roborock_s7_maxv_map_0", "image.roborock_s7_maxv_map_1", + "image.roborock_q10_s5_map", }, ), ], @@ -222,3 +225,52 @@ async def test_image_entity_naming( assert { state.entity_id for state in hass.states.async_all("image") } == expected_entity_ids + + +async def test_q10_map_image( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + fake_q10_vacuum: FakeDevice, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the push-driven Q10 map image.""" + entity_id = "image.roborock_q10_s5_map" + assert hass.states.get(entity_id) is not None + + # The map pushed before startup is served + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{entity_id}") + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"\x89PNG-q10" + + assert fake_q10_vacuum.b01_q10_properties is not None + map_trait = fake_q10_vacuum.b01_q10_properties.map + + def push_update() -> None: + for call in map_trait.add_update_listener.call_args_list: + call.args[0]() + + # A push that does not change the map content must not update the entity + state = hass.states.get(entity_id) + assert state is not None + last_updated = state.state + freezer.tick(timedelta(seconds=30)) + push_update() + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state is not None + assert state.state == last_updated + + # The device pushes an updated map + freezer.tick(timedelta(seconds=30)) + map_trait.image_content = b"\x89PNG-q10-new" + push_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state != last_updated + resp = await client.get(f"/api/image_proxy/{entity_id}") + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"\x89PNG-q10-new" diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index c5ce70d5b071..8a269a3ef040 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -50,6 +50,74 @@ async def test_update_sound_volume( assert state.state == "3.0" +async def test_q10_update_sound_volume( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test changing the volume of a Q10 device.""" + entity_id = "number.roborock_q10_s5_volume" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "50.0" + + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 30.0}, + blocking=True, + target={"entity_id": entity_id}, + ) + + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.volume.set_volume.assert_awaited_once_with(30) + + # The trait listener pushes the new value into the entity state + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "30.0" + + +async def test_q10_volume_unknown_value( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test the Q10 entity reports unknown when the trait value is None.""" + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.volume.volume = None + + await async_update_entity(hass, "number.roborock_q10_s5_volume") + + state = hass.states.get("number.roborock_q10_s5_volume") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_q10_volume_update_failed( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test a failure while changing the volume of a Q10 device.""" + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.volume.set_volume.side_effect = RoborockTimeout + + assert hass.states.get("number.roborock_q10_s5_volume") is not None + + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 30.0}, + blocking=True, + target={"entity_id": "number.roborock_q10_s5_volume"}, + ) + + fake_q10_vacuum.b01_q10_properties.volume.set_volume.assert_awaited_once_with(30) + + async def test_volume_unknown_value( hass: HomeAssistant, setup_entry: MockConfigEntry, diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index bb3b15c84f43..aa02c334e9cb 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -1,5 +1,6 @@ """Test Roborock Select platform.""" +import copy from typing import Any from unittest.mock import AsyncMock, Mock, call @@ -538,3 +539,48 @@ async def test_q10_cleaning_mode_select_invalid_option( assert fake_q10_vacuum.b01_q10_properties fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_not_called() + + +@pytest.mark.parametrize("platforms", [[Platform.SELECT]]) +async def test_zeo_request_protocols_filtered_by_schema( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], +) -> None: + """Test that Zeo request protocols are filtered by the device's supported schema IDs, ensuring correct entities are created.""" + # Find the first Zeo device + zeo_device_1 = next( + (device for device in fake_devices if device.zeo is not None), + None, + ) + assert zeo_device_1 is not None + + # Create a second Zeo device without softener in its schema + zeo_device_2 = copy.deepcopy(zeo_device_1) + zeo_device_2.device_info.duid = "zeo_duid_2" + zeo_device_2._duid = "zeo_duid_2" + zeo_device_2.device_info.name = "Zeo Two" + zeo_device_2._name = "Zeo Two" + zeo_device_2.device_info.sn = "zeo_sn_2" + + # Exclude softener parameters: 214 (SOFTENER_TYPE) and 227 (SOFTENER_EMPTY) + zeo_device_2.product.schema = [ + schema + for schema in zeo_device_2.product.schema + if schema.id not in ("214", "227") + ] + + # Add the second device to the list of fake devices + fake_devices.append(zeo_device_2) + + # Now set up the integration + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + # Verify that the first Zeo device has both detergent and softener entities + assert hass.states.get("select.zeo_one_detergent_type") is not None + assert hass.states.get("select.zeo_one_softener_type") is not None + + # Verify that the second Zeo device has detergent entities but NOT softener entities + assert hass.states.get("select.zeo_two_detergent_type") is not None + assert hass.states.get("select.zeo_two_softener_type") is None diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 7b14fec62042..fa2b9702bef4 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -1,12 +1,17 @@ """Test Roborock Sensors.""" +from typing import Any + import pytest +from roborock.exceptions import RoborockException from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import FakeDevice + from tests.common import MockConfigEntry, snapshot_platform @@ -24,3 +29,64 @@ async def test_sensors( ) -> None: """Test sensors and check test values are correctly set.""" await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id) + + +def setup_coordinator_side_effect( + fake_devices: list[FakeDevice], side_effect: Any +) -> None: + """Set the query/refresh side effect on all fake devices to simulate failure or delay.""" + for device in fake_devices: + if device.v1_properties is not None: + device.v1_properties.status.refresh.side_effect = side_effect + if device.dyad is not None: + device.dyad.query_values.side_effect = side_effect + if device.zeo is not None: + device.zeo.query_values.side_effect = side_effect + if device.b01_q10_properties is not None: + device.b01_q10_properties.refresh.side_effect = side_effect + if device.b01_q7_properties is not None: + device.b01_q7_properties.query_values.side_effect = side_effect + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (RoborockException("Simulated failure"), STATE_UNAVAILABLE), + ], +) +async def test_sensors_coordinator_state( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + fake_devices: list[FakeDevice], + side_effect: Any, + expected_state: str, +) -> None: + """Test sensors state based on coordinator update success or delay.""" + setup_coordinator_side_effect(fake_devices, side_effect) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + # V1 sensors + state = hass.states.get("sensor.roborock_s7_maxv_battery") + assert state is not None + assert state.state == expected_state + + # A01 (Dyad/Zeo) sensors + state = hass.states.get("sensor.dyad_pro_battery") + assert state is not None + assert state.state == expected_state + + state = hass.states.get("sensor.zeo_one_washing_left") + assert state is not None + assert state.state == expected_state + + # B01 Q7 sensors + state = hass.states.get("sensor.roborock_q7_battery") + assert state is not None + assert state.state == expected_state + + # B01 Q10 sensors + state = hass.states.get("sensor.roborock_q10_s5_battery") + assert state is not None + assert state.state == expected_state diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index a2c1d744e45e..7e516f7403a2 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -10,15 +10,20 @@ from roborock.roborock_message import RoborockZeoProtocol from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .conftest import FakeDevice -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache, + snapshot_platform, +) @pytest.fixture @@ -257,14 +262,22 @@ async def test_a01_switch_unknown_state( assert state.state == "unknown" -async def test_q10_do_not_disturb_switch_success( +@pytest.mark.parametrize( + ("entity_id", "trait_name"), + [ + ("switch.roborock_q10_s5_do_not_disturb", "do_not_disturb"), + ("switch.roborock_q10_s5_child_lock", "child_lock"), + ("switch.roborock_q10_s5_dust_collection", "dust_collection"), + ], +) +async def test_q10_switch_success( hass: HomeAssistant, setup_entry: MockConfigEntry, fake_q10_vacuum: FakeDevice, + entity_id: str, + trait_name: str, ) -> None: - """Test turning Q10 Do Not Disturb on and off.""" - entity_id = "switch.roborock_q10_s5_do_not_disturb" - + """Test turning Q10 switch entities on and off.""" assert hass.states.get(entity_id) is not None await hass.services.async_call( @@ -290,25 +303,34 @@ async def test_q10_do_not_disturb_switch_success( assert state.state == "on" assert fake_q10_vacuum.b01_q10_properties is not None - assert fake_q10_vacuum.b01_q10_properties.do_not_disturb.enable.call_count == 1 - assert fake_q10_vacuum.b01_q10_properties.do_not_disturb.disable.call_count == 1 + trait = getattr(fake_q10_vacuum.b01_q10_properties, trait_name) + trait.enable.assert_awaited_once() + trait.disable.assert_awaited_once() -async def test_q10_do_not_disturb_switch_failure( +@pytest.mark.parametrize( + ("entity_id", "trait_name"), + [ + ("switch.roborock_q10_s5_do_not_disturb", "do_not_disturb"), + ("switch.roborock_q10_s5_child_lock", "child_lock"), + ("switch.roborock_q10_s5_dust_collection", "dust_collection"), + ], +) +async def test_q10_switch_failure( hass: HomeAssistant, setup_entry: MockConfigEntry, fake_q10_vacuum: FakeDevice, + entity_id: str, + trait_name: str, ) -> None: - """Test a failure while updating Q10 Do Not Disturb.""" - entity_id = "switch.roborock_q10_s5_do_not_disturb" + """Test a failure while updating a Q10 switch.""" assert fake_q10_vacuum.b01_q10_properties is not None - fake_q10_vacuum.b01_q10_properties.do_not_disturb.enable.side_effect = ( - roborock.exceptions.RoborockTimeout - ) + trait = getattr(fake_q10_vacuum.b01_q10_properties, trait_name) + trait.enable.side_effect = roborock.exceptions.RoborockTimeout assert hass.states.get(entity_id) is not None - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -316,3 +338,89 @@ async def test_q10_do_not_disturb_switch_failure( blocking=True, target={"entity_id": entity_id}, ) + + +async def test_q10_button_light_switch( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test the Q10 write-only indicator light switch assumes its state.""" + entity_id = "switch.roborock_q10_s5_indicator_light" + + # The device never reports the light state, so it starts unknown + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" + + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.button_light.enable.assert_awaited_once() + fake_q10_vacuum.b01_q10_properties.button_light.disable.assert_awaited_once() + + +async def test_q10_button_light_switch_restore_state( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, +) -> None: + """Test the Q10 indicator light restores its assumed state after a restart.""" + entity_id = "switch.roborock_q10_s5_indicator_light" + mock_restore_cache(hass, (State(entity_id, STATE_ON),)) + + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + +async def test_q10_button_light_switch_failure( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + fake_q10_vacuum: FakeDevice, +) -> None: + """Test the Q10 indicator light keeps its state on a failed command.""" + entity_id = "switch.roborock_q10_s5_indicator_light" + assert fake_q10_vacuum.b01_q10_properties is not None + fake_q10_vacuum.b01_q10_properties.button_light.enable.side_effect = ( + roborock.exceptions.RoborockTimeout + ) + + assert hass.states.get(entity_id) is not None + + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + + # The failed command must not flip the assumed state + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/rova/snapshots/test_init.ambr b/tests/components/rova/snapshots/test_init.ambr index 25925ac38654..ca49cd42e3f6 100644 --- a/tests/components/rova/snapshots/test_init.ambr +++ b/tests/components/rova/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_service DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '8381BE 13', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index b02f80f1dfd4..8470ec5fe35f 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.20.75', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'MCA-C5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/rympro/test_init.py b/tests/components/rympro/test_init.py new file mode 100644 index 000000000000..d966f9cf7e30 --- /dev/null +++ b/tests/components/rympro/test_init.py @@ -0,0 +1,69 @@ +"""Test the Read Your Meter Pro integration setup.""" + +from unittest.mock import patch + +from pyrympro import CannotConnectError, OperationError, UnauthorizedError +import pytest + +from homeassistant.components.rympro.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + CONF_TOKEN: "test-token", + CONF_UNIQUE_ID: "test-account-number", +} + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_DATA, + unique_id=TEST_DATA[CONF_UNIQUE_ID], + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.mark.parametrize("exception", [CannotConnectError, OperationError]) +async def test_account_info_error_retries_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + exception: type[Exception], +) -> None: + """Test that a transient account_info error schedules a setup retry.""" + with patch( + "homeassistant.components.rympro.RymPro.account_info", + side_effect=exception, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_relogin_cannot_connect_error_retries_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that a connection error while re-authenticating retries setup.""" + with ( + patch( + "homeassistant.components.rympro.RymPro.account_info", + side_effect=UnauthorizedError, + ), + patch( + "homeassistant.components.rympro.RymPro.login", + side_effect=CannotConnectError, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index 4be166ecf25b..96f97f1af1ba 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -73,8 +71,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -99,7 +97,6 @@ 'model_id': 'UE43LS003', 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr b/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr index 86f1ff155827..f6649aefc54d 100644 --- a/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/satel_integra/snapshots/test_alarm_control_panel.ambr @@ -56,8 +56,8 @@ # name: test_alarm_control_panel[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -78,7 +78,6 @@ 'model_id': None, 'name': 'Home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_binary_sensor.ambr b/tests/components/satel_integra/snapshots/test_binary_sensor.ambr index 5944744b8621..0a8c85b1389a 100644 --- a/tests/components/satel_integra/snapshots/test_binary_sensor.ambr +++ b/tests/components/satel_integra/snapshots/test_binary_sensor.ambr @@ -104,8 +104,8 @@ # name: test_binary_sensors[device-output] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -126,7 +126,6 @@ 'model_id': None, 'name': 'Output', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -135,8 +134,8 @@ # name: test_binary_sensors[device-zone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Zone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_init.ambr b/tests/components/satel_integra/snapshots/test_init.ambr index 9853a728ed61..a88c6922cc6b 100644 --- a/tests/components/satel_integra/snapshots/test_init.ambr +++ b/tests/components/satel_integra/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_parent_device_exists[parent-device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': '192.168.0.2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/satel_integra/snapshots/test_sensor.ambr b/tests/components/satel_integra/snapshots/test_sensor.ambr index c8d63dc6b9b4..10c6478b3c6a 100644 --- a/tests/components/satel_integra/snapshots/test_sensor.ambr +++ b/tests/components/satel_integra/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_sensors[device-zone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Zone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/satel_integra/snapshots/test_switch.ambr b/tests/components/satel_integra/snapshots/test_switch.ambr index ec4a15864407..5ef82fb54bdc 100644 --- a/tests/components/satel_integra/snapshots/test_switch.ambr +++ b/tests/components/satel_integra/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_switches[device] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Switchable Output', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/saunum/snapshots/test_init.ambr b/tests/components/saunum/snapshots/test_init.ambr index 473bfe6ce139..1dfc03ed1eb1 100644 --- a/tests/components/saunum/snapshots/test_init.ambr +++ b/tests/components/saunum/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Saunum Leil', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/saunum/snapshots/test_number.ambr b/tests/components/saunum/snapshots/test_number.ambr index 5e9672153603..70f983cf5432 100644 --- a/tests/components/saunum/snapshots/test_number.ambr +++ b/tests/components/saunum/snapshots/test_number.ambr @@ -6,7 +6,7 @@ ]), 'area_id': None, 'capabilities': dict({ - : 30, + : 15, : 1, : , : 1, @@ -46,7 +46,7 @@ 'attributes': ReadOnlyDict({ : 'duration', : 'Saunum Leil Fan duration', - : 30, + : 15, : 1, : , : 1, diff --git a/tests/components/saunum/test_init.py b/tests/components/saunum/test_init.py index 3a41acf57b5b..8de4866dbedf 100644 --- a/tests/components/saunum/test_init.py +++ b/tests/components/saunum/test_init.py @@ -44,6 +44,10 @@ async def test_async_setup_entry_connection_failed( assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.error_reason_translation_key == "cannot_connect" + assert mock_config_entry.error_reason_translation_placeholders == { + "host": mock_config_entry.data["host"], + } @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr index 1b6cc3f1cdb5..964bcfd5f2e9 100644 --- a/tests/components/schlage/snapshots/test_init.ambr +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Vault Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0', 'via_device_id': None, diff --git a/tests/components/scorpiontrack/__init__.py b/tests/components/scorpiontrack/__init__.py new file mode 100644 index 000000000000..e6917b6c4a9b --- /dev/null +++ b/tests/components/scorpiontrack/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the ScorpionTrack integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the ScorpionTrack integration.""" + 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/scorpiontrack/conftest.py b/tests/components/scorpiontrack/conftest.py new file mode 100644 index 000000000000..efc871fafb79 --- /dev/null +++ b/tests/components/scorpiontrack/conftest.py @@ -0,0 +1,86 @@ +"""Test fixtures for the ScorpionTrack integration.""" + +from collections.abc import Iterator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from pyscorpiontrack import ( + ScorpionTrackClient, + ScorpionTrackPosition, + ScorpionTrackShare, + ScorpionTrackVehicle, +) +import pytest + +from homeassistant.components.scorpiontrack.const import CONF_SHARE_TOKEN, DOMAIN +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_share() -> ScorpionTrackShare: + """Return a representative ScorpionTrack share.""" + now = dt_util.utcnow() + return ScorpionTrackShare( + id=101, + token="canonical-token", + title="Family Cars", + owner_name="Ashby Herbert", + distance_units="miles", + created_at=now - timedelta(days=3), + expires_at=now + timedelta(days=28), + vehicles=( + ScorpionTrackVehicle( + id=1, + name="Golf R", + registration="AB12 CDE", + make="Volkswagen", + model="Golf R", + position=ScorpionTrackPosition( + latitude=51.5074, + longitude=-0.1278, + timestamp=now - timedelta(days=2), + speed_kmh=48.3, + ignition=True, + bearing=182.0, + address="Westminster, London", + ), + status="Moving", + ), + ), + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Family Cars", + data={CONF_SHARE_TOKEN: "canonical-token"}, + unique_id="101", + entry_id="01SCORPIONTRACK_TEST_ENTRY", + ) + + +@pytest.fixture(autouse=True) +def mock_scorpiontrack_client( + mock_share: ScorpionTrackShare, +) -> Iterator[AsyncMock]: + """Mock the ScorpionTrack client.""" + with ( + patch( + "homeassistant.components.scorpiontrack.ScorpionTrackClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient", + new=mock_client, + ), + ): + client = mock_client.return_value + mock_client.extract_token.side_effect = ScorpionTrackClient.extract_token + client.token = "canonical-token" + client.async_get_share.return_value = mock_share + yield client diff --git a/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr b/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr new file mode 100644 index 000000000000..d242dc097022 --- /dev/null +++ b/tests/components/scorpiontrack/snapshots/test_device_tracker.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_device_tracker_state[device_tracker.ab12_cde-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.ab12_cde', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'scorpiontrack', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_location', + 'unique_id': '101_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker_state[device_tracker.ab12_cde-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'AB12 CDE', + : 0, + : list([ + ]), + : 51.5074, + : -0.1278, + : , + : , + }), + 'context': , + 'entity_id': 'device_tracker.ab12_cde', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/scorpiontrack/test_config_flow.py b/tests/components/scorpiontrack/test_config_flow.py new file mode 100644 index 000000000000..e970093b7a8c --- /dev/null +++ b/tests/components/scorpiontrack/test_config_flow.py @@ -0,0 +1,199 @@ +"""Test the ScorpionTrack config flow.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +from pyscorpiontrack import ( + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShare, + ScorpionTrackShareUnavailableError, +) +import pytest + +from homeassistant.components.scorpiontrack.const import ( + CONF_SHARE_TOKEN, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from tests.common import MockConfigEntry + + +async def _async_start_user_flow(hass: HomeAssistant) -> FlowResult: + """Start the 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 result["errors"] == {} + return result + + +async def test_user_flow_creates_entry( + hass: HomeAssistant, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """A valid token should create a config entry.""" + result = await _async_start_user_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SHARE_TOKEN: ( + " https://app.scorpiontrack.com/shared/location" + "?token=canonical-token " + ) + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Family Cars" + assert result["data"] == {CONF_SHARE_TOKEN: "canonical-token"} + assert result["result"].unique_id == "101" + mock_scorpiontrack_client.async_get_share.assert_awaited() + + +async def test_user_flow_uses_vehicle_display_name_without_share_title( + hass: HomeAssistant, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """The config entry title should fall back to the first vehicle display name.""" + share = replace(mock_share, title="") + mock_scorpiontrack_client.async_get_share.return_value = share + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == share.vehicles[0].display_name + assert result["result"].unique_id == "101" + + +async def test_user_flow_uses_default_name_without_title_or_vehicles( + hass: HomeAssistant, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """The config entry title should use the default name when nothing descriptive exists.""" + share = replace(mock_share, title="", vehicles=()) + mock_scorpiontrack_client.async_get_share.return_value = share + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["result"].unique_id == "101" + + +async def test_user_flow_aborts_for_existing_share( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """The same share should not be configured twice.""" + mock_config_entry.add_to_hass(hass) + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + ScorpionTrackConnectionError("Connection failed"), + "cannot_connect", + ), + (ScorpionTrackInvalidTokenError("Invalid token"), "invalid_token"), + ( + ScorpionTrackShareUnavailableError("Share expired"), + "share_unavailable", + ), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_user_flow_shows_validation_errors( + hass: HomeAssistant, + mock_scorpiontrack_client: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Validation errors should surface the correct base error.""" + mock_scorpiontrack_client.async_get_share.side_effect = side_effect + + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + +async def test_user_flow_maps_malformed_input_to_invalid_token( + hass: HomeAssistant, +) -> None: + """Malformed pasted values should show the invalid-token form error.""" + with patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient.extract_token", + side_effect=ValueError("Could not parse share link"), + ): + result = await _async_start_user_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "not a share"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_token"} + + +async def test_user_flow_recovers_after_invalid_token( + hass: HomeAssistant, +) -> None: + """The user flow should recover after an invalid token error.""" + result = await _async_start_user_flow(hass) + + with patch( + "homeassistant.components.scorpiontrack.config_flow.ScorpionTrackClient.extract_token", + side_effect=ScorpionTrackInvalidTokenError("Invalid token"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "not a share"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_token"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SHARE_TOKEN: "canonical-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_SHARE_TOKEN: "canonical-token"} + assert result["result"].unique_id == "101" diff --git a/tests/components/scorpiontrack/test_device_tracker.py b/tests/components/scorpiontrack/test_device_tracker.py new file mode 100644 index 000000000000..2dc2ab4c0681 --- /dev/null +++ b/tests/components/scorpiontrack/test_device_tracker.py @@ -0,0 +1,99 @@ +"""Test the ScorpionTrack device tracker platform.""" + +from dataclasses import replace +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pyscorpiontrack import ScorpionTrackConnectionError, ScorpionTrackShare +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.scorpiontrack.const import DEFAULT_SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_device_tracker_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the ScorpionTrack device tracker state and attributes.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_removed_vehicle_becomes_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Test a tracker becomes unavailable if its vehicle leaves the share.""" + await setup_integration(hass, mock_config_entry) + + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, vehicles=() + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.ab12_cde") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_connection_error_makes_tracker_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Test a tracker becomes unavailable after a refresh connection error.""" + await setup_integration(hass, mock_config_entry) + + mock_scorpiontrack_client.async_get_share.side_effect = ( + ScorpionTrackConnectionError("Connection failed") + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.ab12_cde") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_new_vehicles_after_setup_do_not_add_tracker_entities( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_share: ScorpionTrackShare, + mock_scorpiontrack_client: AsyncMock, +) -> None: + """Vehicles that appear later should wait for a future dynamic-device PR.""" + await setup_integration(hass, mock_config_entry) + + new_vehicle = replace( + mock_share.vehicles[0], + id=2, + name="Tiguan", + registration="EF34 ABC", + model="Tiguan", + ) + mock_scorpiontrack_client.async_get_share.return_value = replace( + mock_share, vehicles=(*mock_share.vehicles, new_vehicle) + ) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.ef34_abc") is None diff --git a/tests/components/scorpiontrack/test_init.py b/tests/components/scorpiontrack/test_init.py new file mode 100644 index 000000000000..570e222f072e --- /dev/null +++ b/tests/components/scorpiontrack/test_init.py @@ -0,0 +1,74 @@ +"""Test ScorpionTrack integration setup.""" + +from unittest.mock import AsyncMock + +from pyscorpiontrack import ( + ScorpionTrackConnectionError, + ScorpionTrackInvalidTokenError, + ScorpionTrackShareUnavailableError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload of entry.""" + await setup_integration(hass, mock_config_entry) + 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_device_is_registered( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the ScorpionTrack vehicle device is registered.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={("scorpiontrack", "101_1")}) + assert device is not None + assert device.name == "AB12 CDE" + assert device.manufacturer == "Volkswagen" + assert device.model == "Golf R" + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (ScorpionTrackInvalidTokenError("Invalid token"), ConfigEntryState.SETUP_ERROR), + ( + ScorpionTrackShareUnavailableError("Share expired"), + ConfigEntryState.SETUP_ERROR, + ), + ( + ScorpionTrackConnectionError("Connection failed"), + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_setup_entry_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_scorpiontrack_client: AsyncMock, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup with token and connection errors.""" + mock_scorpiontrack_client.async_get_share.side_effect = exception + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is expected_state diff --git a/tests/components/scrape/snapshots/test_init.ambr b/tests/components/scrape/snapshots/test_init.ambr index 45a049d7835b..a7c010f3aa1c 100644 --- a/tests/components/scrape/snapshots/test_init.ambr +++ b/tests/components/scrape/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_migrate_from_version_1_to_2[device_registry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Current version', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/screenlogic/conftest.py b/tests/components/screenlogic/conftest.py index b9bb22555a17..25727bf7a8c3 100644 --- a/tests/components/screenlogic/conftest.py +++ b/tests/components/screenlogic/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components.screenlogic import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from . import ( MOCK_ADAPTER_IP, @@ -29,9 +29,6 @@ def mock_config_entry() -> MockConfigEntry: CONF_IP_ADDRESS: MOCK_ADAPTER_IP, CONF_PORT: MOCK_ADAPTER_PORT, }, - options={ - CONF_SCAN_INTERVAL: 30, - }, unique_id=MOCK_ADAPTER_MAC, entry_id=MOCK_CONFIG_ENTRY_ID, ) diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index c7db7a339599..77dc3df46bd6 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -13,7 +13,6 @@ 'entry_id': 'screenlogictest', 'minor_version': 1, 'options': dict({ - 'scan_interval': 30, }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index ad8ef125dac2..4c5ac409b534 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -16,12 +16,8 @@ from homeassistant.components.screenlogic.config_flow import ( GATEWAY_MANUAL_ENTRY, GATEWAY_SELECT_KEY, ) -from homeassistant.components.screenlogic.const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - MIN_SCAN_INTERVAL, -) -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.components.screenlogic.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -302,80 +298,3 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} - - -async def test_option_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.screenlogic.async_setup_entry", - return_value=True, - ): - 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={CONF_SCAN_INTERVAL: 15}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_SCAN_INTERVAL: 15} - - -async def test_option_flow_defaults(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.screenlogic.async_setup_entry", - return_value=True, - ): - 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 result["data"] == { - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - } - - -async def test_option_flow_input_floor(hass: HomeAssistant) -> None: - """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.screenlogic.async_setup_entry", - return_value=True, - ): - 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={CONF_SCAN_INTERVAL: 1} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_SCAN_INTERVAL: MIN_SCAN_INTERVAL, - } diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 8f0a632fcf76..80ad3cfa2850 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -919,6 +919,7 @@ async def test_extraction_functions( "script.test3", } assert set(script.entities_in_script(hass, "script.test1")) == { + "light.device_in_both", "light.in_both", "light.in_first", } diff --git a/tests/components/script/test_llm.py b/tests/components/script/test_llm.py new file mode 100644 index 000000000000..9295d4c05553 --- /dev/null +++ b/tests/components/script/test_llm.py @@ -0,0 +1,106 @@ +"""Tests for the script LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.script import llm as script_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er, llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "script.test_script" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a script.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "llm", {}) + assert await async_setup_component( + hass, + "script", + { + "script": { + "test_script": { + "alias": "test script", + "description": "This is a test script", + "sequence": [ + {"variables": {"result": {"drinks": 2}}}, + {"stop": True, "response_variable": "result"}, + ], + "fields": { + "beer": {"description": "Number of beers", "required": True}, + }, + }, + "unexposed_script": {"sequence": []}, + "123456": {"sequence": []}, + } + }, + ) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_script_tool_only_exposed(hass: HomeAssistant) -> None: + """Test only exposed scripts get a tool.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + names = [tool.name for tool in result.tools] + assert "test_script" in names + assert "unexposed_script" not in names + + +async def test_script_tool_not_exposed(hass: HomeAssistant) -> None: + """Test no script tool is offered when the script is not exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "test_script" not in [tool.name for tool in result.tools] + assert script_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert script_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_script_tool_call(hass: HomeAssistant) -> None: + """Test calling the exposed script through its tool.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(tool for tool in result.tools if tool.name == "test_script") + + response = await tool.async_call( + hass, llm.ToolInput("test_script", {"beer": 1}), llm_context + ) + assert response == {"success": True, "result": {"drinks": 2}} + + +async def test_script_tool_name_not_started_with_digit(hass: HomeAssistant) -> None: + """Test a script whose id starts with a digit gets a valid tool name.""" + async_expose_entity(hass, "conversation", "script.123456", True) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "_123456" in [tool.name for tool in result.tools] + + +async def test_script_tool_description_includes_aliases( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the script tool description is extended with the entity aliases.""" + entity_registry.async_update_entity(ENTITY_ID, aliases=["barkeep", "pour a drink"]) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tool = next(tool for tool in result.tools if tool.name == "test_script") + assert tool.description == ( + "This is a test script. Aliases: ['barkeep', 'pour a drink']" + ) diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index df99522f9dde..aeb51bad6303 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -111,6 +111,23 @@ async def test_search( wled_segment_2_entity.entity_id, area_id=bedroom_area.id ) + # Config entry with a device providing a scene entity + esphome_config_entry = MockConfigEntry(domain="esphome") + esphome_config_entry.add_to_hass(hass) + esphome_device = device_registry.async_get_or_create( + config_entry_id=esphome_config_entry.entry_id, + name="Node", + identifiers={("esphome", "esphome-1")}, + ) + esphome_scene_entity = entity_registry.async_get_or_create( + "scene", + "esphome", + "esphome-1-scene", + suggested_object_id="esphome scene", + config_entry=esphome_config_entry, + device_id=esphome_device.id, + ) + scene_wled_hue_entity = entity_registry.async_get_or_create( "scene", "homeassistant", @@ -136,6 +153,23 @@ async def test_search( labels={label_other.label_id}, ) + # Device tracker of the person, provided by a config entry with a device + mobile_app_config_entry = MockConfigEntry(domain="mobile_app") + mobile_app_config_entry.add_to_hass(hass) + mobile_app_device = device_registry.async_get_or_create( + config_entry_id=mobile_app_config_entry.entry_id, + name="Paulus iPhone", + identifiers={("mobile_app", "phone-1")}, + ) + entity_registry.async_get_or_create( + "device_tracker", + "mobile_app", + "phone-1-tracker", + suggested_object_id="paulus_iphone", + config_entry=mobile_app_config_entry, + device_id=mobile_app_device.id, + ) + script_scene_entity = entity_registry.async_get_or_create( "script", "script", @@ -534,6 +568,7 @@ async def test_search( ItemType.AREA: {living_room_area.id}, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, ItemType.DEVICE: {wled_device.id}, + ItemType.ENTITY: {wled_segment_1_entity.entity_id}, ItemType.FLOOR: {first_floor.floor_id}, ItemType.INTEGRATION: {"wled"}, } @@ -544,6 +579,9 @@ async def test_search( ItemType.AREA: {kitchen_area.id}, ItemType.FLOOR: {first_floor.floor_id}, } + assert search(ItemType.AUTOMATION, "automation.label") == { + ItemType.LABEL: {label_christmas.label_id}, + } assert search(ItemType.AUTOMATION, "automation.group") == { ItemType.AREA: {bedroom_area.id, living_room_area.id, kitchen_area.id}, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id, hue_config_entry.entry_id}, @@ -658,11 +696,23 @@ async def test_search( ItemType.SCENE: {"scene.scene_hue_seg_1", scene_wled_hue_entity.entity_id}, ItemType.SCRIPT: {"script.device", "script.hue"}, } + assert search(ItemType.DEVICE, esphome_device.id) == { + ItemType.CONFIG_ENTRY: {esphome_config_entry.entry_id}, + ItemType.ENTITY: {esphome_scene_entity.entity_id}, + ItemType.INTEGRATION: {"esphome"}, + ItemType.SCENE: {esphome_scene_entity.entity_id}, + } + assert search(ItemType.CONFIG_ENTRY, esphome_config_entry.entry_id) == { + ItemType.DEVICE: {esphome_device.id}, + ItemType.ENTITY: {esphome_scene_entity.entity_id}, + ItemType.INTEGRATION: {"esphome"}, + ItemType.SCENE: {esphome_scene_entity.entity_id}, + } assert not search(ItemType.ENTITY, "sensor.unknown") assert search(ItemType.ENTITY, wled_segment_1_entity.entity_id) == { ItemType.AREA: {living_room_area.id}, - ItemType.AUTOMATION: {"automation.wled_entity"}, + ItemType.AUTOMATION: {"automation.wled_entity", "automation.wled_device"}, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, ItemType.DEVICE: {wled_device.id}, ItemType.FLOOR: {first_floor.floor_id}, @@ -725,6 +775,9 @@ async def test_search( ItemType.SCRIPT: {script_scene_entity.entity_id}, } assert search(ItemType.ENTITY, "device_tracker.paulus_iphone") == { + ItemType.CONFIG_ENTRY: {mobile_app_config_entry.entry_id}, + ItemType.DEVICE: {mobile_app_device.id}, + ItemType.INTEGRATION: {"mobile_app"}, ItemType.PERSON: {person_paulus_entity.entity_id}, } assert search(ItemType.ENTITY, "light.wled_config_entry_source") == { @@ -817,13 +870,53 @@ async def test_search( ItemType.SCRIPT: {"script.group"}, } + assert not search(ItemType.INTEGRATION, "unknown") + assert search(ItemType.INTEGRATION, "wled") == { + ItemType.AREA: {bedroom_area.id, living_room_area.id}, + ItemType.AUTOMATION: {"automation.wled_entity", "automation.wled_device"}, + ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, + ItemType.DEVICE: {wled_device.id}, + ItemType.ENTITY: { + wled_segment_1_entity.entity_id, + wled_segment_2_entity.entity_id, + "light.wled_platform_config_source", + "light.wled_config_entry_source", + }, + ItemType.FLOOR: {first_floor.floor_id, second_floor.floor_id}, + ItemType.GROUP: {"group.wled", "group.wled_hue"}, + ItemType.SCENE: {"scene.scene_wled_seg_1", scene_wled_hue_entity.entity_id}, + ItemType.SCRIPT: {"script.wled"}, + } + assert search(ItemType.INTEGRATION, "hue") == { + ItemType.AREA: {kitchen_area.id}, + ItemType.CONFIG_ENTRY: {hue_config_entry.entry_id}, + ItemType.DEVICE: {hue_device.id}, + ItemType.ENTITY: { + hue_segment_1_entity.entity_id, + hue_segment_2_entity.entity_id, + }, + ItemType.FLOOR: {first_floor.floor_id}, + ItemType.GROUP: {"group.hue", "group.wled_hue"}, + ItemType.SCENE: {"scene.scene_hue_seg_1", scene_wled_hue_entity.entity_id}, + ItemType.SCRIPT: {"script.device", "script.hue"}, + } + assert not search(ItemType.LABEL, "unknown") assert search(ItemType.LABEL, label_christmas.label_id) == { + ItemType.AREA: {living_room_area.id}, ItemType.AUTOMATION: {"automation.label"}, + ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id}, ItemType.DEVICE: {wled_device.id}, + ItemType.FLOOR: {first_floor.floor_id}, + ItemType.INTEGRATION: {"wled"}, } assert search(ItemType.LABEL, label_energy.label_id) == { + ItemType.AREA: {kitchen_area.id}, + ItemType.CONFIG_ENTRY: {hue_config_entry.entry_id}, + ItemType.DEVICE: {hue_device.id}, ItemType.ENTITY: {hue_segment_1_entity.entity_id}, + ItemType.FLOOR: {first_floor.floor_id}, + ItemType.INTEGRATION: {"hue"}, } assert search(ItemType.LABEL, label_other.label_id) == { ItemType.AREA: {bedroom_area.id}, @@ -832,6 +925,7 @@ async def test_search( person_paulus_entity.entity_id, script_scene_entity.entity_id, }, + ItemType.FLOOR: {second_floor.floor_id}, ItemType.PERSON: {person_paulus_entity.entity_id}, ItemType.SCENE: {scene_wled_hue_entity.entity_id}, ItemType.SCRIPT: {"script.label", script_scene_entity.entity_id}, @@ -840,8 +934,11 @@ async def test_search( assert not search(ItemType.PERSON, "person.unknown") assert search(ItemType.PERSON, person_paulus_entity.entity_id) == { ItemType.AREA: {bedroom_area.id}, + ItemType.CONFIG_ENTRY: {mobile_app_config_entry.entry_id}, + ItemType.DEVICE: {mobile_app_device.id}, ItemType.ENTITY: {"device_tracker.paulus_iphone"}, ItemType.FLOOR: {second_floor.floor_id}, + ItemType.INTEGRATION: {"mobile_app"}, ItemType.LABEL: {label_other.label_id}, } @@ -919,6 +1016,9 @@ async def test_search( ItemType.AREA: {kitchen_area.id}, ItemType.FLOOR: {first_floor.floor_id}, } + assert search(ItemType.SCRIPT, "script.label") == { + ItemType.LABEL: {label_other.label_id}, + } assert search(ItemType.SCRIPT, "script.group") == { ItemType.AREA: {bedroom_area.id, living_room_area.id, kitchen_area.id}, ItemType.CONFIG_ENTRY: {wled_config_entry.entry_id, hue_config_entry.entry_id}, diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr index e01ca3ee4bc2..e5544ac276a7 100644 --- a/tests/components/sensibo/snapshots/test_entity.ambr +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': 'bedroom', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0987654329', 'sw_version': 'PUR00111', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': 'hallway', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -62,15 +61,14 @@ 'model_id': None, 'name': 'Hallway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'SKY30046', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ }), @@ -91,15 +89,14 @@ 'model_id': None, 'name': 'Hallway Motion Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'V17', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -124,7 +121,6 @@ 'model_id': None, 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0987654321', 'sw_version': 'PUR00111', 'via_device_id': None, diff --git a/tests/components/sfr_box/snapshots/test_init.ambr b/tests/components/sfr_box/snapshots/test_init.ambr index fc136e73dd1d..b4b7bcb4fffd 100644 --- a/tests/components/sfr_box/snapshots/test_init.ambr +++ b/tests/components/sfr_box/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index 1c8d81f93712..397e088832c2 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -26,7 +26,6 @@ from homeassistant.components.sharkiq.vacuum import ( FAN_SPEEDS_MAP, ) from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, SERVICE_LOCATE, @@ -61,8 +60,7 @@ from tests.common import MockConfigEntry VAC_ENTITY_ID = f"vacuum.{SHARK_DEVICE_DICT['product_name'].lower()}" ROOM_LIST = ["Kitchen", "Living Room"] EXPECTED_FEATURES = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED + VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START @@ -168,7 +166,6 @@ async def test_simple_properties( ("attribute", "target_value"), [ (ATTR_SUPPORTED_FEATURES, EXPECTED_FEATURES), - (ATTR_BATTERY_LEVEL, 50), (ATTR_FAN_SPEED, "Eco"), (ATTR_FAN_SPEED_LIST, list(FAN_SPEEDS_MAP)), (ATTR_ERROR_CODE, 7), diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 44e2ff560599..152820ad21c1 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -9,7 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.shelly.const import DOMAIN, MODEL_FRANKEVER_WATER_VALVE +from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -23,7 +23,6 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from . import ( - MOCK_MAC, init_integration, mutate_rpc_device_status, patch_platforms, @@ -154,51 +153,6 @@ async def test_rpc_button_reauth_error( assert flow["context"].get("entry_id") == entry.entry_id -@pytest.mark.parametrize( - ("gen", "old_unique_id", "new_unique_id", "migration"), - [ - (2, "123456789ABC_reboot", "123456789ABC-reboot", True), - (1, "123456789ABC_reboot", "123456789ABC-reboot", True), - (2, "123456789ABC-reboot", "123456789ABC-reboot", False), - ], -) -async def test_migrate_unique_id( - hass: HomeAssistant, - mock_block_device: Mock, - mock_rpc_device: Mock, - entity_registry: EntityRegistry, - caplog: pytest.LogCaptureFixture, - gen: int, - old_unique_id: str, - new_unique_id: str, - migration: bool, -) -> None: - """Test migration of unique_id.""" - entry = await init_integration(hass, gen, skip_setup=True) - - entity = entity_registry.async_get_or_create( - suggested_object_id="test_name_restart", - 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.test_name_restart") - assert entity_entry - assert entity_entry.unique_id == new_unique_id - - assert ( - bool("Migrating unique_id for button.test_name_restart" in caplog.text) - == migration - ) - - async def test_rpc_blu_trv_button( hass: HomeAssistant, mock_blu_trv: Mock, @@ -404,90 +358,6 @@ async def test_wall_display_virtual_button( 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 - - -@pytest.mark.parametrize( - ("old_id", "new_id", "role"), - [ - ("button", "button_generic", None), - ("button", "button_open", "open"), - ("button", "button_close", "close"), - ], -) -async def test_migrate_unique_id_virtual_components_roles( - hass: HomeAssistant, - mock_rpc_device: Mock, - entity_registry: EntityRegistry, - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, - old_id: str, - new_id: str, - role: str | None, -) -> None: - """Test migration of unique_id for virtual components to include role.""" - entry = await init_integration( - hass, 3, model=MODEL_FRANKEVER_WATER_VALVE, skip_setup=True - ) - old_unique_id = f"{MOCK_MAC}-{old_id}:200" - new_unique_id = f"{old_unique_id}-{new_id}" - config = deepcopy(mock_rpc_device.config) - if role: - config[f"{old_id}:200"] = { - "role": role, - } - else: - config[f"{old_id}:200"] = {} - monkeypatch.setattr(mock_rpc_device, "config", config) - - entity = entity_registry.async_get_or_create( - suggested_object_id="test_name_test_button", - 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.test_name_test_button") - assert entity_entry - assert entity_entry.unique_id == new_unique_id - - assert "Migrating unique_id for button.test_name_test_button" in caplog.text - - async def test_rpc_smoke_mute_alarm_button( hass: HomeAssistant, mock_rpc_device: Mock, diff --git a/tests/components/shelly/test_services.py b/tests/components/shelly/test_services.py index 2324b01ab02a..cda4479f3bf6 100644 --- a/tests/components/shelly/test_services.py +++ b/tests/components/shelly/test_services.py @@ -200,31 +200,6 @@ async def test_service_set_kvs_value( mock_rpc_device.kvs_set.assert_called_once_with("test_key", "test_value") -async def test_service_get_kvs_value_config_entry_not_found( - hass: HomeAssistant, mock_rpc_device: Mock, device_registry: dr.DeviceRegistry -) -> None: - """Test device with no config entries.""" - entry = await init_integration(hass, 2) - - device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] - - # Remove all config entries from device - device_registry.devices[device.id].config_entries.clear() - - with pytest.raises(ServiceValidationError) as exc_info: - await hass.services.async_call( - DOMAIN, - SERVICE_GET_KVS_VALUE, - {ATTR_DEVICE_ID: device.id, ATTR_KEY: "test_key"}, - blocking=True, - return_response=True, - ) - - assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == "config_entry_not_found" - assert exc_info.value.translation_placeholders == {"device_id": device.id} - - async def test_service_get_kvs_value_device_not_initialized( hass: HomeAssistant, mock_rpc_device: Mock, diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr index 8b9713cb3711..20d4229fa9f8 100644 --- a/tests/components/slide_local/snapshots/test_init.ambr +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.2', 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'slide bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890ab', 'sw_version': '2', 'via_device_id': None, diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 99ae823dd973..e700daf95c04 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -40,6 +40,7 @@ MOCK_USER_RECONFIGURE = { CONF_SSL: True, CONF_VERIFY_SSL: False, CONF_GROUP: "user", + CONF_PASSWORD: "new_password", } diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 4c26fcb93175..0e4f8e5c6895 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -8,7 +8,13 @@ import pytest from homeassistant.components.sma.const import CONF_GROUP, DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac @@ -338,6 +344,7 @@ async def test_full_flow_reconfigure( assert entry.data[CONF_SSL] is True assert entry.data[CONF_VERIFY_SSL] is False assert entry.data[CONF_GROUP] == "user" + assert entry.data[CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 @@ -385,6 +392,7 @@ async def test_full_flow_reconfigure_exceptions( assert entry.data[CONF_SSL] is True assert entry.data[CONF_VERIFY_SSL] is False assert entry.data[CONF_GROUP] == "user" + assert entry.data[CONF_PASSWORD] == "new_password" assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index b827e2183c0f..154aebf1f139 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -5,8 +5,8 @@ # name: test_devices[abl_light_b_001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -27,7 +27,6 @@ 'model_id': None, 'name': 'Kitchen Light 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -36,8 +35,8 @@ # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': 'toilet', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -58,7 +57,6 @@ 'model_id': None, 'name': 'Aeotec Energy Monitor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -67,8 +65,8 @@ # name: test_devices[aeotec_ms6] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -89,7 +87,6 @@ 'model_id': None, 'name': "Parent's Bedroom Sensor", 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -98,8 +95,8 @@ # name: test_devices[aeotec_smart_home_hub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Smart Home Hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '000.059.00008', 'via_device_id': None, @@ -145,8 +141,8 @@ # name: test_devices[aq_sensor_3_ikea] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -171,7 +167,6 @@ 'model_id': None, 'name': 'aq-sensor-3-ikea', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -180,8 +175,8 @@ # name: test_devices[aqara_g350] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -202,7 +197,6 @@ 'model_id': None, 'name': 'G350', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a4367b4d2bbfde94', 'sw_version': '4.5.20', 'via_device_id': None, @@ -211,8 +205,8 @@ # name: test_devices[aux_ac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -233,7 +227,6 @@ 'model_id': None, 'name': 'AUX A/C on-off', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -242,8 +235,8 @@ # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -264,7 +257,6 @@ 'model_id': None, 'name': 'Aeon Energy Monitor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -273,8 +265,8 @@ # name: test_devices[bosch_radiator_thermostat_ii] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -295,7 +287,6 @@ 'model_id': None, 'name': 'Radiator Thermostat II [+M] Wohnzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'D44867FFFEB37584', 'sw_version': '2.00.09', 'via_device_id': None, @@ -304,8 +295,8 @@ # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -326,7 +317,6 @@ 'model_id': None, 'name': '2nd Floor Hallway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -335,8 +325,8 @@ # name: test_devices[c2c_shade] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -357,7 +347,6 @@ 'model_id': None, 'name': 'Curtain 1A', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -366,8 +355,8 @@ # name: test_devices[centralite] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -392,7 +381,6 @@ 'model_id': None, 'name': 'Dimmer Debian', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -401,8 +389,8 @@ # name: test_devices[contact_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -427,7 +415,6 @@ 'model_id': None, 'name': '.Front Door Open/Closed Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +423,8 @@ # name: test_devices[copper_water_meter_v03] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -458,7 +445,6 @@ 'model_id': None, 'name': 'Indoor Water Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +453,8 @@ # name: test_devices[da_ac_air_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -489,7 +475,6 @@ 'model_id': None, 'name': 'Air purifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARTIK051_TVTL_18K_12200115', 'via_device_id': None, @@ -498,8 +483,8 @@ # name: test_devices[da_ac_air_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -520,7 +505,6 @@ 'model_id': None, 'name': 'Air filter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AVT-WW-TP1-22-TOUCHOTN_12240702', 'via_device_id': None, @@ -529,8 +513,8 @@ # name: test_devices[da_ac_airsensor_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -551,7 +535,6 @@ 'model_id': None, 'name': '에어모니터 플러스', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', 'via_device_id': None, @@ -560,8 +543,8 @@ # name: test_devices[da_ac_cac_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -582,7 +565,6 @@ '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, @@ -591,8 +573,8 @@ # name: test_devices[da_ac_ehs_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -613,7 +595,6 @@ 'model_id': None, 'name': 'Heat pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AEH-WW-TP1-22-AE6000_17240903', 'via_device_id': None, @@ -622,8 +603,8 @@ # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -644,7 +625,6 @@ 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +633,8 @@ # name: test_devices[da_ac_rac_000003] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -675,7 +655,6 @@ 'model_id': None, 'name': 'Clim Salon', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARTIK051_PRAC_20K_11230313', 'via_device_id': None, @@ -684,8 +663,8 @@ # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -706,7 +685,6 @@ 'model_id': None, 'name': 'Aire Dormitorio Principal', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, @@ -715,8 +693,8 @@ # name: test_devices[da_ac_rac_100001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -737,7 +715,6 @@ 'model_id': None, 'name': 'Corridor A/C', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +723,8 @@ # name: test_devices[da_ks_cooktop_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -768,7 +745,6 @@ 'model_id': None, 'name': 'Table de cuisson', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP2X-20-COOKTOP_40230515', 'via_device_id': None, @@ -777,8 +753,8 @@ # name: test_devices[da_ks_cooktop_31001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -799,7 +775,6 @@ 'model_id': 'NZ64B5046GK', 'name': 'Induction Hob', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'B8C878DX900290H', 'sw_version': None, 'via_device_id': None, @@ -808,8 +783,8 @@ # name: test_devices[da_ks_hood_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -830,7 +805,6 @@ 'model_id': None, 'name': 'Range hood', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AHD-WW-TP1-22-COMMON_40230419', 'via_device_id': None, @@ -839,8 +813,8 @@ # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -861,7 +835,6 @@ 'model_id': None, 'name': 'Microwave', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, @@ -870,8 +843,8 @@ # name: test_devices[da_ks_oven_01061] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -892,7 +865,6 @@ 'model_id': None, 'name': 'Oven', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', 'via_device_id': None, @@ -901,8 +873,8 @@ # name: test_devices[da_ks_oven_0107x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -923,7 +895,6 @@ 'model_id': None, 'name': 'Kitchen oven', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1-22-OVEN-1_40250221', 'via_device_id': None, @@ -932,8 +903,8 @@ # name: test_devices[da_ks_range_0101x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -954,7 +925,6 @@ 'model_id': None, 'name': 'Vulcan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', 'via_device_id': None, @@ -963,8 +933,8 @@ # name: test_devices[da_ks_walloven_0107x] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -985,7 +955,6 @@ 'model_id': None, 'name': 'Four', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20230413.181729', 'via_device_id': None, @@ -994,8 +963,8 @@ # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1016,7 +985,6 @@ 'model_id': None, 'name': 'Refrigerator', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', 'via_device_id': None, @@ -1025,8 +993,8 @@ # name: test_devices[da_ref_normal_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1047,7 +1015,6 @@ 'model_id': None, 'name': 'Refrigerator 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20240616.213423', 'via_device_id': None, @@ -1056,8 +1023,8 @@ # name: test_devices[da_ref_normal_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1078,7 +1045,6 @@ 'model_id': None, 'name': 'Frigo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP1-22-REV1_20241030', 'via_device_id': None, @@ -1087,8 +1053,8 @@ # name: test_devices[da_ref_normal_01011_onedoor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1109,7 +1075,6 @@ 'model_id': 'RR39C7EC5B1/EF', 'name': 'Lodówka', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-RFWW-TP1-24-T4-COM_20250706', 'via_device_id': None, @@ -1118,8 +1083,8 @@ # name: test_devices[da_ref_normal_100001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1140,7 +1105,6 @@ 'model_id': None, 'name': 'Kjøleskap', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1113,8 @@ # name: test_devices[da_rvc_map_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1171,7 +1135,6 @@ 'model_id': None, 'name': 'Robot Vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20260120.215157', 'via_device_id': None, @@ -1180,8 +1143,8 @@ # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1202,7 +1165,6 @@ 'model_id': None, 'name': 'Robot vacuum 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0', 'via_device_id': None, @@ -1211,8 +1173,8 @@ # name: test_devices[da_sac_ehs_000001_sub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1233,7 +1195,6 @@ 'model_id': None, 'name': 'Eco Heating System', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1242,8 +1203,8 @@ # name: test_devices[da_sac_ehs_000001_sub_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1264,7 +1225,6 @@ 'model_id': None, 'name': 'Heat Pump Main', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1273,8 +1233,8 @@ # name: test_devices[da_sac_ehs_000002_sub] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1295,7 +1255,6 @@ 'model_id': None, 'name': 'Wärmepumpe', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '20250317.1', 'via_device_id': None, @@ -1304,8 +1263,8 @@ # name: test_devices[da_vc_stick_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1326,7 +1285,6 @@ 'model_id': 'VS28C9784QK/WA', 'name': 'Stick vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'A-VSWW-TP1-23-VS9700_51250514', 'via_device_id': None, @@ -1335,8 +1293,8 @@ # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1357,7 +1315,6 @@ 'model_id': None, 'name': 'Dishwasher', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, @@ -1366,8 +1323,8 @@ # name: test_devices[da_wm_dw_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1388,7 +1345,6 @@ 'model_id': 'DW60BG850B00ET', 'name': 'Dishwasher 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DW_TP1_21_COMMON_30250513', 'via_device_id': None, @@ -1397,8 +1353,8 @@ # name: test_devices[da_wm_mf_01001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1419,7 +1375,6 @@ 'model_id': None, 'name': 'Filtro in microfibra', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'AMF-WW-TP1-22-COMMON_30230323', 'via_device_id': None, @@ -1428,8 +1383,8 @@ # name: test_devices[da_wm_sc_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1450,7 +1405,6 @@ 'model_id': None, 'name': 'AirDresser', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_DF_TP2_20_COMMON_30230807', 'via_device_id': None, @@ -1459,8 +1413,8 @@ # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1481,7 +1435,6 @@ 'model_id': None, 'name': 'Dryer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1490,8 +1443,8 @@ # name: test_devices[da_wm_wd_000001_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1512,7 +1465,6 @@ 'model_id': None, 'name': 'Seca-Roupa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1521,8 +1473,8 @@ # name: test_devices[da_wm_wd_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1543,7 +1495,6 @@ 'model_id': 'DV90DB8845GHU2', 'name': 'Trockner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP1_21_COMMON_30250508', 'via_device_id': None, @@ -1552,8 +1503,8 @@ # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1574,7 +1525,6 @@ 'model_id': None, 'name': 'Washer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', 'via_device_id': None, @@ -1583,8 +1533,8 @@ # name: test_devices[da_wm_wm_000001_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1605,7 +1555,6 @@ 'model_id': None, 'name': 'Washing Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, @@ -1614,8 +1563,8 @@ # name: test_devices[da_wm_wm_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1636,7 +1585,6 @@ 'model_id': None, 'name': 'Machine à Laver', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'DA_WM_TP1_21_COMMON_30240927', 'via_device_id': None, @@ -1645,8 +1593,8 @@ # name: test_devices[da_wm_wm_100001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1667,7 +1615,6 @@ 'model_id': None, 'name': 'Washer 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1676,8 +1623,8 @@ # name: test_devices[da_wm_wm_100002] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1698,7 +1645,6 @@ 'model_id': None, 'name': 'Washer 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1707,8 +1653,8 @@ # name: test_devices[ecobee_sensor] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1729,7 +1675,6 @@ 'model_id': None, 'name': 'Child Bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250206213001', 'via_device_id': None, @@ -1738,8 +1683,8 @@ # name: test_devices[ecobee_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1760,7 +1705,6 @@ 'model_id': None, 'name': 'Main Floor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250206151734', 'via_device_id': None, @@ -1769,8 +1713,8 @@ # name: test_devices[ecobee_thermostat_offline] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1791,7 +1735,6 @@ 'model_id': None, 'name': 'Downstairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '250308073247', 'via_device_id': None, @@ -1800,8 +1743,8 @@ # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1822,7 +1765,6 @@ 'model_id': None, 'name': 'Fake fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1831,8 +1773,8 @@ # name: test_devices[fibaro_dimmer_2] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1853,7 +1795,6 @@ 'model_id': None, 'name': 'Dimmer entré 1 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1862,8 +1803,8 @@ # name: test_devices[gas_detector] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -1888,7 +1829,6 @@ 'model_id': None, 'name': 'Gas Detector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1897,8 +1837,8 @@ # name: test_devices[gas_meter] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1919,7 +1859,6 @@ 'model_id': None, 'name': 'Gas Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1928,8 +1867,8 @@ # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -1950,7 +1889,6 @@ 'model_id': None, 'name': 'Basement Exit Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1959,8 +1897,8 @@ # name: test_devices[generic_ef00_v1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -1985,7 +1923,6 @@ 'model_id': None, 'name': 'Thermostat Küche', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1994,8 +1931,8 @@ # name: test_devices[generic_fan_3_speed] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2016,7 +1953,6 @@ 'model_id': None, 'name': 'Bedroom Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2025,8 +1961,8 @@ # name: test_devices[heatit_zpushwall] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2047,7 +1983,6 @@ 'model_id': None, 'name': 'Livingroom smart switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2056,8 +1991,8 @@ # name: test_devices[heatit_ztrm3_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2078,7 +2013,6 @@ 'model_id': None, 'name': 'Hall thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2087,8 +2021,8 @@ # name: test_devices[hue_color_temperature_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2109,7 +2043,6 @@ 'model_id': None, 'name': 'Bathroom spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.122.2', 'via_device_id': None, @@ -2118,8 +2051,8 @@ # name: test_devices[hue_rgbw_color_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2140,7 +2073,6 @@ 'model_id': None, 'name': 'Standing light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.122.2', 'via_device_id': None, @@ -2149,8 +2081,8 @@ # name: test_devices[hw_q80r_soundbar] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2171,7 +2103,6 @@ 'model_id': None, 'name': 'Soundbar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'HW-Q80RWWB-1012.6', 'via_device_id': None, @@ -2180,8 +2111,8 @@ # name: test_devices[ikea_kadrilj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2206,7 +2137,6 @@ 'model_id': None, 'name': 'Kitchen IKEA KADRILJ Window blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2215,8 +2145,8 @@ # name: test_devices[ikea_leak_battery] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2237,7 +2167,6 @@ 'model_id': None, 'name': 'Waschkeller Feuchtigkeitssensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.11', 'via_device_id': , @@ -2246,8 +2175,8 @@ # name: test_devices[ikea_motion_illuminance_battery] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2268,7 +2197,6 @@ 'model_id': None, 'name': 'Gaderobe Bewegungsmelder', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.7', 'via_device_id': , @@ -2277,8 +2205,8 @@ # name: test_devices[ikea_plug_powermeter] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2303,7 +2231,6 @@ 'model_id': None, 'name': 'IKEA Plug Powermeter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , @@ -2312,8 +2239,8 @@ # name: test_devices[im_smarttag2_ble_uwb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2334,7 +2261,6 @@ 'model_id': None, 'name': 'SmartTag+ black', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2343,8 +2269,8 @@ # name: test_devices[im_speaker_ai_0001] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2365,7 +2291,6 @@ 'model_id': None, 'name': 'Galaxy Home Mini', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'V310XXU1AWK1', 'via_device_id': None, @@ -2374,8 +2299,8 @@ # name: test_devices[iphone] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2396,7 +2321,6 @@ 'model_id': None, 'name': 'iPhone', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2405,8 +2329,8 @@ # name: test_devices[lumi] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2431,7 +2355,6 @@ 'model_id': None, 'name': 'Outdoor Temp', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2440,8 +2363,8 @@ # name: test_devices[meross_plug] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2462,7 +2385,6 @@ 'model_id': None, 'name': 'Waschkeller Trockner Plug', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '510802250905784', 'sw_version': '9.3.26', 'via_device_id': , @@ -2471,8 +2393,8 @@ # name: test_devices[multipurpose_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2497,7 +2419,6 @@ 'model_id': None, 'name': 'Deck Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2506,8 +2427,8 @@ # name: test_devices[sensi_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2528,7 +2449,6 @@ 'model_id': None, 'name': 'Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '6004971003', 'via_device_id': None, @@ -2537,8 +2457,8 @@ # name: test_devices[sensibo_airconditioner_1] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2559,7 +2479,6 @@ 'model_id': None, 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SKY40147', 'via_device_id': None, @@ -2568,8 +2487,8 @@ # name: test_devices[siemens_washer] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2590,7 +2509,6 @@ 'model_id': None, 'name': 'Wasmachine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2599,8 +2517,8 @@ # name: test_devices[smart_plug] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2625,7 +2543,6 @@ 'model_id': None, 'name': 'Arlo Beta Basestation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2634,8 +2551,8 @@ # name: test_devices[sonos_player] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2656,7 +2573,6 @@ 'model_id': None, 'name': 'Elliots Rum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2665,8 +2581,8 @@ # name: test_devices[tesla_powerwall] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2687,7 +2603,6 @@ 'model_id': None, 'name': 'Powerwall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2696,8 +2611,8 @@ # name: test_devices[tplink_p110] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2718,7 +2633,6 @@ 'model_id': None, 'name': 'Spülmaschine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.3.1 Build 240621 Rel.162048', 'via_device_id': None, @@ -2727,8 +2641,8 @@ # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2749,7 +2663,6 @@ 'model_id': None, 'name': 'Soundbar Living', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, @@ -2758,8 +2671,8 @@ # name: test_devices[vd_network_audio_003s] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2780,7 +2693,6 @@ 'model_id': None, 'name': 'Soundbar 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'SAT-MT8532D24WWC-1016.0', 'via_device_id': None, @@ -2789,8 +2701,8 @@ # name: test_devices[vd_sensor_light_2023] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2811,7 +2723,6 @@ 'model_id': None, 'name': 'Light Sensor - 55" The Frame', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'latest', 'via_device_id': None, @@ -2820,8 +2731,8 @@ # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2842,7 +2753,6 @@ 'model_id': None, 'name': '[TV] Samsung 8 Series (49)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, @@ -2851,8 +2761,8 @@ # name: test_devices[virtual_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2873,7 +2783,6 @@ 'model_id': None, 'name': 'virtual thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2882,8 +2791,8 @@ # name: test_devices[virtual_valve] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2904,7 +2813,6 @@ 'model_id': None, 'name': 'volvo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2913,8 +2821,8 @@ # name: test_devices[virtual_water_sensor] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ }), @@ -2935,7 +2843,6 @@ 'model_id': None, 'name': 'virtual water sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2944,8 +2851,8 @@ # name: test_devices[yale_push_button_deadbolt_lock] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -2970,7 +2877,6 @@ 'model_id': None, 'name': 'Basement Door Lock', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2979,8 +2885,8 @@ # name: test_hub_via_device DeviceRegistryEntrySnapshot({ 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( @@ -3009,7 +2915,6 @@ 'model_id': None, 'name': 'Home Hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '000.055.00005', 'via_device_id': None, diff --git a/tests/components/smarty/snapshots/test_init.ambr b/tests/components/smarty/snapshots/test_init.ambr index 109fd649533e..4bab38a90090 100644 --- a/tests/components/smarty/snapshots/test_init.ambr +++ b/tests/components/smarty/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '127', 'via_device_id': None, diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 752b045cffa3..792c3d8dcadb 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch +from pysmlight import Radio from pysmlight.exceptions import SmlightAuthError from pysmlight.models import BleFeatures from pysmlight.sse import sseClient @@ -143,6 +144,7 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: api.actions.ambilight = AsyncMock(return_value=True) api.cmds = AsyncMock(spec_set=CmdWrapper) api.set_toggle = AsyncMock() + api.set_ble_proxy = AsyncMock(return_value=True) api.sse = MagicMock(spec_set=sseClient) yield api @@ -151,7 +153,14 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: MOCK_ULTIMA = Info( MAC="AA:BB:CC:DD:EE:FF", model="SLZB-Ultima3", + addons={"zwave": True}, ble=BleFeatures(ble_enabled=True, proxy_enabled=True), + u_device=True, + radios=[ + Radio(zb_type=0, chip_index=0), + Radio(zb_type=1, chip_index=1), + Radio(zb_type=5, chip_index=2), + ], ) diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 7f46daef13cd..3a78736a682e 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Mock Title', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'core: v2.3.6 / zigbee: 20240314', 'via_device_id': None, diff --git a/tests/components/smlight/test_bluetooth.py b/tests/components/smlight/test_bluetooth.py index eefe639ddf65..87c91b2be06d 100644 --- a/tests/components/smlight/test_bluetooth.py +++ b/tests/components/smlight/test_bluetooth.py @@ -6,6 +6,9 @@ from pysmlight import Info from pysmlight.models import BleFeatures import pytest +from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.smlight.bluetooth import get_ble_scanner_mode +from homeassistant.components.smlight.const import CONF_BLE_SCANNER_MODE, BLEScannerMode from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -34,6 +37,9 @@ async def test_bluetooth_scanner_lifecycle( ) client_data = mock_connect_scanner.return_value + client_data.scanner.async_set_scanning_mode.assert_called_once_with( + BluetoothScanningMode.AUTO + ) client_data.client.start.assert_called_once() mock_bluetooth_scanner.assert_called_once_with( hass, @@ -85,5 +91,108 @@ async def test_bluetooth_not_started_for_classic_device( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + entry = mock_config_entry + assert entry.state is ConfigEntryState.LOADED + mock_connect_scanner.assert_not_called() + + coordinator = entry.runtime_data.data + assert coordinator.data.info.ble is None + + +@pytest.mark.parametrize( + "scanner_mode", + [ + "auto", + "active", + "passive", + ], +) +async def test_bluetooth_scanner_options( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect_scanner: MagicMock, + mock_ultima_client: MagicMock, + scanner_mode: str, +) -> None: + """Test SLZB BLE scanner options when proxy is expected to start.""" + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + options={CONF_BLE_SCANNER_MODE: scanner_mode}, + ) + 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_connect_scanner.assert_called_once_with( + source=mock_config_entry.unique_id, + name=mock_config_entry.title, + host=mock_config_entry.data[CONF_HOST], + port=5050, + ) + client_data = mock_connect_scanner.return_value + client_data.scanner.async_set_scanning_mode.assert_called_once_with( + BluetoothScanningMode(scanner_mode) + ) + mock_ultima_client.set_ble_proxy.assert_not_called() + + +async def test_bluetooth_scanner_options_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect_scanner: MagicMock, + mock_ultima_client: MagicMock, +) -> None: + """Test SLZB BLE scanner options when the scanner mode is disabled.""" + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + options={CONF_BLE_SCANNER_MODE: "disabled"}, + ) + 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_connect_scanner.assert_not_called() + mock_ultima_client.set_ble_proxy.assert_not_called() + + +async def test_bluetooth_scanner_options_device_proxy_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect_scanner: MagicMock, + mock_ultima_client: MagicMock, +) -> None: + """Test SLZB BLE scanner options when device proxy is disabled on the hardware.""" + mock_ultima_client.get_info.side_effect = None + mock_ultima_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-Ultima3", + ble=BleFeatures(ble_enabled=True, proxy_enabled=False), + ) + + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + options={CONF_BLE_SCANNER_MODE: "passive"}, + ) + 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_connect_scanner.assert_not_called() + mock_ultima_client.set_ble_proxy.assert_not_called() + + +async def test_get_ble_scanner_mode_no_ble( + hass: HomeAssistant, + mock_smlight_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test get_ble_scanner_mode when BLE is not supported by the hardware.""" + info = await mock_smlight_client.get_info() + assert get_ble_scanner_mode(mock_config_entry, info) is BLEScannerMode.DISABLED diff --git a/tests/components/smlight/test_button.py b/tests/components/smlight/test_button.py index ac66324e8938..b51df08cfffd 100644 --- a/tests/components/smlight/test_button.py +++ b/tests/components/smlight/test_button.py @@ -156,23 +156,15 @@ async def test_remove_router_reconnect( ("zigbee_flash_mode", 1), ], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_ultima_client") async def test_multi_radio_buttons_u_device( hass: HomeAssistant, entity_registry: er.EntityRegistry, key: str, idx: int, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, ) -> None: """Test per-radio restart and flash mode buttons on a u-device.""" - mock_smlight_client.get_info.side_effect = None - info = Info.from_dict( - await async_load_json_object_fixture(hass, "info-MR1.json", DOMAIN) - ) - info.u_device = True - mock_smlight_client.get_info.return_value = info - await setup_integration(hass, mock_config_entry) unique_id_suffix = f"_{idx}" if idx else "" @@ -200,16 +192,9 @@ async def test_multi_radio_press_calls_idx( method: str, idx: int, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, + mock_ultima_client: MagicMock, ) -> None: """Test pressing per-radio buttons passes the correct idx to the command.""" - mock_smlight_client.get_info.side_effect = None - info = Info.from_dict( - await async_load_json_object_fixture(hass, "info-MR1.json", DOMAIN) - ) - info.u_device = True - mock_smlight_client.get_info.return_value = info - await setup_integration(hass, mock_config_entry) unique_id_suffix = f"_{idx}" if idx else "" @@ -217,7 +202,7 @@ async def test_multi_radio_press_calls_idx( entity_id = entity_registry.async_get_entity_id(BUTTON_DOMAIN, DOMAIN, unique_id) assert entity_id is not None - mock_method = getattr(mock_smlight_client.cmds, method) + mock_method = getattr(mock_ultima_client.cmds, method) await hass.services.async_call( BUTTON_DOMAIN, @@ -249,29 +234,66 @@ async def test_multi_radio_buttons_shared_non_u_device( ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_ultima_client") async def test_router_button_with_3_radios( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, ) -> None: """Test creation of router buttons for device with 3 radios.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info( - MAC="AA:BB:CC:DD:EE:FF", - radios=[ - Radio(zb_type=0, chip_index=0), - Radio(zb_type=1, chip_index=1), - Radio(zb_type=0, chip_index=2), - ], - ) await setup_integration(hass, mock_config_entry) entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entities) == 4 + router_entities = [e for e in entities if "reconnect_zigbee_router" in e.unique_id] + assert len(router_entities) == 1 entity = entity_registry.async_get("button.mock_title_reconnect_zigbee_router") assert entity is not None + assert entity.unique_id == "aa:bb:cc:dd:ee:ff-reconnect_zigbee_router_1" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_ultima_client") +async def test_zwave_radio_naming( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Z-Wave radio (index 2) button entity naming.""" + await setup_integration(hass, mock_config_entry) + + restart_btn = hass.states.get("button.mock_title_z_wave_restart") + assert restart_btn is not None + assert restart_btn.name == "Mock Title Z-Wave restart" + + flash_btn = hass.states.get("button.mock_title_z_wave_flash_mode") + assert flash_btn is not None + assert flash_btn.name == "Mock Title Z-Wave flash mode" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_zwave_radio_naming_mr10( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test Z-Wave radio (index 1) button entity naming for MR10/MRW10.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-MRW10", + u_device=True, + radios=[ + Radio(zb_type=0, chip_index=0), + Radio(zb_type=5, chip_index=1), + ], + ) + await setup_integration(hass, mock_config_entry) + + restart_btn = hass.states.get("button.mock_title_z_wave_restart") + assert restart_btn is not None + assert restart_btn.name == "Mock Title Z-Wave restart" + + flash_btn = hass.states.get("button.mock_title_z_wave_flash_mode") + assert flash_btn is not None + assert flash_btn.name == "Mock Title Z-Wave flash mode" diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index c4f5d3cb3331..b93acf44e926 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -891,3 +891,187 @@ async def test_reconfigure_auth_error( assert mock_config_entry.unique_id == "aa:bb:cc:dd:ee:ff" assert len(mock_smlight_client.authenticate.mock_calls) == 2 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_options_flow( + hass: HomeAssistant, + mock_ultima_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow does not call the hardware API when switching between non-disabled modes.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_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={ + "ble_scanner_mode": "passive", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ble_scanner_mode": "passive", + } + mock_ultima_client.set_ble_proxy.assert_not_called() + + +async def test_options_flow_enable_from_disabled( + hass: HomeAssistant, + mock_ultima_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow toggles the remote adapter on when transitioning from disabled.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, options={"ble_scanner_mode": "disabled"} + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_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={ + "ble_scanner_mode": "passive", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ble_scanner_mode": "passive", + } + mock_ultima_client.set_ble_proxy.assert_called_once_with(True) + + +async def test_options_flow_error( + hass: HomeAssistant, + mock_ultima_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow error handling when disabling set_ble_proxy fails and succeeds on retry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + + mock_ultima_client.set_ble_proxy.side_effect = SmlightConnectionError + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "ble_scanner_mode": "disabled", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_ultima_client.set_ble_proxy.side_effect = None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "ble_scanner_mode": "disabled", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ble_scanner_mode": "disabled", + } + mock_ultima_client.set_ble_proxy.assert_called_with(False) + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_options_flow_no_ble( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow for device without BLE support redirects to no_settings step.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "no_settings" + assert result["description_placeholders"] == {"model": "SLZB-06p7"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {} + + +async def test_options_flow_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow returns cannot_connect error when config entry has not finished loading.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_options_flow_auth_error( + hass: HomeAssistant, + mock_ultima_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow auth error handling when disabling set_ble_proxy raises SmlightAuthError and reauth completes.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + + mock_ultima_client.set_ble_proxy.side_effect = SmlightAuthError + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "ble_scanner_mode": "disabled", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + progress_flows = [ + flow + for flow in hass.config_entries.flow.async_progress() + if flow["handler"] == DOMAIN and flow["context"].get("source") == "reauth" + ] + assert len(progress_flows) == 1 + reauth_flow = progress_flows[0] + + mock_ultima_client.authenticate.side_effect = None + mock_ultima_client.check_auth_needed.return_value = True + + reauth_result = await hass.config_entries.flow.async_configure( + reauth_flow["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + assert reauth_result["type"] is FlowResultType.ABORT + assert reauth_result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_HOST: MOCK_HOST, + } diff --git a/tests/components/smlight/test_infrared.py b/tests/components/smlight/test_infrared.py index 4b1b4b96f477..012cae971033 100644 --- a/tests/components/smlight/test_infrared.py +++ b/tests/components/smlight/test_infrared.py @@ -3,15 +3,20 @@ from unittest.mock import MagicMock from infrared_protocols.commands import Command +from pysmlight.const import Events as SmEvents from pysmlight.exceptions import SmlightError from pysmlight.models import IRPayload import pytest -from homeassistant.components.infrared import async_send_command +from homeassistant.components.infrared import ( + async_send_command, + async_subscribe_receiver, +) from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from . import get_mock_event_function from .conftest import setup_integration from tests.common import MockConfigEntry @@ -40,10 +45,13 @@ async def test_infrared_setup_ultima( mock_config_entry: MockConfigEntry, mock_ultima_client: MagicMock, ) -> None: - """Test infrared entity is created for Ultima devices.""" + """Test infrared entities are created for Ultima devices.""" await setup_integration(hass, mock_config_entry) - state = hass.states.get("infrared.mock_title_ir_emitter") + state = hass.states.get("infrared.mock_title_infrared_emitter") + assert state is not None + + state = hass.states.get("infrared.mock_title_infrared_receiver") assert state is not None @@ -52,10 +60,13 @@ async def test_infrared_not_created_non_ultima( mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, ) -> None: - """Test infrared entity is not created for non-Ultima devices.""" + """Test infrared entities are not created for non-Ultima devices.""" await setup_integration(hass, mock_config_entry) - state = hass.states.get("infrared.mock_title_ir_emitter") + state = hass.states.get("infrared.mock_title_infrared_emitter") + assert state is None + + state = hass.states.get("infrared.mock_title_infrared_receiver") assert state is None @@ -67,7 +78,7 @@ async def test_infrared_send_command( """Test sending IR command.""" await setup_integration(hass, mock_config_entry) - entity_id = "infrared.mock_title_ir_emitter" + entity_id = "infrared.mock_title_infrared_emitter" state = hass.states.get(entity_id) assert state is not None @@ -90,7 +101,7 @@ async def test_infrared_send_command_error( """Test connection error handling.""" await setup_integration(hass, mock_config_entry) - entity_id = "infrared.mock_title_ir_emitter" + entity_id = "infrared.mock_title_infrared_emitter" state = hass.states.get(entity_id) assert state is not None @@ -113,7 +124,7 @@ async def test_infrared_send_empty_command_error( """Test ValueError from pysmlight is surfaced as HomeAssistantError.""" await setup_integration(hass, mock_config_entry) - entity_id = "infrared.mock_title_ir_emitter" + entity_id = "infrared.mock_title_infrared_emitter" state = hass.states.get(entity_id) assert state is not None @@ -137,7 +148,7 @@ async def test_infrared_state_updated_after_send( """Test that entity state is updated with a timestamp after a successful send.""" await setup_integration(hass, mock_config_entry) - entity_id = "infrared.mock_title_ir_emitter" + entity_id = "infrared.mock_title_infrared_emitter" state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_UNKNOWN @@ -146,3 +157,33 @@ async def test_infrared_state_updated_after_send( state = hass.states.get(entity_id) assert state.state == "2025-09-03T22:00:00.000+00:00" + + +@pytest.mark.freeze_time("2025-09-03T22:00:00+00:00") +async def test_infrared_receiver_event( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ultima_client: MagicMock, +) -> None: + """Test infrared receiver receives event and updates state.""" + await setup_integration(hass, mock_config_entry) + + entity_id = "infrared.mock_title_infrared_receiver" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + signals = [] + async_subscribe_receiver(hass, entity_id, signals.append) + + event_function = get_mock_event_function(mock_ultima_client, SmEvents.IR_CODE) + assert event_function is not None + + event_function([9000, 4500, 560, 1690]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "2025-09-03T22:00:00.000+00:00" + + assert len(signals) == 1 + assert signals[0].timings == [9000, 4500, 560, 1690] diff --git a/tests/components/smlight/test_sensor.py b/tests/components/smlight/test_sensor.py index 75bd5861f819..7f95c5bb1cb9 100644 --- a/tests/components/smlight/test_sensor.py +++ b/tests/components/smlight/test_sensor.py @@ -14,11 +14,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import setup_integration -from tests.common import ( - MockConfigEntry, - async_load_json_object_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, snapshot_platform pytestmark = [ pytest.mark.usefixtures( @@ -95,16 +91,12 @@ async def test_zigbee2_temp_sensor( assert state.state == "20.45" +@pytest.mark.usefixtures("mock_ultima_client") async def test_zigbee_type_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_smlight_client: MagicMock, ) -> None: - """Test for zigbee type sensor with second radio.""" - mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info.from_dict( - await async_load_json_object_fixture(hass, "info-MR1.json", DOMAIN) - ) + """Test for zigbee type sensor with multiple radios.""" await setup_integration(hass, mock_config_entry) state = hass.states.get("sensor.mock_title_zigbee_type") @@ -115,6 +107,9 @@ async def test_zigbee_type_sensors( assert state assert state.state == "router" + # Radio 3 is Z-Wave, so no Zigbee-type sensor should be created + assert hass.states.get("sensor.mock_title_zigbee_type_3") is None + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_psram_usage_sensor( diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index acd9cfe197e3..1945584df820 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -391,3 +391,40 @@ async def test_update_blank_release_notes( result = await ws_client.receive_json() await hass.async_block_till_done() assert result["result"] is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_ultima_client") +async def test_zwave_update_naming( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Z-Wave radio (index 2) update entity naming.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("update.mock_title_z_wave_firmware") + assert state is not None + assert state.name == "Mock Title Z-Wave firmware" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_zwave_update_naming_mr10( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test Z-Wave radio (index 1) update entity naming for MR10/MRW10.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-MRW10", + u_device=True, + radios=[ + Radio(zb_type=0, chip_index=0), + Radio(zb_type=5, chip_index=1), + ], + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("update.mock_title_z_wave_firmware") + assert state is not None + assert state.name == "Mock Title Z-Wave firmware" diff --git a/tests/components/smtp/conftest.py b/tests/components/smtp/conftest.py index 4af1b2e703e5..27336b6a7cb6 100644 --- a/tests/components/smtp/conftest.py +++ b/tests/components/smtp/conftest.py @@ -51,11 +51,13 @@ def mock_smtp() -> Generator[MagicMock]: with ( patch( - "homeassistant.components.smtp.helpers.smtplib.SMTP", autospec=True + "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True ) as mock_client, + patch("homeassistant.components.smtp.helpers.smtplib.SMTP", new=mock_client), patch("homeassistant.components.smtp.config_flow.SMTP", new=mock_client), ): client = mock_client.return_value + client.cls = mock_client yield client @@ -70,17 +72,6 @@ def mock_make_msgid() -> Generator[None]: yield -@pytest.fixture(name="smtp_ssl") -def mock_smtp_ssl() -> Generator[MagicMock]: - """Mock SMTP.""" - - with patch( - "homeassistant.components.smtp.config_flow.SMTP_SSL", autospec=True - ) as mock_client: - client = mock_client.return_value - yield client - - @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: """Mock smtp configuration entry.""" @@ -89,7 +80,7 @@ def mock_config_entry() -> MockConfigEntry: title="Home Assistant", data=USER_INPUT, options={ - CONF_TIMEOUT: 5, + CONF_TIMEOUT: 1312, }, entry_id="123456789", subentries_data=[ diff --git a/tests/components/smtp/test_config_flow.py b/tests/components/smtp/test_config_flow.py index 0767acbe0546..2708daee3e90 100644 --- a/tests/components/smtp/test_config_flow.py +++ b/tests/components/smtp/test_config_flow.py @@ -11,9 +11,16 @@ from homeassistant.components.smtp.const import ( CONF_ENCRYPTION, CONF_SENDER_NAME, DOMAIN, + SECTION_OPTIONS, SUBENTRY_TYPE_RECIPIENT, ) -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, FlowType +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigEntryState, + ConfigSubentryData, + FlowType, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -24,16 +31,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er from .conftest import USER_INPUT from tests.common import MockConfigEntry -@pytest.mark.usefixtures("smtp", "smtp_ssl") @pytest.mark.parametrize("encryption", ["tls", "starttls"]) async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str + hass: HomeAssistant, mock_setup_entry: AsyncMock, encryption: str, smtp: MagicMock ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -47,6 +54,7 @@ async def test_form( { **USER_INPUT, CONF_ENCRYPTION: encryption, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, }, ) await hass.async_block_till_done() @@ -57,6 +65,7 @@ async def test_form( **USER_INPUT, CONF_ENCRYPTION: encryption, } + assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 await hass.async_block_till_done(wait_background_tasks=True) @@ -72,6 +81,8 @@ async def test_form( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Recipient" assert result["unique_id"] == "recipient@example.com" + assert smtp.cls.call_args[0] == ("mail.example.com", 587) + assert smtp.cls.call_args[1]["timeout"] == 60 @pytest.mark.usefixtures("smtp") @@ -91,7 +102,10 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) await hass.async_block_till_done() @@ -127,7 +141,10 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) assert result["type"] is FlowResultType.FORM @@ -137,13 +154,17 @@ async def test_form_errors( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + { + **USER_INPUT, + SECTION_OPTIONS: {CONF_TIMEOUT: 60}, + }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home Assistant" assert result["data"] == USER_INPUT + assert result["options"] == {CONF_TIMEOUT: 60} assert len(mock_setup_entry.mock_calls) == 1 @@ -208,9 +229,8 @@ async def test_options_flow( } -@pytest.mark.usefixtures("smtp") async def test_form_reconfigure( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock ) -> None: """Test reconfigure flow.""" @@ -243,6 +263,7 @@ async def test_form_reconfigure( } assert len(hass.config_entries.async_entries()) == 1 + smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) @pytest.mark.usefixtures("smtp") @@ -351,8 +372,9 @@ async def test_form_reconfigure_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("smtp") -async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_form_reauth( + hass: HomeAssistant, config_entry: MockConfigEntry, smtp: MagicMock +) -> None: """Test reauth flow.""" config_entry.add_to_hass(hass) @@ -381,6 +403,7 @@ async def test_form_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) - } assert len(hass.config_entries.async_entries()) == 1 + smtp.cls.assert_called_with("mail.example.com", 587, timeout=1312) @pytest.mark.parametrize( @@ -442,3 +465,136 @@ async def test_form_reauth_errors( CONF_PASSWORD: "new-password", } assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("smtp") +async def test_form_subentry_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test subentry reconfigure flow.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + assert (entity := entity_registry.async_get("notify.home_assistant_recipient")) + assert entity.unique_id == "123456789_recipient@example.com" + + result = await config_entry.start_subentry_reconfigure_flow(hass, "ABCDEF") + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "changed@example.com"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.subentries["ABCDEF"].unique_id == "changed@example.com" + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) + assert (entity := entity_registry.async_get("notify.home_assistant_recipient")) + assert entity.unique_id == "123456789_changed@example.com" + + +@pytest.mark.usefixtures("smtp") +async def test_form_subentry_reconfigure_already_configured( + hass: HomeAssistant, +) -> None: + """Test we abort subentry reconfigure flow when already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home Assistant", + data=USER_INPUT, + options={ + CONF_TIMEOUT: 5, + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_id="ABCDEF", + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title="Recipient", + unique_id="recipient@example.com", + ), + ConfigSubentryData( + data={}, + subentry_id="GHIJKL", + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title="Recipient2", + unique_id="changed@example.com", + ), + ], + ) + 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, "ABCDEF") + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "changed@example.com"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("smtp") +async def test_form_subentry_reconfigure_updates_title( + hass: HomeAssistant, +) -> None: + """Test subentry reconfigure flow updates subentry title if it matches email.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Home Assistant", + data=USER_INPUT, + options={ + CONF_TIMEOUT: 5, + }, + entry_id="123456789", + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_id="ABCDEF", + subentry_type=SUBENTRY_TYPE_RECIPIENT, + title="recipient@example.com", + unique_id="recipient@example.com", + ) + ], + ) + + 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, "ABCDEF") + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "changed@example.com"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.subentries["ABCDEF"].unique_id == "changed@example.com" + assert config_entry.subentries["ABCDEF"].title == "changed@example.com" diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 77b1569b56a3..510013aa53a4 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -4,6 +4,7 @@ from pathlib import Path import re from smtplib import ( SMTPAuthenticationError, + SMTPException, SMTPHeloError, SMTPSenderRefused, SMTPServerDisconnected, @@ -16,6 +17,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.notify import ( ATTR_MESSAGE, + ATTR_TARGET, DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, ) @@ -278,6 +280,7 @@ async def test_notify_send_message( "Content-Transfer-Encoding: 7bit\n" "Subject: Home Assistant\n" "From: Home Assistant \n" + "To: Recipient \n" "X-Mailer: Home Assistant\n" "Date: Sat, 02 May 2026 20:09:37 -0700\n" "Message-Id: <177777777700.12345.12345678901234567890@mock>\n\n" @@ -359,3 +362,35 @@ async def test_notify_retry_on_disconnect_with_broken_quit( ) assert smtp.sendmail.call_count == 2 + + +@pytest.mark.parametrize("exception", [SMTPServerDisconnected, SMTPException]) +async def test_legacy_notify_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + smtp: MagicMock, + exception: Exception, +) -> None: + """Test legacy notify action raises when retries are exhausted.""" + + 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 + + smtp.sendmail.side_effect = exception + + with pytest.raises(HomeAssistantError) as e: + await hass.services.async_call( + NOTIFY_DOMAIN, + "home_assistant", + { + ATTR_TARGET: ["recipient@example.com"], + ATTR_MESSAGE: "Hello World", + }, + blocking=True, + ) + + assert e.value.translation_key == "send_mail_connection_error" + assert smtp.sendmail.call_count == 2 diff --git a/tests/components/snooz/snapshots/test_init.ambr b/tests/components/snooz/snapshots/test_init.ambr index ef893776b22d..2428bdd78c12 100644 --- a/tests/components/snooz/snapshots/test_init.ambr +++ b/tests/components/snooz/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': None, 'name_by_user': None, - 'primary_config_entry': None, 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index df4a23dd27eb..78ba927837a2 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -8,8 +8,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from soco import SoCo -from soco.alarms import Alarms +from soco import SoCo, soco_reset from soco.data_structures import ( DidlFavorite, DidlMusicTrack, @@ -167,15 +166,12 @@ async def async_autosetup_sonos(async_setup_sonos): await async_setup_sonos() -def reset_sonos_alarms(alarm_event: SonosMockEvent) -> None: - """Reset the Sonos alarms to a known state.""" - sonos_alarms = Alarms() - sonos_alarms.alarms = {} - sonos_alarms._last_zone_used = None - sonos_alarms._last_alarm_list_version = None - sonos_alarms.last_uid = None - sonos_alarms.last_id = 0 - alarm_event.variables["alarm_list_version"] = "RINCON_test:0" +@pytest.fixture(autouse=True) +def reset_sonos(): + """Reset soco state before and after each test.""" + soco_reset() + yield + soco_reset() @pytest.fixture @@ -186,7 +182,6 @@ def async_setup_sonos( async def _wrapper(): config_entry.add_to_hass(hass) - reset_sonos_alarms(alarm_event) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) await fire_zgs_event() @@ -953,7 +948,6 @@ async def sonos_setup_two_speakers( """Set up home assistant with two Sonos Speakers.""" soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") - reset_sonos_alarms(alarm_event) await async_setup_component( hass, diff --git a/tests/components/sonos/snapshots/test_diagnostics.ambr b/tests/components/sonos/snapshots/test_diagnostics.ambr index 9e3dfcb47e79..a4b1500de962 100644 --- a/tests/components/sonos/snapshots/test_diagnostics.ambr +++ b/tests/components/sonos/snapshots/test_diagnostics.ambr @@ -20,6 +20,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', @@ -112,6 +113,7 @@ 'enabled_entities': list([ 'binary_sensor.zone_a_charging', 'binary_sensor.zone_a_microphone', + 'button.zone_a_cancel_announcement', 'media_player.zone_a', 'number.zone_a_audio_delay', 'number.zone_a_balance', diff --git a/tests/components/sonos/test_button.py b/tests/components/sonos/test_button.py new file mode 100644 index 000000000000..60a2617bcd8c --- /dev/null +++ b/tests/components/sonos/test_button.py @@ -0,0 +1,143 @@ +"""Tests for the Sonos button platform.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from sonos_websocket import CLIP_ID_KEY +from sonos_websocket.exception import SonosWebsocketError + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +CANCEL_ANNOUNCEMENT_BUTTON = "button.zone_a_cancel_announcement" + + +async def _announce_clip(hass: HomeAssistant, content_id: str) -> None: + """Play an announcement clip to set the active clip id.""" + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: content_id, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + + +async def test_cancel_announcement_no_prior( + hass: HomeAssistant, + async_autosetup_sonos, +) -> None: + """Test cancelling when no announcement has been played.""" + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +async def test_cancel_announcement( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling a currently playing announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock(return_value=[{"success": 1}, {}]) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + sonos_websocket.cancel_clip.assert_called_once_with("clip-123") + + +async def test_cancel_announcement_no_clip_id_from_announce_response( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, +) -> None: + """Test cancelling fails when the announce response has no clip ID.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [{"success": 1}, None] + await _announce_clip(hass, content_id) + + with pytest.raises( + ServiceValidationError, match="No active announcement to cancel" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("cancel_clip_side_effect", "cancel_clip_return", "error_match"), + [ + pytest.param( + SonosWebsocketError("Connection lost"), + None, + "Failed to reach Sonos speaker for announcement: Connection lost", + id="websocket_error", + ), + pytest.param( + None, + [{"success": 0}, {}], + "Cancelling announcement failed", + id="non_success_response", + ), + ], +) +async def test_cancel_announcement_errors( + hass: HomeAssistant, + async_autosetup_sonos, + sonos_websocket, + cancel_clip_side_effect: SonosWebsocketError | None, + cancel_clip_return: list[dict[str, Any]] | None, + error_match: str, +) -> None: + """Test error handling when cancelling an announcement.""" + content_id = "http://10.0.0.1:8123/local/sounds/doorbell.mp3" + sonos_websocket.play_clip.return_value = [ + {"success": 1}, + {CLIP_ID_KEY: "clip-123"}, + ] + await _announce_clip(hass, content_id) + + sonos_websocket.cancel_clip = AsyncMock( + side_effect=cancel_clip_side_effect, + return_value=cancel_clip_return, + ) + with pytest.raises(HomeAssistantError, match=error_match): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: CANCEL_ANNOUNCEMENT_BUTTON}, + blocking=True, + ) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 0c655de07492..f718428acab6 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus +from itertools import chain, repeat import logging from unittest.mock import Mock, PropertyMock, patch @@ -252,6 +253,15 @@ class _MockSoCoVisibleZones(MockSoCo): return self.vz_return +class _MockSoCoUidError(MockSoCo): + """Mock SoCo used for uid property error tests.""" + + @property + def visible_zones(self): + """Return no additional zones without touching uid lookup.""" + return set() + + async def _setup_hass(hass: HomeAssistant): await async_setup_component( hass, @@ -294,6 +304,65 @@ async def test_async_poll_manual_hosts_1( await hass.async_block_till_done(wait_background_tasks=True) +async def test_async_poll_manual_hosts_uid_oserror( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test uid lookup OSError skips host and logs warning.""" + soco_1 = soco_factory.cache_mock(_MockSoCoUidError(), "10.10.10.1", "Living Room") + soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + uid = soco_1.uid + + with ( + caplog.at_level(logging.WARNING), + patch.object( + type(soco_1), + "uid", + new_callable=PropertyMock, + create=True, + side_effect=chain([uid], repeat(OSError("uid unavailable"))), + ), + ): + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + assert f"Could not get Sonos uid from {soco_1.ip_address}" in caplog.text + + await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_async_poll_manual_hosts_uid_http_error( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test uid lookup HTTPError skips host.""" + resp = Response() + resp.status_code = HTTPStatus.FORBIDDEN + http_error = HTTPError(response=resp) + + soco_1 = soco_factory.cache_mock(_MockSoCoUidError(), "10.10.10.1", "Living Room") + soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + uid = soco_1.uid + + with patch.object( + type(soco_1), + "uid", + new_callable=PropertyMock, + create=True, + side_effect=chain([uid], repeat(http_error)), + ): + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + + await hass.async_block_till_done(wait_background_tasks=True) + + async def test_async_poll_manual_hosts_2( hass: HomeAssistant, soco_factory: SoCoMockFactory, diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 4dac45065347..18db04c55ca3 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -13,6 +13,7 @@ from soco.data_structures import ( DidlPlaylistContainer, SearchResult, ) +from soco.exceptions import SoCoUPnPException from sonos_websocket.exception import SonosWebsocketError from syrupy.assertion import SnapshotAssertion @@ -326,6 +327,69 @@ async def test_play_media_library_content_error( ) +@pytest.mark.parametrize( + ("error", "translation_key", "translation_placeholders"), + [ + pytest.param( + OSError("Network down"), + "call_failed", + { + "target": "media_player.zone_a", + "error": "Network down", + }, + id="generic-error", + ), + pytest.param( + SoCoUPnPException("UPnP Error 701 received", "701", ""), + "upnp_call_failed", + { + "target": "media_player.zone_a", + "error": "UPnP Error 701 received", + "error_code": "701", + }, + id="upnp-error", + ), + pytest.param( + SoCoUPnPException("UPnP Error 800 received", "800", ""), + "upnp_call_failed_music_service_unavailable", + { + "target": "media_player.zone_a", + "error": "UPnP Error 800 received", + "error_code": "800", + }, + id="upnp-error-800-music-service-unavailable", + ), + ], +) +async def test_play_media_error_translation( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + error: Exception, + translation_key: str, + translation_placeholders: dict[str, str], +) -> None: + """Test play_media surfaces translated error details for failures.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + soco_mock.play_uri.side_effect = error + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "track", + ATTR_MEDIA_CONTENT_ID: _track_url, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.REPLACE, + }, + blocking=True, + ) + + assert err.value.translation_key == translation_key + assert err.value.translation_placeholders == translation_placeholders + + _track_url = "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3" diff --git a/tests/components/splunk/test_config_flow.py b/tests/components/splunk/test_config_flow.py index dd5d33dd0cf0..d8b55f14afd9 100644 --- a/tests/components/splunk/test_config_flow.py +++ b/tests/components/splunk/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -59,6 +59,27 @@ async def test_user_flow_success( assert mock_hass_splunk.check.call_count == 2 +@pytest.mark.usefixtures("mock_hass_splunk") +async def test_user_flow_defaults_ssl_on(hass: HomeAssistant) -> None: + """Test a new entry defaults to SSL enabled when the field is omitted.""" + 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_TOKEN: "test-token-123", + CONF_HOST: "splunk.example.com", + CONF_PORT: 8088, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_SSL] is True + assert result["data"][CONF_VERIFY_SSL] is True + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -258,6 +279,20 @@ async def test_reconfigure_flow_success( assert mock_config_entry.title == "new-splunk.example.com:9088" +@pytest.mark.usefixtures("mock_hass_splunk") +async def test_reconfigure_flow_preserves_stored_ssl( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reconfigure pre-fills the stored SSL value, not the schema default.""" + 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"] == "reconfigure" + assert get_schema_suggested_value(result["data_schema"].schema, CONF_SSL) is False + + @pytest.mark.parametrize( ("side_effect", "error"), [ diff --git a/tests/components/splunk/test_init.py b/tests/components/splunk/test_init.py index 91148bcda291..f6fde25ab9a9 100644 --- a/tests/components/splunk/test_init.py +++ b/tests/components/splunk/test_init.py @@ -120,6 +120,27 @@ async def test_yaml_import_without_filter( assert entries[0].source == SOURCE_IMPORT +@pytest.mark.usefixtures("mock_setup_entry", "mock_hass_splunk") +async def test_yaml_import_defaults_ssl_on(hass: HomeAssistant) -> None: + """Test YAML import defaults to SSL enabled when the field is omitted.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_TOKEN: "test-token", + CONF_HOST: "localhost", + CONF_PORT: 8088, + } + }, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data[CONF_SSL] is True + + @pytest.mark.usefixtures("mock_setup_entry") async def test_yaml_with_filter( hass: HomeAssistant, mock_hass_splunk: AsyncMock diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr index 03678ef4ff83..c4e33fea3244 100644 --- a/tests/components/squeezebox/snapshots/test_init.ambr +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Test Player', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '', 'via_device_id': , @@ -37,8 +36,8 @@ # name: test_device_registry_server_merged DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -67,7 +66,6 @@ 'model_id': 'LMS', 'name': '1.1.1.1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '', 'via_device_id': , diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 3e1d1322e7dc..25dac8d3b058 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -763,6 +763,11 @@ async def test_bind_failure_skips_adapter( if self.source == ("2001:db8::", 0, 0, 1): raise OSError + # The UPnP server needs a presentation URL, which is derived from the + # instance URL. In production http is set up before ssdp; set an internal + # URL here so get_url() succeeds without relying on http being set up. + hass.config.internal_url = "http://10.10.10.10:8123" + SsdpListener.async_start = _async_start UpnpServer.async_start = _async_start await init_ssdp_component(hass) diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index 7dca15875689..3901f464d219 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -158,18 +158,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, statistics_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the statistics config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() @@ -181,15 +173,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.statistics.async_unload_entry", wraps=statistics.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -197,6 +186,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("sensor.my_statistics") + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -362,7 +354,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes statistics config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" statistics_config_entry = MockConfigEntry( data={}, @@ -382,22 +374,13 @@ async def test_migration_1_1( ) statistics_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(statistics_config_entry.entry_id) await hass.async_block_till_done() assert statistics_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") diff --git a/tests/components/steam_online/__init__.py b/tests/components/steam_online/__init__.py index 51c0d5a2a579..2208159f033e 100644 --- a/tests/components/steam_online/__init__.py +++ b/tests/components/steam_online/__init__.py @@ -1,6 +1,6 @@ """Tests for Steam integration.""" -from homeassistant.components.steam_online.const import CONF_ACCOUNT, CONF_ACCOUNTS +from homeassistant.components.steam_online.const import CONF_ACCOUNT from homeassistant.const import CONF_API_KEY API_KEY = "abc123" @@ -13,12 +13,3 @@ CONF_DATA = { CONF_API_KEY: API_KEY, CONF_ACCOUNT: ACCOUNT_1, } - -CONF_OPTIONS = {CONF_ACCOUNTS: {ACCOUNT_1: ACCOUNT_NAME_1}} - -CONF_OPTIONS_2 = { - CONF_ACCOUNTS: { - ACCOUNT_1: ACCOUNT_NAME_1, - ACCOUNT_2: ACCOUNT_NAME_2, - } -} diff --git a/tests/components/steam_online/conftest.py b/tests/components/steam_online/conftest.py index a71782eda153..44630ada87c4 100644 --- a/tests/components/steam_online/conftest.py +++ b/tests/components/steam_online/conftest.py @@ -5,9 +5,10 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from homeassistant.components.steam_online.const import DOMAIN +from homeassistant.components.steam_online.const import DOMAIN, SUBENTRY_TYPE_FRIEND +from homeassistant.config_entries import ConfigSubentryData -from . import ACCOUNT_1, CONF_DATA, CONF_OPTIONS +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_2, CONF_DATA from tests.common import MockConfigEntry, load_json_object_fixture, patch @@ -18,9 +19,16 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, data=CONF_DATA, - options=CONF_OPTIONS, unique_id=ACCOUNT_1, - version=2, + subentries_data=[ + ConfigSubentryData( + data={}, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ), + ], + version=3, ) @@ -42,6 +50,11 @@ def mock_steam_api() -> Generator[MagicMock]: "homeassistant.components.steam_online.config_flow.steam.api.interface" ) as mock_client, patch("homeassistant.components.steam_online.config_flow.steam.api.key.set"), + patch( + "homeassistant.components.steam_online.coordinator.steam.api.interface", + new=mock_client, + ), + patch("homeassistant.components.steam_online.coordinator.steam.api.key.set"), patch( "homeassistant.components.steam_online.config_flow.MAX_IDS_TO_REQUEST", 2 ), diff --git a/tests/components/steam_online/fixtures/GetPlayerSummaries.json b/tests/components/steam_online/fixtures/GetPlayerSummaries.json index ad3aaab0c5b6..c81c15a95065 100644 --- a/tests/components/steam_online/fixtures/GetPlayerSummaries.json +++ b/tests/components/steam_online/fixtures/GetPlayerSummaries.json @@ -14,10 +14,13 @@ "avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb", "lastlogoff": 1775409487, "personastate": 1, + "primaryclanid": "1234567890123456", + "timecreated": 1273953511, "realname": "John Dough", "personastateflags": 0, "gameextrainfo": "The Witcher: Enhanced Edition", - "gameid": "20900" + "gameid": "20900", + "lobbysteamid": "109775243377594361" }, { "steamid": "12345678912345678", @@ -31,6 +34,7 @@ "avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb", "lastlogoff": 1775409487, "personastate": 2, + "timecreated": 1303243041, "personastateflags": 0 } ] diff --git a/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json b/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json new file mode 100644 index 000000000000..dae685459f42 --- /dev/null +++ b/tests/components/steam_online/fixtures/GetPlayerSummariesSingle.json @@ -0,0 +1,22 @@ +{ + "response": { + "players": { + "player": [ + { + "steamid": "12345678912345678", + "communityvisibilitystate": 1, + "profilestate": 1, + "personaname": "testaccount2", + "profileurl": "https://steamcommunity.com/profiles/987654321/", + "avatar": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg", + "avatarmedium": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_medium.jpg", + "avatarfull": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg", + "avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb", + "lastlogoff": 1775409487, + "personastate": 2, + "personastateflags": 0 + } + ] + } + } +} diff --git a/tests/components/steam_online/snapshots/test_init.ambr b/tests/components/steam_online/snapshots/test_init.ambr new file mode 100644 index 000000000000..17711814c7c8 --- /dev/null +++ b/tests/components/steam_online/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://steamcommunity.com/profiles/123456789/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'steam_online', + '12345678901234567', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Valve', + 'model': 'Steam', + 'model_id': None, + 'name': 'testaccount1', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/steam_online/snapshots/test_sensor.ambr b/tests/components/steam_online/snapshots/test_sensor.ambr index 26e5aa3a1b0b..cac8d8f57495 100644 --- a/tests/components/steam_online/snapshots/test_sensor.ambr +++ b/tests/components/steam_online/snapshots/test_sensor.ambr @@ -1,5 +1,85 @@ # serializer version: 1 -# name: test_sensors[sensor.steam_testaccount1-entry] +# name: test_sensors[sensor.testaccount1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678901234567_account', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'created': datetime.datetime(2010, 5, 15, 12, 58, 31, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + : 'enum', + : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', + : 'testaccount1', + 'game': 'The Witcher: Enhanced Edition', + 'game_icon': 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/20900/746d1cd48fb2e57d579b05b6e9eccba95859e549.jpg', + 'game_id': '20900', + 'game_image_header': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/header.jpg', + 'game_image_main': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/capsule_616x353.jpg', + 'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc), + 'level': 10, + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), + 'real_name': 'John Dough', + }), + 'context': , + 'entity_id': 'sensor.testaccount1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'online', + }) +# --- +# name: test_sensors[sensor.testaccount1_last_online-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -13,7 +93,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.steam_testaccount1', + 'entity_id': 'sensor.testaccount1_last_online', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -21,39 +101,372 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'testaccount1', + 'object_id_base': 'Last online', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678901234567_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount1_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'testaccount1 Last online', + }), + 'context': , + 'entity_id': 'sensor.testaccount1_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-04-05T17:18:07+00:00', + }) +# --- +# name: test_sensors[sensor.testaccount1_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount1_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'testaccount1', + 'original_name': 'Level', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678901234567_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount1_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'testaccount1 Level', + : , + }), + 'context': , + 'entity_id': 'sensor.testaccount1_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.testaccount1_now_playing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.testaccount1_now_playing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Now playing', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Now playing', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678901234567_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount1_now_playing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'app_id': '20900', + : 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/20900/746d1cd48fb2e57d579b05b6e9eccba95859e549.jpg', + : 'testaccount1 Now playing', + }), + 'context': , + 'entity_id': 'sensor.testaccount1_now_playing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'The Witcher: Enhanced Edition', + }) +# --- +# name: test_sensors[sensor.testaccount2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, 'platform': 'steam_online', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, 'translation_key': , - 'unique_id': '12345678901234567_account', + 'unique_id': '12345678912345678_account', 'unit_of_measurement': None, }) # --- -# name: test_sensors[sensor.steam_testaccount1-state] +# name: test_sensors[sensor.testaccount2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'created': datetime.datetime(2011, 4, 19, 12, 57, 21, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + : 'enum', : 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg', - : 'Steam testaccount1', - 'game': 'The Witcher: Enhanced Edition', - 'game_icon': 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/20900/746d1cd48fb2e57d579b05b6e9eccba95859e549.jpg', - 'game_id': '20900', - 'game_image_header': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/header.jpg', - 'game_image_main': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/capsule_616x353.jpg', - 'last_online': datetime.datetime(2026, 4, 5, 10, 18, 7, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + : 'testaccount2', + 'game': None, + 'game_icon': None, + 'game_id': None, + 'game_image_header': None, + 'game_image_main': None, + 'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc), 'level': 10, + : list([ + 'offline', + 'online', + 'busy', + 'away', + 'snooze', + 'looking_to_trade', + 'looking_to_play', + ]), + 'real_name': None, }), 'context': , - 'entity_id': 'sensor.steam_testaccount1', + 'entity_id': 'sensor.testaccount2', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'online', + 'state': 'busy', + }) +# --- +# name: test_sensors[sensor.testaccount2_last_online-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.testaccount2_last_online', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last online', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678912345678_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount2_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'testaccount2 Last online', + }), + 'context': , + 'entity_id': 'sensor.testaccount2_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-04-05T17:18:07+00:00', + }) +# --- +# name: test_sensors[sensor.testaccount2_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testaccount2_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678912345678_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount2_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'testaccount2 Level', + : , + }), + 'context': , + 'entity_id': 'sensor.testaccount2_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.testaccount2_now_playing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.testaccount2_now_playing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Now playing', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Now playing', + 'platform': 'steam_online', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '12345678912345678_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testaccount2_now_playing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'app_id': None, + : 'testaccount2 Now playing', + }), + 'context': , + 'entity_id': 'sensor.testaccount2_now_playing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', }) # --- diff --git a/tests/components/steam_online/test_config_flow.py b/tests/components/steam_online/test_config_flow.py index 9c22c9ab09ef..950c0f493e27 100644 --- a/tests/components/steam_online/test_config_flow.py +++ b/tests/components/steam_online/test_config_flow.py @@ -6,23 +6,19 @@ from unittest.mock import AsyncMock, MagicMock import pytest import steam.api -from homeassistant.components.steam_online.const import CONF_ACCOUNTS, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY +from homeassistant.components.steam_online.const import ( + CONF_ACCOUNT, + DOMAIN, + SUBENTRY_TYPE_FRIEND, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, ConfigSubentry +from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import entity_registry as er -from . import ( - ACCOUNT_1, - ACCOUNT_2, - ACCOUNT_NAME_1, - CONF_DATA, - CONF_OPTIONS, - CONF_OPTIONS_2, -) +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_1, ACCOUNT_NAME_2, API_KEY, CONF_DATA -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.mark.usefixtures("steam_api") @@ -46,7 +42,6 @@ async def test_flow_user( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == ACCOUNT_NAME_1 assert result["data"] == CONF_DATA - assert result["options"] == CONF_OPTIONS assert result["result"].unique_id == ACCOUNT_1 assert len(mock_setup_entry.mock_calls) == 1 @@ -94,7 +89,6 @@ async def test_flow_user_errors( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == ACCOUNT_NAME_1 assert result["data"] == CONF_DATA - assert result["options"] == CONF_OPTIONS assert result["result"].unique_id == ACCOUNT_1 assert len(mock_setup_entry.mock_calls) == 1 @@ -120,6 +114,30 @@ async def test_flow_user_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("steam_api") +async def test_flow_user_already_configured_as_subentry( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test user initialized flow with duplicate account.""" + 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_API_KEY: API_KEY, + CONF_ACCOUNT: ACCOUNT_2, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_subentry" + + @pytest.mark.usefixtures("steam_api") async def test_flow_reauth( hass: HomeAssistant, @@ -188,103 +206,297 @@ async def test_flow_reauth_errors( assert len(hass.config_entries.async_entries()) == 1 -@pytest.mark.usefixtures("steam_api") -async def test_options_flow( - hass: HomeAssistant, - config_entry: MockConfigEntry, -) -> None: - """Test updating options.""" - config_entry.add_to_hass(hass) +async def test_add_friend_flow(hass: HomeAssistant, steam_api: MagicMock) -> None: + """Test add friend subentry flow.""" + steam_api.return_value.GetPlayerSummaries.return_value = ( + await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN + ) + ) + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["step_id"] == "user" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1, ACCOUNT_2]}, + user_input={CONF_ACCOUNT: ACCOUNT_2}, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS_2 + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ) + } @pytest.mark.usefixtures("steam_api") -async def test_options_flow_deselect( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - config_entry: MockConfigEntry, +async def test_add_friend_flow_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: - """Test deselecting user.""" - config_entry.add_to_hass(hass) + """Test add friend subentry flow aborts if friend is already configured as subentry.""" + config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + data={CONF_ACCOUNT: ACCOUNT_2}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("steam_api") +async def test_add_friend_flow_already_configured_as_entry(hass: HomeAssistant) -> None: + """Test add friend subentry flow aborts if friend is already configured as config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_2, + data=CONF_DATA, + unique_id=ACCOUNT_2, + version=3, + ).add_to_hass(hass) + + 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 + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["step_id"] == "user" - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={CONF_ACCOUNTS: []}, + user_input={CONF_ACCOUNT: ACCOUNT_2}, ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured_as_entry" + + +@pytest.mark.parametrize( + ("side_effect", "reason", "description_placeholders"), + [ + ( + steam.api.HTTPError("Server connection failed: Unauthorized (401)"), + "friendlist_private", + { + CONF_NAME: ACCOUNT_NAME_1, + "privacy_settings_url": "https://steamcommunity.com/profiles/123456789/edit/settings", + }, + ), + ( + steam.api.HTTPError, + "cannot_connect", + None, + ), + ( + steam.api.HTTPTimeoutError, + "timeout_connect", + None, + ), + ( + ValueError, + "unknown", + None, + ), + ], +) +async def test_add_friend_flow_abort_errors( + hass: HomeAssistant, + steam_api: MagicMock, + config_entry: MockConfigEntry, + side_effect: type[Exception] | Exception, + reason: str, + description_placeholders: dict[str, str] | None, +) -> None: + """Test add friend subentry flow aborts on errors.""" + + steam_api.return_value.GetFriendList.side_effect = side_effect + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_ACCOUNTS: {}} - assert len(entity_registry.entities) == 0 + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + assert result["description_placeholders"] == description_placeholders -async def test_options_flow_timeout( +async def test_add_friend_flow_abort_no_more_friends( hass: HomeAssistant, - config_entry: MockConfigEntry, steam_api: MagicMock, + config_entry: MockConfigEntry, ) -> None: - """Test updating options timeout getting friends list.""" + """Test add friend subentry flow aborts when no more friends left to add.""" + + steam_api.return_value.GetPlayerSummaries.return_value = ( + await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN + ) + ) + + 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 + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_more_friends" + + +@pytest.mark.usefixtures("steam_api") +async def test_add_friend_flow_config_entry_not_loaded( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test add friend subentry flow.""" config_entry.add_to_hass(hass) - steam_api.return_value.GetFriendList.side_effect = steam.api.HTTPTimeoutError - result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1]}, + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "config_entry_not_loaded" -async def test_options_flow_unauthorized( +@pytest.mark.parametrize( + ("side_effect", "error_msg"), + [ + (steam.api.HTTPTimeoutError, "timeout_connect"), + (steam.api.HTTPError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_add_friend_errors( hass: HomeAssistant, - config_entry: MockConfigEntry, steam_api: MagicMock, + side_effect: type[Exception], + error_msg: str, ) -> None: - """Test updating options when user's friends list is not public.""" - config_entry.add_to_hass(hass) - steam_api.return_value.GetFriendList.side_effect = steam.api.HTTPError - result = await hass.config_entries.options.async_init(config_entry.entry_id) + """Test add friend subentry flow with recoverable errors.""" - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_ACCOUNTS: [ACCOUNT_1]}, + player_summaries = await async_load_json_object_fixture( + hass, "GetPlayerSummariesSingle.json", DOMAIN ) + steam_api.return_value.GetPlayerSummaries.return_value = player_summaries + + config_entry = MockConfigEntry( + domain=DOMAIN, + title=ACCOUNT_NAME_1, + data=CONF_DATA, + unique_id=ACCOUNT_1, + version=3, + ) + + 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 + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, SUBENTRY_TYPE_FRIEND), + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + steam_api.return_value.GetPlayerSummaries.side_effect = [ + side_effect, + player_summaries, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT: ACCOUNT_2}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_msg} + + steam_api.return_value.GetPlayerSummaries.side_effect = None + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_ACCOUNT: ACCOUNT_2}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONF_OPTIONS + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: ConfigSubentry( + data={}, + subentry_id=subentry_id, + subentry_type=SUBENTRY_TYPE_FRIEND, + title=ACCOUNT_NAME_2, + unique_id=ACCOUNT_2, + ) + } @pytest.mark.usefixtures("steam_api") diff --git a/tests/components/steam_online/test_init.py b/tests/components/steam_online/test_init.py index 89ea46f6a00b..61339d066f97 100644 --- a/tests/components/steam_online/test_init.py +++ b/tests/components/steam_online/test_init.py @@ -4,14 +4,19 @@ from unittest.mock import MagicMock import pytest import steam.api +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.steam_online.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.steam_online.const import ( + CONF_ACCOUNTS, + DOMAIN, + SUBENTRY_TYPE_FRIEND, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ACCOUNT_1, ACCOUNT_NAME_1, CONF_DATA, CONF_OPTIONS +from . import ACCOUNT_1, ACCOUNT_2, ACCOUNT_NAME_1, ACCOUNT_NAME_2, CONF_DATA from tests.common import MockConfigEntry @@ -97,6 +102,7 @@ async def test_device_info( hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test device info.""" config_entry.add_to_hass(hass) @@ -104,28 +110,26 @@ async def test_device_info( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert ( - device := device_registry.async_get_device( - identifiers={(DOMAIN, config_entry.entry_id)} - ) + device_registry.async_get_device(identifiers={(DOMAIN, ACCOUNT_1)}) == snapshot ) - assert device.configuration_url == "https://store.steampowered.com" - assert device.entry_type == dr.DeviceEntryType.SERVICE - assert device.identifiers == {(DOMAIN, config_entry.entry_id)} - assert device.manufacturer == DEFAULT_NAME - assert device.name == DEFAULT_NAME - @pytest.mark.usefixtures("steam_api") async def test_migrate_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test entry migration.""" config_entry = MockConfigEntry( domain=DOMAIN, data=CONF_DATA, - options=CONF_OPTIONS, + options={ + CONF_ACCOUNTS: { + ACCOUNT_1: ACCOUNT_NAME_1, + ACCOUNT_2: ACCOUNT_NAME_2, + } + }, unique_id=ACCOUNT_1, version=1, ) @@ -142,12 +146,30 @@ async def test_migrate_entry( original_name=ACCOUNT_NAME_1, ) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, config_entry.entry_id)}, + ) + assert sensor.unique_id == f"sensor.steam_{ACCOUNT_1}" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.version == 2 + assert config_entry.version == 3 assert (sensor := entity_registry.async_get(sensor.entity_id)) assert sensor.unique_id == f"{ACCOUNT_1}_account" + + assert (device := device_registry.async_get(device.id)) + assert device.identifiers == {(DOMAIN, ACCOUNT_1)} + + assert len(config_entry.subentries) == 1 + subentries = list(config_entry.subentries.values()) + assert subentries[0].unique_id == ACCOUNT_2 + assert subentries[0].title == ACCOUNT_NAME_2 + assert subentries[0].subentry_type == SUBENTRY_TYPE_FRIEND + + assert config_entry.options == {} + + assert device_registry.async_get_device(identifiers={(DOMAIN, ACCOUNT_2)}) diff --git a/tests/components/stiebel_eltron/conftest.py b/tests/components/stiebel_eltron/conftest.py index 5c43aab97196..395d8ed90947 100644 --- a/tests/components/stiebel_eltron/conftest.py +++ b/tests/components/stiebel_eltron/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch +from modbus_connection.mock import MockModbusConnection from pystiebeleltron import ControllerModel from pystiebeleltron.lwz import OperatingMode import pytest @@ -30,6 +31,22 @@ def mock_get_controller_model() -> Generator[MagicMock]: yield mock_get_model +@pytest.fixture(autouse=True) +def mock_connect_tcp( + mock_modbus_connection: MockModbusConnection, +) -> Generator[AsyncMock]: + """Patch connect_tcp to return the in-memory mock connection.""" + connect = AsyncMock(return_value=mock_modbus_connection) + with ( + patch("homeassistant.components.stiebel_eltron.connect_tcp", new=connect), + patch( + "homeassistant.components.stiebel_eltron.config_flow.connect_tcp", + new=connect, + ), + ): + yield connect + + @pytest.fixture(autouse=True) def mock_lwz_api() -> Generator[MagicMock]: """Patch the LWZ API and return the mocked client.""" @@ -38,24 +55,16 @@ def mock_lwz_api() -> Generator[MagicMock]: "homeassistant.components.stiebel_eltron.coordinator.LwzStiebelEltronAPI", autospec=True, ) as mock_api_cls: - api_client = MagicMock() + api_client = mock_api_cls.return_value - api_client.get_target_temp = MagicMock(return_value=22.5) - api_client.get_current_temp = MagicMock(return_value=21.0) - api_client.get_current_humidity = MagicMock(return_value=45.0) - api_client.get_operation = MagicMock(return_value=OperatingMode.AUTOMATIC) - api_client.get_filter_alarm_status = MagicMock(return_value=False) + api_client.get_target_temp.return_value = 22.5 + api_client.get_current_temp.return_value = 21.0 + api_client.get_current_humidity.return_value = 45.0 + api_client.get_operation.return_value = OperatingMode.AUTOMATIC + api_client.get_heating_status.return_value = True + api_client.get_cooling_status.return_value = False + api_client.get_filter_alarm_status.return_value = False - api_client.connect = AsyncMock() - api_client.close = AsyncMock() - api_client.async_update = AsyncMock() - api_client.set_operation = AsyncMock() - api_client.set_target_temp = AsyncMock() - - api_client.is_connected = False - api_client.host = "1.1.1.1" - - mock_api_cls.return_value = api_client yield api_client diff --git a/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr b/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..d192b35094be --- /dev/null +++ b/tests/components/stiebel_eltron/snapshots/test_diagnostics.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'cooling_status': False, + 'current_humidity': 45.0, + 'current_temp': 21.0, + 'filter_alarm': False, + 'heating_status': True, + 'operating_mode': 'AUTOMATIC', + 'target_temp': 22.5, + }), + 'entry_data': dict({ + 'host': '**REDACTED**', + 'port': 502, + }), + 'model': 'LWZ', + }) +# --- diff --git a/tests/components/stiebel_eltron/test_climate.py b/tests/components/stiebel_eltron/test_climate.py index 2fcf832deee1..77f4e6b86f6c 100644 --- a/tests/components/stiebel_eltron/test_climate.py +++ b/tests/components/stiebel_eltron/test_climate.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from pymodbus.exceptions import ModbusException +from modbus_connection import ModbusError from pystiebeleltron.lwz import OperatingMode import pytest from syrupy.assertion import SnapshotAssertion @@ -22,7 +22,6 @@ from homeassistant.components.stiebel_eltron.climate import ( PRESET_READY, PRESET_WATER_HEATING, ) -from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -105,58 +104,27 @@ async def test_climate_entity_operating_modes( assert state.attributes[ATTR_PRESET_MODE] == expected_preset -async def test_climate_entity_set_hvac_mode_without_preset( +async def test_climate_entity_set_hvac_mode( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lwz_api: MagicMock, ) -> None: """Test setting HVAC mode.""" - - # Prepare mock - mock_lwz_api.get_operation.return_value = None - await _setup_integration(hass, mock_config_entry) - # Ensure no preset is active - state = hass.states.get(CLIMATE_ENTITY_ID) - assert state is not None - assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_PRESET_MODE) is None - - # Test setting to AUTO mock_lwz_api.set_operation.reset_mock() await async_set_hvac_mode(hass, HVACMode.AUTO, CLIMATE_ENTITY_ID) mock_lwz_api.set_operation.assert_awaited_with(OperatingMode.AUTOMATIC) - # Test setting to HEAT mock_lwz_api.set_operation.reset_mock() await async_set_hvac_mode(hass, HVACMode.HEAT, CLIMATE_ENTITY_ID) mock_lwz_api.set_operation.assert_awaited_with(OperatingMode.MANUAL_MODE) - # Test setting to OFF mock_lwz_api.set_operation.reset_mock() await async_set_hvac_mode(hass, HVACMode.OFF, CLIMATE_ENTITY_ID) mock_lwz_api.set_operation.assert_awaited_with(OperatingMode.DHW) -async def test_climate_entity_set_hvac_mode_with_preset( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_lwz_api: MagicMock, -) -> None: - """Test that setting HVAC mode does nothing when a preset is active.""" - - # Prepare mock - mock_lwz_api.get_operation.return_value = OperatingMode.DAY_MODE - mock_lwz_api.set_operation.reset_mock() - - await _setup_integration(hass, mock_config_entry) - - # Should not call set_operation when preset is active - await async_set_hvac_mode(hass, HVACMode.HEAT, CLIMATE_ENTITY_ID) - mock_lwz_api.set_operation.assert_not_called() - - async def test_climate_entity_set_temperature( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -169,6 +137,32 @@ async def test_climate_entity_set_temperature( mock_lwz_api.set_target_temp.assert_awaited_with(23.5) +async def test_climate_entity_set_hvac_mode_handles_api_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lwz_api: MagicMock, +) -> None: + """Test setting HVAC mode handles API exception.""" + await _setup_integration(hass, mock_config_entry) + + mock_lwz_api.set_operation.side_effect = ModbusError("write failed") + with pytest.raises(HomeAssistantError): + await async_set_hvac_mode(hass, HVACMode.AUTO, CLIMATE_ENTITY_ID) + + +async def test_climate_entity_set_preset_mode_handles_api_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lwz_api: MagicMock, +) -> None: + """Test setting preset mode handles API exception.""" + await _setup_integration(hass, mock_config_entry) + + mock_lwz_api.set_operation.side_effect = ModbusError("write failed") + with pytest.raises(HomeAssistantError): + await async_set_preset_mode(hass, PRESET_COMFORT, CLIMATE_ENTITY_ID) + + async def test_climate_entity_set_temperature_handles_api_exception( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -178,7 +172,7 @@ async def test_climate_entity_set_temperature_handles_api_exception( await _setup_integration(hass, mock_config_entry) - mock_lwz_api.set_target_temp.side_effect = ModbusException("write failed") + mock_lwz_api.set_target_temp.side_effect = ModbusError("write failed") with pytest.raises(HomeAssistantError): await async_set_temperature(hass, 24.0, CLIMATE_ENTITY_ID) diff --git a/tests/components/stiebel_eltron/test_config_flow.py b/tests/components/stiebel_eltron/test_config_flow.py index 377091d30df5..b325d90380a3 100644 --- a/tests/components/stiebel_eltron/test_config_flow.py +++ b/tests/components/stiebel_eltron/test_config_flow.py @@ -2,16 +2,27 @@ from unittest.mock import MagicMock +from modbus_connection import ModbusError from pystiebeleltron import ControllerModel, StiebelEltronModbusError +import pytest from homeassistant.components.stiebel_eltron.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT 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 +USER_INPUT = {CONF_HOST: "1.1.1.1", CONF_PORT: 502} +RECONFIGURE_INPUT = {CONF_HOST: "2.2.2.2", CONF_PORT: 502} +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.2", + hostname="servicewelt", + macaddress="000000000001", +) + async def test_full_flow(hass: HomeAssistant) -> None: """Test the full flow.""" @@ -23,50 +34,50 @@ async def test_full_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Stiebel Eltron" - assert result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - } + assert result["data"] == USER_INPUT +@pytest.mark.parametrize( + ("failing_fixture", "side_effect"), + [ + pytest.param( + "mock_get_controller_model", StiebelEltronModbusError, id="model_read" + ), + pytest.param("mock_connect_tcp", ModbusError, id="connect"), + ], +) async def test_form_cannot_connect( hass: HomeAssistant, - mock_get_controller_model: MagicMock, + request: pytest.FixtureRequest, + failing_fixture: str, + side_effect: type[Exception], ) -> None: - """Test we handle cannot connect error.""" + """Test we handle a cannot connect error while opening or reading the device.""" + failing_mock = request.getfixturevalue(failing_fixture) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - mock_get_controller_model.side_effect = StiebelEltronModbusError + failing_mock.side_effect = side_effect result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - mock_get_controller_model.side_effect = None + failing_mock.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -85,10 +96,7 @@ async def test_form_unknown_exception( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.FORM @@ -99,15 +107,102 @@ async def test_form_unknown_exception( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguration flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_INPUT, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_HOST] == "2.2.2.2" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + pytest.param(StiebelEltronModbusError, "cannot_connect", id="cannot_connect"), + pytest.param(Exception, "unknown", id="unknown"), + ], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_controller_model: MagicMock, + side_effect: type[Exception], + expected_error: str, +) -> None: + """Test error handling in reconfiguration flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + assert result["type"] is FlowResultType.FORM + + mock_get_controller_model.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_INPUT, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_get_controller_model.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_INPUT, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure aborts if another entry already uses the given host/port.""" + other_entry = MockConfigEntry( + domain=DOMAIN, + title="Stiebel Eltron", + data=RECONFIGURE_INPUT, + entry_id="stiebel_eltron_002", + ) + + mock_config_entry.add_to_hass(hass) + other_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -119,11 +214,87 @@ async def test_already_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 502, - }, + USER_INPUT, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_flow(hass: HomeAssistant) -> None: + """Test the full DHCP discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stiebel Eltron" + assert result["data"] == {CONF_HOST: "1.1.1.2", CONF_PORT: 502} + assert result["result"].unique_id == "00:00:00:00:00:01" + + +async def test_dhcp_discovery_updates_host(hass: HomeAssistant) -> None: + """Test DHCP discovery updates the host of an entry with a matching MAC.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="Stiebel Eltron", + data={CONF_HOST: "1.1.1.1", CONF_PORT: 502}, + unique_id="00:00:00:00:00:01", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == "1.1.1.2" + + +async def test_dhcp_discovery_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test DHCP discovery aborts for an already configured host.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.1.1.1", + hostname="servicewelt", + macaddress="000000000001", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_reason"), + [ + pytest.param(StiebelEltronModbusError, "cannot_connect", id="cannot_connect"), + pytest.param(Exception, "unknown", id="unknown"), + ], +) +async def test_dhcp_discovery_errors( + hass: HomeAssistant, + mock_get_controller_model: MagicMock, + side_effect: type[Exception], + expected_reason: str, +) -> None: + """Test DHCP discovery aborts when the device cannot be validated.""" + mock_get_controller_model.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason diff --git a/tests/components/stiebel_eltron/test_diagnostics.py b/tests/components/stiebel_eltron/test_diagnostics.py new file mode 100644 index 000000000000..bc1c820fb1d0 --- /dev/null +++ b/tests/components/stiebel_eltron/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for STIEBEL ELTRON diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +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, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + 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() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/stiebel_eltron/test_init.py b/tests/components/stiebel_eltron/test_init.py index 4fe188ce0b10..5f93cc84e25c 100644 --- a/tests/components/stiebel_eltron/test_init.py +++ b/tests/components/stiebel_eltron/test_init.py @@ -1,7 +1,9 @@ """Tests for the STIEBEL ELTRON integration.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch +from modbus_connection import ModbusError, ModbusTimeoutError +from modbus_connection.mock import MockModbusConnection from pystiebeleltron import StiebelEltronModbusError from homeassistant.components.stiebel_eltron.const import DOMAIN @@ -26,7 +28,7 @@ async def test_async_setup_entry_success( async def test_async_setup_entry_with_custom_port( hass: HomeAssistant, - mock_get_controller_model: MagicMock, + mock_connect_tcp: AsyncMock, ) -> None: """Test setup with custom port.""" config_entry = MockConfigEntry( @@ -39,12 +41,12 @@ async def test_async_setup_entry_with_custom_port( result = await hass.config_entries.async_setup(config_entry.entry_id) assert result is True - mock_get_controller_model.assert_called_once_with("192.168.1.100", 5020) + mock_connect_tcp.assert_called_once_with("192.168.1.100", port=5020) async def test_async_setup_entry_without_port( hass: HomeAssistant, - mock_get_controller_model: MagicMock, + mock_connect_tcp: AsyncMock, ) -> None: """Test setup without port (should use default).""" config_entry = MockConfigEntry( @@ -57,7 +59,22 @@ async def test_async_setup_entry_without_port( result = await hass.config_entries.async_setup(config_entry.entry_id) assert result is True - mock_get_controller_model.assert_called_once_with("192.168.1.100", 502) + mock_connect_tcp.assert_called_once_with("192.168.1.100", port=502) + + +async def test_async_setup_entry_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect_tcp: AsyncMock, +) -> None: + """Test setup retries when the connection cannot be opened.""" + mock_connect_tcp.side_effect = ModbusTimeoutError("could not connect") + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_async_setup_entry_modbus_error( @@ -65,11 +82,85 @@ async def test_async_setup_entry_modbus_error( mock_config_entry: MockConfigEntry, mock_get_controller_model: MagicMock, ) -> None: - """Test setup fails when get_controller_model raises an error.""" + """Test setup retries when reading the controller model fails.""" mock_config_entry.add_to_hass(hass) mock_get_controller_model.side_effect = StiebelEltronModbusError() result = await hass.config_entries.async_setup(mock_config_entry.entry_id) assert result is False - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_async_setup_entry_coordinator_update_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lwz_api: MagicMock, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Test setup retries and closes the connection when the first update fails.""" + mock_lwz_api.async_update.side_effect = ModbusError("update failed") + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert result is False + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_modbus_connection.connected is False + + +async def test_connection_lost_reloads_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Test a lost connection schedules a reload of the 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() + + with patch.object( + hass.config_entries, "async_schedule_reload" + ) as mock_schedule_reload: + mock_modbus_connection.simulate_connection_lost() + + mock_schedule_reload.assert_called_once_with(mock_config_entry.entry_id) + + +async def test_unload_entry_closes_connection( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Test unloading the config entry closes the Modbus connection.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_modbus_connection.connected is False + + +async def test_unload_entry_does_not_close_connection_if_platform_unload_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_connection: MockModbusConnection, +) -> None: + """Test the connection is not closed if platform unload fails.""" + 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.config_entries.ConfigEntries.async_unload_platforms", + return_value=False, + ): + result = await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert result is False + assert mock_modbus_connection.connected is True diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index 26d1182fe571..c897ec328877 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -12,6 +12,7 @@ from homeassistant.components.subaru.const import ( VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_HEALTH, VEHICLE_MODEL_NAME, VEHICLE_MODEL_YEAR, VEHICLE_NAME, @@ -107,7 +108,10 @@ VEHICLE_STATUS_EV = { "WINDOW_SUNROOF_STATUS": "UNKNOWN", "LATITUDE": 40.0, "LONGITUDE": -100.0, - } + }, + VEHICLE_HEALTH: { + "RECOMMENDED_TIRE_PRESSURE": {"FRONT_TIRES": 35, "REAR_TIRES": 33}, + }, } @@ -144,12 +148,10 @@ VEHICLE_STATUS_G3 = { EXPECTED_STATE_EV_IMPERIAL = { "AVG_FUEL_CONSUMPTION": "51.1", "DISTANCE_TO_EMPTY_FUEL": "170", - "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", "EV_DISTANCE_TO_EMPTY": "1", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", - "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "ODOMETER": "1234", @@ -159,7 +161,9 @@ EXPECTED_STATE_EV_IMPERIAL = { "TYRE_PRESSURE_FRONT_RIGHT": "31.9", "TYRE_PRESSURE_REAR_LEFT": "32.6", "TYRE_PRESSURE_REAR_RIGHT": "unknown", - "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "VEHICLE_STATE_TYPE": "ignition_off", + "recommended_tire_pressure_front": "35", + "recommended_tire_pressure_rear": "33", "LATITUDE": 40.0, "LONGITUDE": -100.0, } @@ -167,12 +171,10 @@ EXPECTED_STATE_EV_IMPERIAL = { EXPECTED_STATE_EV_METRIC = { "AVG_FUEL_CONSUMPTION": "4.6", "DISTANCE_TO_EMPTY_FUEL": "273.59", - "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", "EV_DISTANCE_TO_EMPTY": "1.61", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", - "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", "ODOMETER": "1985.93", @@ -182,7 +184,9 @@ EXPECTED_STATE_EV_METRIC = { "TYRE_PRESSURE_FRONT_RIGHT": "219.94", "TYRE_PRESSURE_REAR_LEFT": "224.77", "TYRE_PRESSURE_REAR_RIGHT": "unknown", - "VEHICLE_STATE_TYPE": "IGNITION_OFF", + "VEHICLE_STATE_TYPE": "ignition_off", + "recommended_tire_pressure_front": "241.32", + "recommended_tire_pressure_rear": "227.53", "LATITUDE": 40.0, "LONGITUDE": -100.0, } @@ -191,12 +195,10 @@ EXPECTED_STATE_EV_METRIC = { EXPECTED_STATE_EV_UNAVAILABLE = { "AVG_FUEL_CONSUMPTION": "unavailable", "DISTANCE_TO_EMPTY_FUEL": "unavailable", - "EV_CHARGER_STATE_TYPE": "unavailable", "EV_CHARGE_SETTING_AMPERE_TYPE": "unavailable", "EV_CHARGE_VOLT_TYPE": "unavailable", "EV_DISTANCE_TO_EMPTY": "unavailable", "EV_IS_PLUGGED_IN": "unavailable", - "EV_STATE_OF_CHARGE_MODE": "unavailable", "EV_STATE_OF_CHARGE_PERCENT": "unavailable", "EV_TIME_TO_FULLY_CHARGED_UTC": "unavailable", "ODOMETER": "unavailable", @@ -207,6 +209,8 @@ EXPECTED_STATE_EV_UNAVAILABLE = { "TYRE_PRESSURE_REAR_LEFT": "unavailable", "TYRE_PRESSURE_REAR_RIGHT": "unavailable", "VEHICLE_STATE_TYPE": "unavailable", + "recommended_tire_pressure_front": "unavailable", + "recommended_tire_pressure_rear": "unavailable", "LATITUDE": "unavailable", "LONGITUDE": "unavailable", } diff --git a/tests/components/subaru/snapshots/test_diagnostics.ambr b/tests/components/subaru/snapshots/test_diagnostics.ambr index 14c19dd78a9f..b6cf9336d2f1 100644 --- a/tests/components/subaru/snapshots/test_diagnostics.ambr +++ b/tests/components/subaru/snapshots/test_diagnostics.ambr @@ -10,6 +10,12 @@ }), 'data': list([ dict({ + 'vehicle_health': dict({ + 'RECOMMENDED_TIRE_PRESSURE': dict({ + 'FRONT_TIRES': 35, + 'REAR_TIRES': 33, + }), + }), 'vehicle_status': dict({ 'AVG_FUEL_CONSUMPTION': 51.1, 'DISTANCE_TO_EMPTY_FUEL': 170, @@ -61,6 +67,12 @@ 'username': '**REDACTED**', }), 'data': dict({ + 'vehicle_health': dict({ + 'RECOMMENDED_TIRE_PRESSURE': dict({ + 'FRONT_TIRES': 35, + 'REAR_TIRES': 33, + }), + }), 'vehicle_status': dict({ 'AVG_FUEL_CONSUMPTION': 51.1, 'DISTANCE_TO_EMPTY_FUEL': 170, diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index 73c06b4d86ed..66e6b4c25064 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -1,17 +1,19 @@ """Test Subaru sensors.""" +import copy from typing import Any from unittest.mock import patch import pytest from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.subaru.const import DOMAIN +from homeassistant.components.subaru.const import DOMAIN, VEHICLE_STATUS from homeassistant.components.subaru.sensor import ( API_GEN_2_SENSORS, EV_SENSORS, SAFETY_SENSORS, ) +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -19,6 +21,7 @@ from .api_responses import ( EXPECTED_STATE_EV_METRIC, EXPECTED_STATE_EV_UNAVAILABLE, TEST_VIN_2_EV, + VEHICLE_STATUS_EV, ) from .conftest import ( MOCK_API_FETCH, @@ -27,7 +30,7 @@ from .conftest import ( setup_subaru_config_entry, ) -from tests.common import get_sensor_display_state +from tests.common import MockConfigEntry, get_sensor_display_state async def test_sensors_ev_metric(hass: HomeAssistant, ev_entry) -> None: @@ -137,6 +140,10 @@ def _assert_data(hass: HomeAssistant, expected_state: dict[str, Any]) -> None: expected_states = {} entity_registry = er.async_get(hass) for item in sensor_list: + # Disabled-by-default sensors (e.g. the *_raw diagnostic companions) + # aren't loaded into the state machine, so there's nothing to assert. + if not item.entity_registry_enabled_default: + continue entity = entity_registry.async_get_entity_id( SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_{item.key}" ) @@ -145,3 +152,78 @@ def _assert_data(hass: HomeAssistant, expected_state: dict[str, Any]) -> None: for sensor, value in expected_states.items(): state = get_sensor_display_state(hass, entity_registry, sensor) assert state == value + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ev_entry") +async def test_recommended_tire_pressure_from_vehicle_health( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Recommended tire pressure sensors derive their value from vehicle_health. + + These sensors are disabled-by-default diagnostics, so they're skipped in + `_assert_data`. Enable them here to exercise the `value_fn`-from- + `vehicle_health` path (the only consumer of nested-section value_fn in + the integration today). Fixture has FRONT_TIRES=35 / REAR_TIRES=33 PSI; + the test default unit system is metric, so HA's pressure conversion + surfaces them as kPa. + """ + front = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_recommended_tire_pressure_front" + ) + assert front is not None + assert ( + get_sensor_display_state(hass, entity_registry, front) + == EXPECTED_STATE_EV_METRIC["recommended_tire_pressure_front"] + ) + + rear = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_recommended_tire_pressure_rear" + ) + assert rear is not None + assert ( + get_sensor_display_state(hass, entity_registry, rear) + == EXPECTED_STATE_EV_METRIC["recommended_tire_pressure_rear"] + ) + + +async def test_avg_fuel_consumption_zero_metric( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, +) -> None: + """AVG_FUEL_CONSUMPTION of 0 returns 0 verbatim instead of dividing by zero. + + Guards the metric conversion at sensor.py:`native_value` so that a fresh + vehicle reporting 0 mpg doesn't raise ZeroDivisionError on metric installs. + """ + status_with_zero = copy.deepcopy(VEHICLE_STATUS_EV) + status_with_zero[VEHICLE_STATUS]["AVG_FUEL_CONSUMPTION"] = 0 + + await setup_subaru_config_entry( + hass, subaru_config_entry, vehicle_status=status_with_zero + ) + + state = hass.states.get("sensor.test_vehicle_2_average_fuel_consumption") + assert state is not None + assert state.state == "0" + + +async def test_enum_unmapped_value_reports_unknown( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, +) -> None: + """Unmapped ENUM values report as `unknown`. + + Live API values not in `VEHICLE_STATE_OPTIONS` fall through to `unknown` + rather than surfacing the raw upstream string as the entity state. + """ + status_with_unmapped_value = copy.deepcopy(VEHICLE_STATUS_EV) + status_with_unmapped_value[VEHICLE_STATUS]["VEHICLE_STATE_TYPE"] = "ENGINE_RUNNING" + + await setup_subaru_config_entry( + hass, subaru_config_entry, vehicle_status=status_with_unmapped_value + ) + + enum_state = hass.states.get("sensor.test_vehicle_2_vehicle_state") + assert enum_state is not None + assert enum_state.state == STATE_UNKNOWN diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index 7cedf7dc476d..53e3394f83a7 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -5,7 +5,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -24,7 +26,7 @@ 'object_id_base': 'Water price', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Water price', 'platform': 'suez_water', @@ -33,16 +35,16 @@ 'supported_features': 0, 'translation_key': 'water_price', 'unique_id': '123456_water_price', - 'unit_of_measurement': '€', + 'unit_of_measurement': '€/m³', }) # --- # name: test_sensors_valid_state[sensor.suez_mock_device_water_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by toutsurmoneau.fr', - : 'monetary', : 'Suez mock device Water price', - : '€', + : , + : '€/m³', }), 'context': , 'entity_id': 'sensor.suez_mock_device_water_price', diff --git a/tests/components/sunricher_dali/snapshots/test_init.ambr b/tests/components/sunricher_dali/snapshots/test_init.ambr index ca94d3b5dffe..87052be3a781 100644 --- a/tests/components/sunricher_dali/snapshots/test_init.ambr +++ b/tests/components/sunricher_dali/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,15 +29,14 @@ 'model_id': None, 'name': 'Test Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '6A242121110E', 'sw_version': None, 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -58,15 +57,14 @@ 'model_id': None, 'name': 'Dimmer 0000-02', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -87,15 +85,14 @@ 'model_id': None, 'name': 'CCT 0000-03', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -116,15 +113,14 @@ 'model_id': None, 'name': 'HS Color Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -145,7 +141,6 @@ 'model_id': None, 'name': 'RGBW Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 6aed898fadf9..f3cddd346f03 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -208,12 +208,6 @@ async def test_device_registry_config_entry_1( device_id=device_entry.id, original_name="ABC", ) - # Add another config entry to the same device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - device_entry.id, add_config_entry_id=other_config_entry.entry_id - ) switch_as_x_config_entry = MockConfigEntry( data={}, @@ -246,15 +240,12 @@ async def test_device_registry_config_entry_1( async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) - # Remove the wrapped switch's config entry from the device, this removes the - # wrapped switch + # Remove the wrapped switch, this removes the switch_as_x config entry with patch( "homeassistant.components.switch_as_x.async_unload_entry", wraps=switch_as_x.async_unload_entry, ) as mock_setup_entry: - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=switch_config_entry.entry_id - ) + entity_registry.async_remove(switch_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -1134,9 +1125,6 @@ async def test_migrate( minor_version=1, ) config_entry.add_to_hass(hass) - device_registry.async_update_device( - device_entry.id, add_config_entry_id=config_entry.entry_id - ) switch_as_x_entity_entry = entity_registry.async_get_or_create( target_domain, "switch_as_x", @@ -1179,19 +1167,9 @@ async def test_migrate( assert hass.states.get(f"{target_domain}.abc") is not None assert entity_registry.async_get(f"{target_domain}.abc") is not None - # Entity removed from device to prevent deletion, then added back to device - assert events == [ - { - "action": "update", - "changes": {"device_id": device_entry.id}, - "entity_id": switch_as_x_entity_entry.entity_id, - }, - { - "action": "update", - "changes": {"device_id": None}, - "entity_id": switch_as_x_entity_entry.entity_id, - }, - ] + # The switch_as_x config entry was never added to the device, so migration does + # not change the switch_as_x entity's device link + assert events == [] @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 10c6c2f06d1b..6877a9ebc14f 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -300,7 +300,6 @@ async def test_polling_is_only_disabled_after_webhook_delivery( entity_id = "vacuum.vacuum_name_1" state = hass.states.get(entity_id) assert state is not None - assert state.attributes["battery_level"] == 71 # Change API return values and wait for update mock_get_status.return_value = { @@ -317,7 +316,6 @@ async def test_polling_is_only_disabled_after_webhook_delivery( # Validate that the state was updated again via fetch state = hass.states.get(entity_id) assert state is not None - assert state.attributes["battery_level"] == 60 hass.bus.async_fire(EVENT_HOMEASSISTANT_START) webhook_id = entry.data[CONF_WEBHOOK_ID] diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index 91ddb124dc03..c4d7bf6d090d 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -73,8 +73,6 @@ async def test_lock( ("Smart Lock", 0), ("Smart Lock Pro", 1), ("Smart Lock Ultra", 2), - ("Lock Vision", 3), - ("Lock Vision Pro", 4), ("Smart Lock Pro Wifi", 5), ], ) diff --git a/tests/components/switchbot_cloud/test_vacuum.py b/tests/components/switchbot_cloud/test_vacuum.py index 71b237aafa2a..3410be3c7bb4 100644 --- a/tests/components/switchbot_cloud/test_vacuum.py +++ b/tests/components/switchbot_cloud/test_vacuum.py @@ -293,7 +293,6 @@ async def test_k10_plus_webhook_updates_state_after_reload( state = hass.states.get(entity_id) assert state is not None assert state.state == VacuumActivity.CLEANING.value - assert state.attributes["battery_level"] == 74 async def test_k20_plus_pro_set_fan_speed( diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 42dedc115dc7..684bc3a6f3ec 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -53,8 +53,8 @@ # name: test_number_entities[binary_sensor.door_1_operational_problem].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': None, 'name': 'Door 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , @@ -135,8 +134,8 @@ # name: test_number_entities[binary_sensor.door_2_operational_problem].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Door 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index c3e135498986..a1e7656951ed 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -53,8 +53,8 @@ # name: test_number_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -79,7 +79,6 @@ 'model_id': None, 'name': 'Tailwind iQ3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': None, diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index e6670bc31ed3..d3aa188a6931 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -55,8 +55,8 @@ # name: test_cover_entities[cover.door_1].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -77,7 +77,6 @@ 'model_id': None, 'name': 'Door 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , @@ -139,8 +138,8 @@ # name: test_cover_entities[cover.door_2].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -161,7 +160,6 @@ 'model_id': None, 'name': 'Door 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': , diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index 46d08aac7c3b..737ceee9b1e4 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -62,8 +62,8 @@ # name: test_number_entities.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -88,7 +88,6 @@ 'model_id': None, 'name': 'Tailwind iQ3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '10.10', 'via_device_id': None, diff --git a/tests/components/tankerkoenig/conftest.py b/tests/components/tankerkoenig/conftest.py index 1517c3d20604..9966d91dc620 100644 --- a/tests/components/tankerkoenig/conftest.py +++ b/tests/components/tankerkoenig/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.tankerkoenig import DOMAIN +from homeassistant.components.tankerkoenig.const import DOMAIN from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr b/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr index bce05a91749e..999c2fb26661 100644 --- a/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr +++ b/tests/components/tankerkoenig/snapshots/test_binary_sensor.ambr @@ -1,9 +1,9 @@ # serializer version: 1 # name: test_binary_sensor ReadOnlyDict({ - : 'door', + : 'opening', : 'Station Somewhere Street 1 Status', - 'latitude': 51.1, - 'longitude': 13.1, + : 51.1, + : 13.1, }) # --- diff --git a/tests/components/tankerkoenig/snapshots/test_sensor.ambr b/tests/components/tankerkoenig/snapshots/test_sensor.ambr index 3b65840cb246..55788bd12fd4 100644 --- a/tests/components/tankerkoenig/snapshots/test_sensor.ambr +++ b/tests/components/tankerkoenig/snapshots/test_sensor.ambr @@ -7,8 +7,8 @@ : 'Station Somewhere Street 1 Super E10', 'fuel_type': , 'house_number': '1', - 'latitude': 51.1, - 'longitude': 13.1, + : 51.1, + : 13.1, 'postcode': 1234, : , 'station_name': 'Station ABC', @@ -24,8 +24,8 @@ : 'Station Somewhere Street 1 Super', 'fuel_type': , 'house_number': '1', - 'latitude': 51.1, - 'longitude': 13.1, + : 51.1, + : 13.1, 'postcode': 1234, : , 'station_name': 'Station ABC', @@ -41,8 +41,8 @@ : 'Station Somewhere Street 1 Diesel', 'fuel_type': , 'house_number': '1', - 'latitude': 51.1, - 'longitude': 13.1, + : 51.1, + : 13.1, 'postcode': 1234, : , 'station_name': 'Station ABC', diff --git a/tests/components/tankerkoenig/test_sensor.py b/tests/components/tankerkoenig/test_sensor.py index 00b507f0015b..b6201034bf31 100644 --- a/tests/components/tankerkoenig/test_sensor.py +++ b/tests/components/tankerkoenig/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.tankerkoenig import DOMAIN +from homeassistant.components.tankerkoenig.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 1c987f7466c3..77a231826a26 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -23,6 +23,20 @@ from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClient, WebSocketGenerator +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + async def test_subscribing_config_topic( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -324,12 +338,21 @@ async def test_device_remove_multiple_config_entries_1( ) await hass.async_block_till_done() - # Verify device entry is created - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify device entry is created. Identifiers and connections are unique per config + # entry, so Tasmota discovery creates a separate device sharing the connection + tasmota_device_entry = _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) - assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert tasmota_device_entry is not None + assert tasmota_device_entry.config_entries == {tasmota_entry.entry_id} + mock_device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + assert mock_device_entry is not None async_fire_mqtt_message( hass, @@ -338,9 +361,19 @@ async def test_device_remove_multiple_config_entries_1( ) await hass.async_block_till_done() - # Verify device entry is not removed - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify the Tasmota device is removed, but the other config entry's device is not + assert ( + _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + is None + ) + device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert device_entry is not None assert device_entry.config_entries == {mock_entry.entry_id} @@ -378,21 +411,29 @@ async def test_device_remove_multiple_config_entries_2( ) await hass.async_block_till_done() - # Verify device entry is created - device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mac)} + # Verify device entry is created. Identifiers and connections are unique per config + # entry, so Tasmota discovery creates a separate device sharing the connection + device_entry = _get_device_for_config_entry( + device_registry, + tasmota_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert device_entry.config_entries == {tasmota_entry.entry_id} assert other_device_entry.id != device_entry.id - # Remove other config entry from the device + # Remove the config entry from the other (non-Tasmota) device sharing the connection + mock_device_entry = _get_device_for_config_entry( + device_registry, + mock_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) device_registry.async_update_device( - device_entry.id, remove_config_entry_id=mock_entry.entry_id + mock_device_entry.id, remove_config_entry_id=mock_entry.entry_id ) await hass.async_block_till_done() - # Verify device entry is not removed + # Verify the Tasmota device entry is not removed device_entry = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index 38874d08f3af..e356fd427b19 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_bridge_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Bridge-AB1C', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '0000-0000', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_lock_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'Tedee PRO', 'name': 'Lock-1A2B', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index 456df2b3c34c..69ff7de8b147 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -53,8 +53,8 @@ # name: test_lock_without_pullspring.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': 'Tedee GO', 'name': 'Lock-2C3D', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/telegram_bot/test_init.py b/tests/components/telegram_bot/test_init.py index f5d54e19b241..749bc10ebc19 100644 --- a/tests/components/telegram_bot/test_init.py +++ b/tests/components/telegram_bot/test_init.py @@ -1,16 +1,21 @@ """Init tests for the Telegram Bot integration.""" +import pytest + from homeassistant.components.telegram_bot.const import ( ATTR_PARSER, + CONF_ALLOWED_CHAT_IDS, CONF_API_ENDPOINT, + CONF_CHAT_ID, DEFAULT_API_ENDPOINT, DOMAIN, PARSER_MD, PLATFORM_BROADCAST, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.const import CONF_API_KEY, CONF_PLATFORM from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -19,7 +24,7 @@ async def test_migration_error( hass: HomeAssistant, mock_external_calls: None, ) -> None: - """Test migrate config entry from 1.1 to 1.2.""" + """Test migrate config entry from unsupported version.""" mock_config_entry = MockConfigEntry( unique_id="mock api key", @@ -43,7 +48,7 @@ async def test_migrate_entry_from_1_1( hass: HomeAssistant, mock_external_calls: None, ) -> None: - """Test migrate config entry from 1.1 to 1.2.""" + """Test migrate config entry from 1.1, chaining through to the latest version.""" mock_config_entry = MockConfigEntry( unique_id="mock api key", @@ -61,9 +66,174 @@ async def test_migrate_entry_from_1_1( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.version == 1 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT, } + + +@pytest.mark.parametrize("collapsed_chat_index", [0, 1]) +@pytest.mark.parametrize( + "chats_without_notify_entity", + [ + pytest.param((), id="notify entities intact"), + pytest.param((654321,), id="notify entity deleted"), + ], +) +async def test_migrate_entry_to_per_chat_devices( + hass: HomeAssistant, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + collapsed_chat_index: int, + chats_without_notify_entity: tuple[int, ...], +) -> None: + """Test migrating chats sharing one bot device to per-chat devices.""" + bot_id = 123456 # test_user id from mock_external_calls + chat_ids = (123456, 654321) + config_entry = MockConfigEntry( + unique_id="mock api key", + domain=DOMAIN, + minor_version=2, + data={ + CONF_PLATFORM: PLATFORM_BROADCAST, + CONF_API_KEY: "mock api key", + CONF_API_ENDPOINT: DEFAULT_API_ENDPOINT, + }, + options={ATTR_PARSER: PARSER_MD}, + subentries_data=[ + ConfigSubentryData( + unique_id="123456", + data={CONF_CHAT_ID: 123456}, + subentry_type=CONF_ALLOWED_CHAT_IDS, + title="chat 1", + ), + ConfigSubentryData( + unique_id="654321", + data={CONF_CHAT_ID: 654321}, + subentry_type=CONF_ALLOWED_CHAT_IDS, + title="chat 2", + ), + ], + ) + config_entry.add_to_hass(hass) + subentry_ids = list(config_entry.subentries) + + # Post-store-migration state: one shared bot device collapsed onto an arbitrary chat + # subentry, holding the event entity and every surviving chat's notify entity. + bot_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=subentry_ids[collapsed_chat_index], + identifiers={(DOMAIN, str(bot_id))}, + ) + event_entity = entity_registry.async_get_or_create( + "event", + DOMAIN, + f"{bot_id}_update_event", + config_entry=config_entry, + device_id=bot_device.id, + ) + notify_entities = { + chat_id: entity_registry.async_get_or_create( + "notify", + DOMAIN, + f"{bot_id}_{chat_id}", + config_entry=config_entry, + config_subentry_id=subentry_id, + device_id=bot_device.id, + ) + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True) + if chat_id not in chats_without_notify_entity + } + + 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.minor_version == 3 + + # Every chat has its own device - owned by that subentry and linked to the bot device - + # even a chat whose notify entity was deleted before the migration ran. A surviving + # notify entity is moved onto its chat's device. + for subentry_id, chat_id in zip(subentry_ids, chat_ids, strict=True): + chat_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{bot_id}_{chat_id}")} + ) + assert chat_device is not None + assert chat_device.config_subentry_id == subentry_id + assert chat_device.via_device_id == bot_device.id + if chat_id in notify_entities: + assert ( + entity_registry.async_get(notify_entities[chat_id].entity_id).device_id + == chat_device.id + ) + + # The bot device was handed back to the config entry, keeping the event entity + bot_device = device_registry.async_get(bot_device.id) + assert bot_device is not None + assert bot_device.config_subentry_id is None + assert entity_registry.async_get(event_entity.entity_id).device_id == bot_device.id + + +async def test_per_chat_devices( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Each chat gets its own device linked to the config-entry-level bot device.""" + 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() + + # The bot device belongs to the config entry (no subentry) and holds the event entity + bot_device = device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) + assert bot_device is not None + assert bot_device.config_subentry_id is None + + for chat_id in (123456, 654321): + chat_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"123456_{chat_id}")} + ) + assert chat_device is not None + assert chat_device.config_subentry_id is not None + assert chat_device.via_device_id == bot_device.id + notify_entity_id = entity_registry.async_get_entity_id( + "notify", DOMAIN, f"123456_{chat_id}" + ) + assert notify_entity_id is not None + assert entity_registry.async_get(notify_entity_id).device_id == chat_device.id + + +async def test_remove_chat_subentry_removes_per_chat_device( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Removing a chat subentry removes just its per-chat device and notify entity.""" + 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() + + subentry_id = next( + sid + for sid, subentry in mock_broadcast_config_entry.subentries.items() + if subentry.data[CONF_CHAT_ID] == 123456 + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")}) + assert entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456") + + hass.config_entries.async_remove_subentry(mock_broadcast_config_entry, subentry_id) + await hass.async_block_till_done() + + # The removed chat's device and notify entity are gone; the other chat and the bot + # device remain + assert not device_registry.async_get_device(identifiers={(DOMAIN, "123456_123456")}) + assert not entity_registry.async_get_entity_id("notify", DOMAIN, "123456_123456") + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456_654321")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "123456")}) diff --git a/tests/components/telegram_bot/test_notify.py b/tests/components/telegram_bot/test_notify.py index 2305114127d9..ffa4daebff12 100644 --- a/tests/components/telegram_bot/test_notify.py +++ b/tests/components/telegram_bot/test_notify.py @@ -43,7 +43,7 @@ async def test_send_message( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", ATTR_MESSAGE: "mock message", ATTR_TITLE: "mock title", }, @@ -64,7 +64,7 @@ async def test_send_message( message_thread_id=None, ) - state = hass.states.get("notify.mock_title_mock_chat") + state = hass.states.get("notify.mock_chat") assert state assert state.state == "2025-01-09T12:00:00+00:00" diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index a4592826d752..3e7fd2848508 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -224,7 +224,7 @@ async def test_send_message( { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -322,7 +322,7 @@ async def test_send_message_with_inline_keyboard( { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -368,9 +368,9 @@ async def test_send_sticker_partial_error( assert err.value.translation_key == "multiple_errors" assert err.value.translation_placeholders == { "errors": ( - "`entity_id` notify.mock_title_mock_chat_1:" + "`entity_id` notify.mock_chat_1:" " mock network error\n" - "`entity_id` notify.mock_title_mock_chat_2:" + "`entity_id` notify.mock_chat_2:" " mock network error" ) } @@ -588,7 +588,7 @@ async def test_send_file(hass: HomeAssistant, webhook_bot, service: str) -> None { ATTR_CHAT_ID: 12345678, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat", + ATTR_ENTITY_ID: "notify.mock_chat", } ] } @@ -1076,7 +1076,7 @@ async def test_send_message_with_config_entry( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1187,7 +1187,7 @@ async def test_delete_message( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1616,7 +1616,7 @@ async def test_send_video( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1648,7 +1648,7 @@ async def test_send_video( { ATTR_CHAT_ID: 123456, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", } ] } @@ -1837,7 +1837,7 @@ async def test_send_message_multi_target( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -1857,7 +1857,7 @@ async def test_notify_entity_send_message( response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, - {ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", ATTR_MESSAGE: "test_message"}, + {ATTR_ENTITY_ID: "notify.mock_chat_2", ATTR_MESSAGE: "test_message"}, blocking=True, return_response=True, ) @@ -1867,7 +1867,7 @@ async def test_notify_entity_send_message( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -1921,7 +1921,7 @@ async def test_migrate_chat_id( { ATTR_CHAT_ID: 654321, ATTR_MESSAGE_ID: 12345, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_2", + ATTR_ENTITY_ID: "notify.mock_chat_2", } ] } @@ -2616,7 +2616,7 @@ async def test_send_media_group( "chats": [ { ATTR_CHAT_ID: 123456, - ATTR_ENTITY_ID: "notify.mock_title_mock_chat_1", + ATTR_ENTITY_ID: "notify.mock_chat_1", ATTR_MESSAGE_ID: [12345, 12346, 12347, 12348], } ] diff --git a/tests/components/teltonika/snapshots/test_init.ambr b/tests/components/teltonika/snapshots/test_init.ambr index 6280477ceeaa..1b0158f00d79 100644 --- a/tests/components/teltonika/snapshots/test_init.ambr +++ b/tests/components/teltonika/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry_creation DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://192.168.1.1', 'connections': set({ tuple( @@ -32,7 +32,6 @@ 'model_id': None, 'name': 'RUTX50 Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'RUTX_R_00.07.17.3', 'via_device_id': None, diff --git a/tests/components/template/snapshots/test_device_tracker.ambr b/tests/components/template/snapshots/test_device_tracker.ambr index 2d27c9c770e4..9066306a81bb 100644 --- a/tests/components/template/snapshots/test_device_tracker.ambr +++ b/tests/components/template/snapshots/test_device_tracker.ambr @@ -6,8 +6,8 @@ : 10.0, : list([ ]), - : 10.0, - : 40.0, + : 10.0, + : 40.0, : , : , }), diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 934c5f9ed92d..bbcdf7cfab25 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -321,7 +321,7 @@ async def test_config_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == template_type - availability = {"advanced_options": {"availability": "{{ True }}"}} + availability = {"additional_options": {"availability": "{{ True }}"}} with patch( "homeassistant.components.template.async_setup_entry", wraps=async_setup_entry @@ -1103,7 +1103,7 @@ async def test_config_flow_preview( assert result["preview"] == "template" availability = { - "advanced_options": { + "additional_options": { "availability": "{{ is_state('binary_sensor.available', 'on') }}" } } diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 0813e96bc461..9fe566ed877f 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -35,6 +35,7 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -42,6 +43,8 @@ from .conftest import ( setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry @@ -49,6 +52,7 @@ from tests.typing import WebSocketGenerator TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_POSITION_ENTITY_ID = "sensor.test_position" +TEST_TILT_POSITION_ENTITY_ID = "sensor.test_tilt_position" TEST_AVAILABILITY_ENTITY = "binary_sensor.availability" TEST_COVER = TemplatePlatformSetup( @@ -57,6 +61,7 @@ TEST_COVER = TemplatePlatformSetup( make_test_trigger( TEST_STATE_ENTITY_ID, TEST_POSITION_ENTITY_ID, + TEST_TILT_POSITION_ENTITY_ID, TEST_AVAILABILITY_ENTITY, ), ) @@ -1102,3 +1107,167 @@ async def test_flow_preview( ) assert state["state"] == CoverState.OPEN + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "position": "{{ states('sensor.test_position') | float(None) }}", + "set_cover_position": [], + "tilt": "{{ states('sensor.test_tilt_position') | float(None) }}", + "set_cover_tilt_position": [], + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + "final_state", + ), + [ + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + CoverState.OPEN, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.OPEN, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": True, + "is_closing": False, + }, + CoverState.OPENING, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.OPENING, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 10, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": True, + }, + CoverState.CLOSING, + { + "current_position": 10, + "current_tilt_position": 10, + }, + CoverState.CLOSING, + ), + ( + CoverState.OPEN, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + CoverState.CLOSED, + { + "current_position": 0, + "current_tilt_position": 10, + }, + CoverState.OPEN, + ), + ( + STATE_UNAVAILABLE, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + STATE_UNKNOWN, + { + "current_position": None, + "current_tilt_position": None, + }, + CoverState.OPEN, + ), + ( + STATE_UNKNOWN, + { + "current_cover_position": 0, + "current_cover_tilt_position": 10, + "is_opening": False, + "is_closing": False, + }, + STATE_UNKNOWN, + { + "current_position": None, + "current_tilt_position": None, + }, + CoverState.OPEN, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: CoverState | str, + saved_extra_data: dict | None, + initial_state: CoverState | str, + initial_attributes: ConfigType, + final_state: CoverState | str, +) -> None: + """Test restoring trigger template weather.""" + + restored_attributes = { # These should be ignored + "current_position": 5, + "current_tilt_position": 5, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_COVER, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_COVER, + style, + config, + "states('sensor.test_position') | float(0) > 50", + ) + + state = assert_state_and_attributes( + hass, + TEST_COVER, + initial_state, + initial_attributes, + ) + + await async_trigger(hass, "sensor.test_position", "75") + await async_trigger(hass, "sensor.test_tilt_position", "75") + + state = hass.states.get(TEST_COVER.entity_id) + assert state.state == final_state + assert state.attributes["current_position"] == 75 + assert state.attributes["current_tilt_position"] == 75 diff --git a/tests/components/template/test_device_tracker.py b/tests/components/template/test_device_tracker.py index b47d249def99..1eb3d54b490d 100644 --- a/tests/components/template/test_device_tracker.py +++ b/tests/components/template/test_device_tracker.py @@ -21,12 +21,15 @@ from homeassistant.helpers.typing import ConfigType from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_trigger, setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry, async_setup_component @@ -35,6 +38,7 @@ from tests.conftest import WebSocketGenerator TEST_STATE_ENTITY_ID = "sensor.test_state" TEST_LATITUDE_ENTITY_ID = "sensor.test_latitude" TEST_LONGITUDE_ENTITY_ID = "sensor.test_longitude" +TEST_LOCATION_ACCURACY_ENTITY_ID = "sensor.test_location_accuracy" TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.availability" TEST_TRACKER = TemplatePlatformSetup( device_tracker.DOMAIN, @@ -43,6 +47,7 @@ TEST_TRACKER = TemplatePlatformSetup( TEST_AVAILABILITY_ENTITY_ID, TEST_LATITUDE_ENTITY_ID, TEST_LONGITUDE_ENTITY_ID, + TEST_LOCATION_ACCURACY_ENTITY_ID, TEST_STATE_ENTITY_ID, ), ) @@ -150,7 +155,7 @@ async def test_setup_config_entry( options={ "name": TEST_TRACKER.object_id, **TEST_MINIMUM_REQUIREMENTS, - "advanced_options": {"location_accuracy": "{{ 10 }}"}, + "additional_options": {"location_accuracy": "{{ 10 }}"}, "template_type": device_tracker.DOMAIN, }, title="My template", @@ -623,3 +628,146 @@ async def test_flow_preview( assert state["state"] == STATE_NOT_HOME assert state["attributes"]["latitude"] == 10.0 assert state["attributes"]["longitude"] == 40.0 + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "in_zones": "{{ state_attr('sensor.test_state', 'in_zones') }}", + "latitude": "{{ states('sensor.test_latitude') | float(None) }}", + "longitude": "{{ states('sensor.test_longitude') | float(None) }}", + "location_accuracy": "{{ states('sensor.test_location_accuracy') | float(None) }}", + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + STATE_HOME, + { + "in_zones": ["zone.home"], + "latitude": 32.87336, + "longitude": 117.228743, + "location_accuracy": 5.0, + }, + STATE_HOME, + { + "in_zones": ["zone.home"], + "latitude": 32.87336, + "longitude": 117.228743, + "gps_accuracy": 5.0, + }, + ), + ( + STATE_NOT_HOME, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_NOT_HOME, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "gps_accuracy": 10.0, + }, + ), + ( + STATE_UNAVAILABLE, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": None, + "longitude": None, + "gps_accuracy": None, + }, + ), + ( + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": 15.0, + "longitude": 15.0, + "location_accuracy": 10.0, + }, + STATE_UNKNOWN, + { + "in_zones": [], + "latitude": None, + "longitude": None, + "gps_accuracy": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring trigger template device tracker.""" + + restored_attributes = { # These should be ignored + "latitude": 5, + "longitude": 5, + "location_accuracy": 2.0, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_TRACKER, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_TRACKER, + style, + config, + "states('sensor.test_latitude') | float(0) > 19.9", + ) + + state = assert_state_and_attributes( + hass, + TEST_TRACKER, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, "sensor.test_state", "anything", {"in_zones": ["zone.home"]} + ) + await async_trigger(hass, "sensor.test_latitude", "32.88") + await async_trigger(hass, "sensor.test_longitude", "117.24") + await async_trigger(hass, "sensor.test_location_accuracy", "20") + + state = hass.states.get(TEST_TRACKER.entity_id) + assert state.state == STATE_HOME + assert state.attributes["in_zones"] == ["zone.home"] + assert state.attributes["latitude"] == 32.88 + assert state.attributes["longitude"] == 117.24 + assert state.attributes["gps_accuracy"] == 20.0 diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index fc5ab9b78ee8..1c2ad479f9bb 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -26,6 +26,7 @@ from .conftest import ( ConfigurationStyle, TemplatePlatformSetup, assert_action, + assert_state_and_attributes, async_get_flow_preview_state, async_trigger, make_test_action, @@ -33,6 +34,8 @@ from .conftest import ( setup_and_test_nested_unique_id, setup_and_test_unique_id, setup_entity, + setup_mock_template_entity_restore_state, + setup_restore_template_entity, ) from tests.common import MockConfigEntry @@ -47,7 +50,7 @@ TEST_FAN = TemplatePlatformSetup( fan.DOMAIN, "test_fan", make_test_trigger( - TEST_INPUT_BOOLEAN, TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY + TEST_AVAILABILITY_ENTITY, TEST_INPUT_BOOLEAN, TEST_STATE_ENTITY_ID ), ) @@ -1366,3 +1369,230 @@ async def test_flow_preview( ) assert state["state"] == STATE_ON + + +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + { + "state": "{{ state_attr('sensor.test_sensor', 'is_on') }}", + "turn_on": [], + "turn_off": [], + "percentage": "{{ state_attr('sensor.test_sensor', 'percentage') }}", + "set_percentage": [], + "preset_mode": "{{ state_attr('sensor.test_sensor', 'preset_mode') }}", + "set_preset_mode": [], + "preset_modes": ["off", "auto", "low", "medium", "high"], + "oscillating": "{{ state_attr('sensor.test_sensor', 'oscillating') }}", + "set_oscillating": [], + "direction": "{{ state_attr('sensor.test_sensor', 'direction') }}", + "set_direction": [], + }, + ], +) +@pytest.mark.parametrize( + ( + "saved_state", + "saved_extra_data", + "initial_state", + "initial_attributes", + ), + [ + ( + STATE_ON, + { + "is_on": True, + "percentage": 10, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + STATE_ON, + { + "percentage": 10, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + ), + ( + STATE_OFF, + { + "is_on": False, + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + STATE_OFF, + { + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + ), + ( + STATE_UNAVAILABLE, + { + "is_on": True, + "percentage": 0, + "preset_mode": "auto", + "oscillating": True, + "direction": DIRECTION_FORWARD, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_UNKNOWN, + { + "is_on": False, + "percentage": 0, + "preset_mode": "off", + "oscillating": False, + "direction": DIRECTION_FORWARD, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "is_on": "True", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "percentage": "0", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "oscillating": "True", + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "preset_mode": 75, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ( + STATE_ON, + { + "direction": 75, + }, + STATE_UNKNOWN, + { + "percentage": None, + "preset_mode": None, + "oscillating": None, + "direction": None, + }, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, + config: ConfigType, + style: ConfigurationStyle, + saved_state: str, + saved_extra_data: dict | None, + initial_state: str, + initial_attributes: ConfigType, +) -> None: + """Test restoring template fan.""" + + restored_attributes = { # These should be ignored + "percentage": 45, + "preset_mode": "high", + "oscillating": True, + "direction": DIRECTION_REVERSE, + } + + setup_mock_template_entity_restore_state( + hass, + TEST_FAN, + saved_state, + saved_extra_data=saved_extra_data, + saved_attributes=restored_attributes, + ) + + await setup_restore_template_entity( + hass, + TEST_FAN, + style, + config, + f"states('{TEST_STATE_ENTITY_ID}') | float(0) > 10", + ) + + state = assert_state_and_attributes( + hass, + TEST_FAN, + initial_state, + initial_attributes, + ) + + await async_trigger( + hass, + TEST_STATE_ENTITY_ID, + "11", + { + "is_on": True, + "percentage": 55, + "preset_mode": "low", + "oscillating": True, + "direction": DIRECTION_REVERSE, + }, + ) + + state = hass.states.get(TEST_FAN.entity_id) + assert state.state == STATE_ON + assert state.attributes["percentage"] == 55 + assert state.attributes["preset_mode"] == "low" + assert state.attributes["oscillating"] is True + assert state.attributes["direction"] == DIRECTION_REVERSE diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 5c27ef80248a..edd85ec0dad0 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -532,7 +532,7 @@ async def test_migration_1_1( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from v1.1 removes template config entry from device.""" + """Test migration from v1.1 does not add the template config entry to the device.""" device_config_entry = MockConfigEntry() device_config_entry.add_to_hass(hass) @@ -557,29 +557,53 @@ async def test_migration_1_1( ) template_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - device_entry.id, add_config_entry_id=template_config_entry.entry_id - ) - - # Check preconditions - device_entry = device_registry.async_get(device_entry.id) - assert template_config_entry.entry_id in device_entry.config_entries - await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() assert template_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not in the device and the helper # entity is linked to the source device device_entry = device_registry.async_get(device_entry.id) assert template_config_entry.entry_id not in device_entry.config_entries template_entity_entry = entity_registry.async_get("sensor.my_template") assert template_entity_entry.device_id == device_entry.id - assert template_config_entry.version == 1 - assert template_config_entry.minor_version == 2 + assert template_config_entry.version == 2 + assert template_config_entry.minor_version == 1 + + +async def test_migration_1_2( + hass: HomeAssistant, +) -> None: + """Test migration from v1.2 renames the advanced_options section.""" + + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "template_type": "sensor", + "state": "{{ 'foo' }}", + "advanced_options": {"availability": "{{ True }}"}, + }, + title="My template", + version=1, + minor_version=2, + ) + template_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + assert template_config_entry.state is ConfigEntryState.LOADED + assert "advanced_options" not in template_config_entry.options + assert template_config_entry.options["additional_options"] == { + "availability": "{{ True }}" + } + + assert template_config_entry.version == 2 + assert template_config_entry.minor_version == 1 async def test_migration_from_future_version( @@ -595,7 +619,7 @@ async def test_migration_from_future_version( "state": "{{ 'foo' }}", }, title="My template", - version=2, + version=3, minor_version=1, ) config_entry.add_to_hass(hass) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 8528814f7f72..85eb83f01885 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -9,7 +9,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import template, vacuum from homeassistant.components.template.vacuum import CONF_CLEAN_SEGMENTS, CONF_SEGMENTS from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, Segment, VacuumActivity, @@ -86,14 +85,12 @@ TEMPLATE_VACUUM_ACTIONS = { def _verify( hass: HomeAssistant, expected_state: str, - expected_battery_level: int | None = None, expected_fan_speed: str | None = None, ) -> None: """Verify vacuum's state and speed.""" state = hass.states.get(TEST_VACUUM.entity_id) attributes = state.attributes assert state.state == expected_state - assert attributes.get(ATTR_BATTERY_LEVEL) == expected_battery_level assert attributes.get(ATTR_FAN_SPEED) == expected_fan_speed @@ -192,88 +189,74 @@ async def setup_attributes_state_vacuum( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "state_template", "extra_config", "parm1", "parm2"), + ("style", "state_template", "extra_config", "parm1"), [ ( ConfigurationStyle.MODERN, None, {"start": {"service": "script.vacuum_start"}}, STATE_UNKNOWN, - None, ), ( ConfigurationStyle.TRIGGER, None, {"start": {"service": "script.vacuum_start"}}, STATE_UNKNOWN, - None, ), ( ConfigurationStyle.MODERN, "{{ 'cleaning' }}", { - "battery_level": "{{ 100 }}", "start": {"service": "script.vacuum_start"}, }, VacuumActivity.CLEANING, - 100, ), ( ConfigurationStyle.TRIGGER, "{{ 'cleaning' }}", { - "battery_level": "{{ 100 }}", "start": {"service": "script.vacuum_start"}, }, VacuumActivity.CLEANING, - 100, ), ( ConfigurationStyle.MODERN, "{{ 'abc' }}", { - "battery_level": "{{ 101 }}", "start": {"service": "script.vacuum_start"}, }, STATE_UNKNOWN, - None, ), ( ConfigurationStyle.TRIGGER, "{{ 'abc' }}", { - "battery_level": "{{ 101 }}", "start": {"service": "script.vacuum_start"}, }, STATE_UNKNOWN, - None, ), ( ConfigurationStyle.MODERN, "{{ this_function_does_not_exist() }}", { - "battery_level": "{{ this_function_does_not_exist() }}", "fan_speed": "{{ this_function_does_not_exist() }}", "start": {"service": "script.vacuum_start"}, }, STATE_UNAVAILABLE, - None, ), ( ConfigurationStyle.TRIGGER, "{{ this_function_does_not_exist() }}", { - "battery_level": "{{ this_function_does_not_exist() }}", "fan_speed": "{{ this_function_does_not_exist() }}", "start": {"service": "script.vacuum_start"}, }, STATE_UNAVAILABLE, - None, ), ], ) @pytest.mark.usefixtures("setup_base_vacuum") -async def test_valid_configs(hass: HomeAssistant, count, parm1, parm2) -> None: +async def test_valid_configs(hass: HomeAssistant, count, parm1) -> None: """Test: configs.""" # Ensure trigger entity templates are rendered @@ -281,7 +264,7 @@ async def test_valid_configs(hass: HomeAssistant, count, parm1, parm2) -> None: await hass.async_block_till_done() assert len(hass.states.async_all("vacuum")) == count - _verify(hass, parm1, parm2) + _verify(hass, parm1) @pytest.mark.parametrize("count", [0]) @@ -302,68 +285,6 @@ async def test_invalid_configs(hass: HomeAssistant, count) -> None: assert len(hass.states.async_all("vacuum")) == count -@pytest.mark.parametrize( - ("count", "state_template", "extra_config"), - [(1, "{{ states('sensor.test_state') }}", {})], -) -@pytest.mark.parametrize( - ("style", "attribute"), - [ - (ConfigurationStyle.MODERN, "battery_level"), - (ConfigurationStyle.TRIGGER, "battery_level"), - ], -) -@pytest.mark.parametrize( - ("attribute_template", "expected"), - [ - ("{{ '0' }}", 0), - ("{{ 100 }}", 100), - ("{{ 101 }}", None), - ("{{ -1 }}", None), - ("{{ 'foo' }}", None), - ], -) -@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") -async def test_battery_level_template( - hass: HomeAssistant, expected: int | None -) -> None: - """Test templates with values from other entities.""" - await async_trigger(hass, TEST_STATE_ENTITY_ID) - _verify(hass, STATE_UNKNOWN, expected) - - -@pytest.mark.parametrize( - ("count", "state_template", "extra_config", "attribute_template"), - [(1, "{{ states('sensor.test_state') }}", {}, "{{ 50 }}")], -) -@pytest.mark.parametrize( - ("style", "attribute", "issue_count"), - [ - (ConfigurationStyle.MODERN, "battery_level", 1), - (ConfigurationStyle.TRIGGER, "battery_level", 1), - ], -) -@pytest.mark.usefixtures("setup_single_attribute_state_vacuum") -async def test_battery_level_template_repair( - hass: HomeAssistant, - issue_count: int, - issue_registry: ir.IssueRegistry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test battery_level template raises issue.""" - await async_trigger(hass, TEST_STATE_ENTITY_ID, VacuumActivity.DOCKED) - - assert len(issue_registry.issues) == issue_count - issue = issue_registry.async_get_issue( - "template", f"deprecated_battery_level_{TEST_VACUUM.entity_id}" - ) - assert issue.domain == "template" - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders["entity_name"] == TEST_VACUUM.object_id - assert issue.translation_placeholders["entity_id"] == TEST_VACUUM.entity_id - assert "Detected that integration 'template' is setting the" not in caplog.text - - @pytest.mark.parametrize( ("count", "state_template", "extra_config"), [ @@ -396,7 +317,7 @@ async def test_battery_level_template_repair( async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> None: """Test templates with values from other entities.""" await async_trigger(hass, TEST_STATE_ENTITY_ID) - _verify(hass, STATE_UNKNOWN, None, expected) + _verify(hass, STATE_UNKNOWN, expected) @pytest.mark.parametrize( diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index ec276c5436fb..602778a6bf23 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : -30.222626, - : -97.6236871, + : -30.222626, + : -97.6236871, : , : , }), @@ -104,8 +104,8 @@ : 0, : list([ ]), - : 30.2226265, - : -97.6236871, + : 30.2226265, + : -97.6236871, : , : , }), diff --git a/tests/components/tesla_fleet/snapshots/test_init.ambr b/tests/components/tesla_fleet/snapshots/test_init.ambr index 7ce999659005..7edbf70f56c0 100644 --- a/tests/components/tesla_fleet/snapshots/test_init.ambr +++ b/tests/components/tesla_fleet/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[{('tesla_fleet', '123456')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Energy Site', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[{('tesla_fleet', 'LRWXF7EK4KC700000')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'LRWXF7EK4KC700000', 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[{('tesla_fleet', 'abd-123')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_devices[{('tesla_fleet', 'bcd-234')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '234', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index 9eb12961dfa2..e1de5866a5f3 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -68,6 +68,30 @@ async def test_press( command.assert_called_once() +async def test_homelink_no_location( + hass: HomeAssistant, normal_config_entry: MockConfigEntry +) -> None: + """Test pressing homelink without vehicle location raises a translated error.""" + await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) + + coordinator = normal_config_entry.runtime_data.vehicles[0].coordinator + coordinator.data.pop("drive_state_latitude", None) + coordinator.data.pop("drive_state_longitude", None) + + with ( + patch("tesla_fleet_api.tesla.VehicleFleet.trigger_homelink") as command, + pytest.raises(HomeAssistantError) as error, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ["button.test_homelink"]}, + blocking=True, + ) + assert error.value.translation_key == "homelink_no_location" + command.assert_not_called() + + async def test_press_signing_error( hass: HomeAssistant, normal_config_entry: MockConfigEntry, mock_products: AsyncMock ) -> None: diff --git a/tests/components/tesla_fleet/test_media_player.py b/tests/components/tesla_fleet/test_media_player.py index 3233246b8b5d..a164d2cbc08c 100644 --- a/tests/components/tesla_fleet/test_media_player.py +++ b/tests/components/tesla_fleet/test_media_player.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline @@ -13,6 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, MediaPlayerState, ) from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -37,6 +39,35 @@ async def test_media_player( assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) +async def test_media_player_volume_step( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, +) -> None: + """Test volume_up raises the level by exactly one Tesla notch.""" + + await setup_platform(hass, normal_config_entry, [Platform.MEDIA_PLAYER]) + + entity_id = "media_player.test_media_player" + + with patch( + "tesla_fleet_api.tesla.VehicleFleet.adjust_volume", + return_value=COMMAND_OK, + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # One notch up from the vehicle_data fixture's audio_volume of 1.6667 in a + # 10.333333 range: (1.6667 + 0.333333) / 10.333333. + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx( + 0.1935516, abs=1e-4 + ) + + async def test_media_player_alt( hass: HomeAssistant, snapshot: SnapshotAssertion, diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index b69ebfef6a92..de3cb20c4392 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -6,13 +6,13 @@ from typing import Any from unittest.mock import MagicMock, patch import pytest -from tesla_wall_connector.wall_connector import Lifetime, Version, Vitals +from tesla_wall_connector.wall_connector import Lifetime, Version, Vitals, WifiStatus from homeassistant.components.tesla_wall_connector.const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, ) -from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -52,13 +52,16 @@ def get_default_version_data(): async def create_wall_connector_entry( - hass: HomeAssistant, side_effect=None, vitals_data=None, lifetime_data=None + hass: HomeAssistant, + side_effect: type[Exception] | Exception | None = None, + vitals_data: Vitals | None = None, + lifetime_data: Lifetime | None = None, + wifi_status_data: WifiStatus | None = None, ) -> MockConfigEntry: """Create a wall connector entry in hass.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.3.4"}, - options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) entry.add_to_hass(hass) @@ -79,6 +82,11 @@ async def create_wall_connector_entry( return_value=lifetime_data, side_effect=side_effect, ), + patch( + "tesla_wall_connector.WallConnector.async_get_wifi_status", + return_value=wifi_status_data, + side_effect=side_effect, + ), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -101,6 +109,7 @@ def get_vitals_mock() -> Vitals: mock.currentA_a = 10 mock.currentB_a = 11.1 mock.currentC_a = 12 + mock.vehicle_current_a = 32 mock.total_power_w = 7650.3 mock.session_energy_wh = 1234.56 mock.contactor_closed = False @@ -113,6 +122,13 @@ def get_lifetime_mock() -> Lifetime: return MagicMock(auto_spec=Lifetime) +def get_wifi_status_mock() -> WifiStatus: + """Get mocked Wi-Fi status object.""" + mock = MagicMock(auto_spec=WifiStatus) + mock.wifi_rssi = -42 + return mock + + @dataclass class EntityAndExpectedValues: """Class for keeping entity id along with expected update values.""" @@ -124,17 +140,22 @@ class EntityAndExpectedValues: async def _test_sensors( hass: HomeAssistant, - entities_and_expected_values, + entities_and_expected_values: list[EntityAndExpectedValues], vitals_first_update: Vitals, vitals_second_update: Vitals, lifetime_first_update: Lifetime, lifetime_second_update: Lifetime, + wifi_status_first_update: WifiStatus, + wifi_status_second_update: WifiStatus, ) -> None: """Test update of sensor values.""" # First Update: Data is fetched when the integration is initialized await create_wall_connector_entry( - hass, vitals_data=vitals_first_update, lifetime_data=lifetime_first_update + hass, + vitals_data=vitals_first_update, + lifetime_data=lifetime_first_update, + wifi_status_data=wifi_status_first_update, ) # Verify expected vs actual values of first update @@ -156,11 +177,15 @@ async def _test_sensors( "tesla_wall_connector.WallConnector.async_get_lifetime", return_value=lifetime_second_update, ), + patch( + "tesla_wall_connector.WallConnector.async_get_wifi_status", + return_value=wifi_status_second_update, + ), ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL) ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Verify expected vs actual values of second update for entity in entities_and_expected_values: diff --git a/tests/components/tesla_wall_connector/test_binary_sensor.py b/tests/components/tesla_wall_connector/test_binary_sensor.py index 3990369262d3..050f5c152813 100644 --- a/tests/components/tesla_wall_connector/test_binary_sensor.py +++ b/tests/components/tesla_wall_connector/test_binary_sensor.py @@ -7,6 +7,7 @@ from .conftest import ( _test_sensors, get_lifetime_mock, get_vitals_mock, + get_wifi_status_mock, ) @@ -29,6 +30,7 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_second_update.vehicle_connected = False lifetime_mock = get_lifetime_mock() + wifi_status_mock = get_wifi_status_mock() await _test_sensors( hass, @@ -37,4 +39,6 @@ async def test_sensors(hass: HomeAssistant) -> None: vitals_second_update=mock_vitals_second_update, lifetime_first_update=lifetime_mock, lifetime_second_update=lifetime_mock, + wifi_status_first_update=wifi_status_mock, + wifi_status_second_update=wifi_status_mock, ) diff --git a/tests/components/tesla_wall_connector/test_config_flow.py b/tests/components/tesla_wall_connector/test_config_flow.py index fc1f41995155..2fe7a9f5fe94 100644 --- a/tests/components/tesla_wall_connector/test_config_flow.py +++ b/tests/components/tesla_wall_connector/test_config_flow.py @@ -1,17 +1,29 @@ """Test the Tesla Wall Connector config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from tesla_wall_connector.exceptions import WallConnectorConnectionError from homeassistant import config_entries -from homeassistant.components.tesla_wall_connector.const import DOMAIN +from homeassistant.components.tesla_wall_connector.const import ( + CONF_SPLIT_PHASE, + DEFAULT_SPLIT_PHASE, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST 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 +from .conftest import ( + get_default_version_data, + get_lifetime_mock, + get_vitals_mock, + get_wifi_status_mock, +) + +from tests.common import MockConfigEntry, get_schema_suggested_value async def test_form(mock_wall_connector_version, hass: HomeAssistant) -> None: @@ -35,6 +47,7 @@ async def test_form(mock_wall_connector_version, hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Tesla Wall Connector" assert result2["data"] == {CONF_HOST: "1.1.1.1"} + assert result2["options"] == {CONF_SPLIT_PHASE: DEFAULT_SPLIT_PHASE} assert len(mock_setup_entry.mock_calls) == 1 @@ -50,11 +63,19 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: "1.1.1.1"}, + {CONF_HOST: "1.1.1.1", CONF_SPLIT_PHASE: True}, ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_HOST) + == "1.1.1.1" + ) + assert ( + get_schema_suggested_value(result2["data_schema"].schema, CONF_SPLIT_PHASE) + is True + ) async def test_form_other_error( @@ -131,6 +152,79 @@ async def test_dhcp_can_finish( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_HOST: "1.2.3.4"} + assert result["options"] == {CONF_SPLIT_PHASE: DEFAULT_SPLIT_PHASE} + + +async def test_form_with_split_phase(hass: HomeAssistant) -> None: + """Test setting single-phase / split-phase during setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with ( + patch( + "tesla_wall_connector.WallConnector.async_get_version", + return_value=get_default_version_data(), + ), + patch( + "homeassistant.components.tesla_wall_connector.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_SPLIT_PHASE: True, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == {CONF_HOST: "1.1.1.1"} + assert result2["options"] == {CONF_SPLIT_PHASE: True} + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + options={CONF_SPLIT_PHASE: False}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tesla_wall_connector.WallConnector" + ) as wall_connector: + client = wall_connector.return_value + client.async_get_version = AsyncMock(return_value=get_default_version_data()) + client.async_get_vitals = AsyncMock(return_value=get_vitals_mock()) + client.async_get_lifetime = AsyncMock(return_value=get_lifetime_mock()) + client.async_get_wifi_status = AsyncMock(return_value=get_wifi_status_mock()) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + assert wall_connector.call_args.kwargs[CONF_SPLIT_PHASE] is False + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + wall_connector.reset_mock() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SPLIT_PHASE: True}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_SPLIT_PHASE: True} + assert entry.options == {CONF_SPLIT_PHASE: True} + assert entry.state is ConfigEntryState.LOADED + assert wall_connector.call_args.kwargs[CONF_SPLIT_PHASE] is True async def test_dhcp_already_exists( diff --git a/tests/components/tesla_wall_connector/test_init.py b/tests/components/tesla_wall_connector/test_init.py index fbb3abc17467..04ef327c354c 100644 --- a/tests/components/tesla_wall_connector/test_init.py +++ b/tests/components/tesla_wall_connector/test_init.py @@ -2,20 +2,43 @@ from tesla_wall_connector.exceptions import WallConnectorConnectionError +from homeassistant.components.tesla_wall_connector.const import ( + DOMAIN, + WALLCONNECTOR_DEVICE_MANUFACTURER, + WALLCONNECTOR_DEVICE_MODEL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from .conftest import create_wall_connector_entry, get_lifetime_mock, get_vitals_mock +from .conftest import ( + create_wall_connector_entry, + get_lifetime_mock, + get_vitals_mock, + get_wifi_status_mock, +) -async def test_init_success(hass: HomeAssistant) -> None: +async def test_init_success( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test setup and that we get the device info, including firmware version.""" entry = await create_wall_connector_entry( - hass, vitals_data=get_vitals_mock(), lifetime_data=get_lifetime_mock() + hass, + vitals_data=get_vitals_mock(), + lifetime_data=get_lifetime_mock(), + wifi_status_data=get_wifi_status_mock(), ) assert entry.state is ConfigEntryState.LOADED + device = device_registry.async_get_device(identifiers={(DOMAIN, "abc123")}) + assert device + assert device.manufacturer == WALLCONNECTOR_DEVICE_MANUFACTURER + assert device.model == WALLCONNECTOR_DEVICE_MODEL + assert device.model_id == "part_123" + assert device.serial_number == "abc123" + assert device.sw_version == "1.2.3" async def test_init_while_offline(hass: HomeAssistant) -> None: @@ -31,7 +54,10 @@ async def test_load_unload(hass: HomeAssistant) -> None: """Config entry can be unloaded.""" entry = await create_wall_connector_entry( - hass, vitals_data=get_vitals_mock(), lifetime_data=get_lifetime_mock() + hass, + vitals_data=get_vitals_mock(), + lifetime_data=get_lifetime_mock(), + wifi_status_data=get_wifi_status_mock(), ) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index 07746beb7ad7..261f92ffab40 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -7,6 +7,7 @@ from .conftest import ( _test_sensors, get_lifetime_mock, get_vitals_mock, + get_wifi_status_mock, ) @@ -35,6 +36,9 @@ async def test_sensors(hass: HomeAssistant) -> None: EntityAndExpectedValues( "sensor.tesla_wall_connector_energy", "988.022", "989.0" ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_vehicle_current", "32", "16" + ), EntityAndExpectedValues( "sensor.tesla_wall_connector_phase_a_current", "10", "7" ), @@ -59,6 +63,7 @@ async def test_sensors(hass: HomeAssistant) -> None: EntityAndExpectedValues( "sensor.tesla_wall_connector_session_energy", "1.23456", "0.1122" ), + EntityAndExpectedValues("sensor.tesla_wall_connector_wifi_rssi", "-42", "-54"), ] mock_vitals_first_update = get_vitals_mock() @@ -76,6 +81,7 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_second_update.currentA_a = 7 mock_vitals_second_update.currentB_a = 8 mock_vitals_second_update.currentC_a = 9 + mock_vitals_second_update.vehicle_current_a = 16 mock_vitals_second_update.total_power_w = 5499.5 mock_vitals_second_update.session_energy_wh = 112.2 @@ -83,6 +89,9 @@ async def test_sensors(hass: HomeAssistant) -> None: lifetime_mock_first_update.energy_wh = 988022 lifetime_mock_second_update = get_lifetime_mock() lifetime_mock_second_update.energy_wh = 989000 + wifi_status_first_update = get_wifi_status_mock() + wifi_status_second_update = get_wifi_status_mock() + wifi_status_second_update.wifi_rssi = -54 await _test_sensors( hass, @@ -91,4 +100,6 @@ async def test_sensors(hass: HomeAssistant) -> None: vitals_second_update=mock_vitals_second_update, lifetime_first_update=lifetime_mock_first_update, lifetime_second_update=lifetime_mock_second_update, + wifi_status_first_update=wifi_status_first_update, + wifi_status_second_update=wifi_status_second_update, ) diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index de385735b61a..88c28648dc43 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -17,6 +17,10 @@ VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ASLEEP = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ASLEEP["response"]["state"] = TeslemetryState.OFFLINE VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) +VEHICLE_DATA_NONE = load_json_object_fixture("vehicle_data.json", DOMAIN) +VEHICLE_DATA_NONE["response"]["vehicle_state"]["ft"] = None +VEHICLE_DATA_NONE["response"]["vehicle_state"]["rt"] = None +VEHICLE_DATA_NONE["response"]["charge_state"]["charge_port_door_open"] = None LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) SITE_INFO_WEEK_CROSSING = load_json_object_fixture( diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index e65442579b2f..bff34849d51c 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -410,7 +410,7 @@ # name: test_select_streaming[climate.test_cabin_overheat_protection] StateSnapshot({ 'attributes': ReadOnlyDict({ - : None, + : 26, : 'Test Cabin overheat protection', : list([ , diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index f00138333468..dc00633792f6 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -476,6 +476,271 @@ 'state': 'open', }) # --- +# name: test_cover_none[cover.test_charge_port_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge port door', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_charge_port_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Test Charge port door', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.test_charge_port_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_cover_none[cover.test_frunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frunk', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_frunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Test Frunk', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_cover_none[cover.test_sunroof-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sunroof', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'window', + : 'Test Sunroof', + : False, + : , + }), + 'context': , + 'entity_id': 'cover.test_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_none[cover.test_trunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Trunk', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_trunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'door', + : 'Test Trunk', + : None, + : , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_cover_none[cover.test_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.test_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Windows', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'LRW3F7EK4NC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_none[cover.test_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'window', + : 'Test Windows', + : True, + : , + }), + 'context': , + 'entity_id': 'cover.test_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- # name: test_cover_noscope[cover.test_charge_port_door-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 3644f1530207..6c5f86dbef5f 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : -30.222626, - : -97.6236871, + : -30.222626, + : -97.6236871, : , : , }), @@ -104,8 +104,8 @@ : 0, : list([ ]), - : 30.2226265, - : -97.6236871, + : 30.2226265, + : -97.6236871, : , : , }), @@ -124,8 +124,8 @@ : 0, : list([ ]), - : -30.222626, - : -97.6236871, + : -30.222626, + : -97.6236871, : , : , }), @@ -144,8 +144,8 @@ : 0, : list([ ]), - : 30.2226265, - : -97.6236871, + : 30.2226265, + : -97.6236871, : , : , }), diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index 722aacd989b8..a4b842e36a23 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[{('teslemetry', '123456')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console/energy/123456', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Energy Site', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123456', 'sw_version': '23.44.0 eb113390', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[{('teslemetry', 'LRW3F7EK4NC700000')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console/vehicle/LRW3F7EK4NC700000', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': '3', 'name': 'Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'LRW3F7EK4NC700000', 'sw_version': '2026.0.0', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[{('teslemetry', 'abd-123')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '123', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_devices[{('teslemetry', 'bcd-234')}] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Wall Connector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '234', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 096deb8341f6..8b0dc76c820b 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -130,7 +130,7 @@ : 0, : '/api/brands/integration/teslemetry/icon.png', : 'Test Update', - : False, + : True, : '2025.1.1', : '2025.2.1', : None, @@ -138,6 +138,31 @@ : None, : , : None, + : 50, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[install_complete] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : False, + : '2025.1.1', + : '2025.2.1', + : None, + : None, + : None, + : , + : None, : None, }), 'context': , @@ -155,7 +180,7 @@ : 0, : '/api/brands/integration/teslemetry/icon.png', : 'Test Update', - : False, + : True, : '2025.1.1', : '2025.2.1', : None, @@ -163,7 +188,7 @@ : None, : , : None, - : None, + : 50, }), 'context': , 'entity_id': 'update.test_update', @@ -248,3 +273,53 @@ 'state': 'off', }) # --- +# name: test_update_streaming_scheduled_not_clobbered[downloading_after_schedule_cleared] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : True, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : 5, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_update_streaming_scheduled_not_clobbered[scheduled] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 0, + : '/api/brands/integration/teslemetry/icon.png', + : 'Test Update', + : True, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index c7640d991044..68342935a822 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -1,5 +1,6 @@ """Test the Teslemetry climate platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest @@ -19,7 +20,7 @@ from homeassistant.components.climate import ( SERVICE_TURN_ON, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -28,6 +29,7 @@ from . import assert_entities, reload_platform, setup_platform from .const import ( COMMAND_ERRORS, COMMAND_IGNORED_REASON, + METADATA, METADATA_NOSCOPE, VEHICLE_DATA_ALT, ) @@ -202,6 +204,26 @@ async def test_climate_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +async def test_climate_state_unknown( + hass: HomeAssistant, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, +) -> None: + """Test that a missing climate_state_is_climate_on reports unknown, not off.""" + + metadata = deepcopy(METADATA) + metadata["vehicles"]["LRW3F7EK4NC700000"]["polling"] = True + mock_metadata.return_value = metadata + + data = deepcopy(VEHICLE_DATA_ALT) + data["response"]["climate_state"]["is_climate_on"] = None + mock_vehicle_data.return_value = data + + await setup_platform(hass, [Platform.CLIMATE]) + + assert hass.states.get("climate.test_climate").state == STATE_UNKNOWN + + async def test_invalid_error(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Tests service error is handled.""" diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 7184e488a03a..4e45ab2c6dd8 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -20,7 +20,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import assert_entities, setup_platform -from .const import COMMAND_ERRORS, COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT +from .const import ( + COMMAND_ERRORS, + COMMAND_OK, + METADATA_NOSCOPE, + VEHICLE_DATA_ALT, + VEHICLE_DATA_NONE, +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -51,6 +57,21 @@ async def test_cover_alt( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_cover_none( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Tests that polling covers report unknown when coordinator data is null.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_NONE + entry = await setup_platform(hass, [Platform.COVER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_noscope( hass: HomeAssistant, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 8088ee094202..2e37d0c1d9c6 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -10,13 +10,16 @@ import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import ( Forbidden, + InsufficientCredits, InvalidResponse, InvalidToken, + LoginRequired, RateLimited, SubscriptionRequired, TeslaFleetError, ) +from homeassistant.components.teslemetry import _get_access_token from homeassistant.components.teslemetry.const import CLIENT_ID, DOMAIN # Coordinator constants @@ -24,10 +27,12 @@ from homeassistant.components.teslemetry.coordinator import ( ENERGY_HISTORY_INTERVAL, ENERGY_INFO_INTERVAL, ENERGY_LIVE_INTERVAL, + INSUFFICIENT_CREDITS_RETRY_AFTER, METADATA_INTERVAL, VEHICLE_INTERVAL, ) from homeassistant.components.teslemetry.models import TeslemetryData +from homeassistant.components.teslemetry.oauth import TeslemetryImplementation from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -37,9 +42,17 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.update_coordinator import UpdateFailed -from . import setup_platform +from . import mock_config_entry, setup_platform from .const import ( CONFIG_V1, ENERGY_HISTORY, @@ -58,10 +71,16 @@ from tests.common import MockConfigEntry, async_fire_time_changed ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), + (LoginRequired, ConfigEntryState.SETUP_ERROR), (SubscriptionRequired, ConfigEntryState.SETUP_ERROR), (TeslaFleetError, ConfigEntryState.SETUP_RETRY), ] +VEHICLE_ERRORS = [ + *ERRORS, + (InsufficientCredits, ConfigEntryState.SETUP_RETRY), +] + async def test_load_unload(hass: HomeAssistant) -> None: """Test load and unload.""" @@ -101,7 +120,7 @@ async def test_devices( assert device == snapshot(name=f"{device.identifiers}") -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +@pytest.mark.parametrize(("side_effect", "state"), VEHICLE_ERRORS) async def test_vehicle_refresh_error( hass: HomeAssistant, mock_vehicle_data: AsyncMock, @@ -996,3 +1015,149 @@ async def test_dynamic_device_discovery_no_reload_without_changes( # Verify reload was NOT triggered since no subscription changes mock_reload.assert_not_called() + + +async def test_insufficient_credits_backs_off_polling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Running out of command credits should back off, not hammer the API every poll.""" + call_count = 0 + + def vehicle_data_side_effect(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return deepcopy(VEHICLE_DATA) + raise InsufficientCredits + + mock_vehicle_data.side_effect = vehicle_data_side_effect + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert call_count == 1 + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert call_count == 2 + assert entry.state is ConfigEntryState.LOADED + + coordinator = entry.runtime_data.vehicles[0].coordinator + assert isinstance(coordinator.last_exception, UpdateFailed) + assert coordinator.last_exception.retry_after == INSUFFICIENT_CREDITS_RETRY_AFTER + + +def _oauth_session(hass: HomeAssistant, entry: MockConfigEntry) -> OAuth2Session: + """Build an OAuth2Session for directly exercising _get_access_token.""" + return OAuth2Session(hass, entry, TeslemetryImplementation(hass, DOMAIN, CLIENT_ID)) + + +async def test_get_access_token_dead_token_during_setup_triggers_auth_failed( + hass: HomeAssistant, +) -> None: + """A dead/revoked refresh token during setup must raise ConfigEntryAuthFailed. + + OAuth servers commonly report a dead refresh token with a non-401 status + (e.g. 400 invalid_grant). Only recognizing status 401 let this fall + through to ConfigEntryNotReady, which retries setup indefinitely without + ever prompting the user to reauthenticate. + """ + mock_entry = mock_config_entry() + mock_entry.add_to_hass(hass) + mock_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS) + session = _oauth_session(hass, mock_entry) + + with ( + patch.object( + OAuth2Session, + "async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=MagicMock(), status=400, domain=DOMAIN + ), + ), + pytest.raises(ConfigEntryAuthFailed), + ): + await _get_access_token(session) + + +async def test_get_access_token_rate_limited_during_setup_is_not_fatal( + hass: HomeAssistant, +) -> None: + """A 429 from the token endpoint during setup should back off, not be fatal.""" + mock_entry = mock_config_entry() + mock_entry.add_to_hass(hass) + mock_entry.mock_state(hass, ConfigEntryState.SETUP_IN_PROGRESS) + session = _oauth_session(hass, mock_entry) + + with ( + patch.object( + OAuth2Session, + "async_ensure_token_valid", + side_effect=OAuth2TokenRequestTransientError( + request_info=MagicMock(), status=429, domain=DOMAIN + ), + ), + pytest.raises(ConfigEntryNotReady), + ): + await _get_access_token(session) + + +async def test_get_access_token_dead_token_after_setup_starts_reauth( + hass: HomeAssistant, +) -> None: + """Test a token dying after setup (re)starts reauth without tearing down. + + The coordinator handles the rest once the exception is re-raised. + """ + mock_entry = mock_config_entry() + mock_entry.add_to_hass(hass) + mock_entry.mock_state(hass, ConfigEntryState.LOADED) + session = _oauth_session(hass, mock_entry) + + with ( + patch.object( + OAuth2Session, + "async_ensure_token_valid", + side_effect=OAuth2TokenRequestReauthError( + request_info=MagicMock(), status=400, domain=DOMAIN + ), + ), + pytest.raises(OAuth2TokenRequestReauthError), + ): + await _get_access_token(session) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert any( + flow["handler"] == DOMAIN and flow["context"].get("source") == "reauth" + for flow in flows + ) + + +async def test_get_access_token_rate_limited_after_setup_is_not_fatal( + hass: HomeAssistant, +) -> None: + """A transient token-refresh error after setup must not force reauth.""" + mock_entry = mock_config_entry() + mock_entry.add_to_hass(hass) + mock_entry.mock_state(hass, ConfigEntryState.LOADED) + session = _oauth_session(hass, mock_entry) + + with ( + patch.object( + OAuth2Session, + "async_ensure_token_valid", + side_effect=OAuth2TokenRequestTransientError( + request_info=MagicMock(), status=429, domain=DOMAIN + ), + ), + pytest.raises(OAuth2TokenRequestTransientError), + ): + await _get_access_token(session) + await hass.async_block_till_done() + + assert not hass.config_entries.flow.async_progress() diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index a7e58d525ba2..b9b893652206 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -1,5 +1,6 @@ """Test the Teslemetry select platform.""" +from collections.abc import Awaitable, Callable from copy import deepcopy from unittest.mock import AsyncMock, patch @@ -15,15 +16,25 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL -from homeassistant.components.teslemetry.select import LOW +from homeassistant.components.teslemetry.coordinator import ( + ENERGY_INFO_INTERVAL, + VEHICLE_INTERVAL, +) +from homeassistant.components.teslemetry.select import HIGH, LEVEL, LOW, MEDIUM, OFF 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 . import assert_entities, reload_platform, setup_platform -from .const import COMMAND_ERRORS, COMMAND_OK, METADATA, SITE_INFO, VEHICLE_DATA_ALT +from .const import ( + COMMAND_ERRORS, + COMMAND_OK, + METADATA, + METADATA_LEGACY, + SITE_INFO, + VEHICLE_DATA_ALT, +) from tests.common import async_fire_time_changed @@ -183,6 +194,68 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: call.assert_called_once() +@pytest.mark.parametrize( + ("entity_id", "seat_position"), + [ + ("select.test_seat_cooler_front_left", 1), + ("select.test_seat_cooler_front_right", 2), + ], +) +async def test_seat_cooler_services( + hass: HomeAssistant, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, + entity_id: str, + seat_position: int, +) -> None: + """Test the seat cooler entities send the 1-indexed seat position. + + remote_seat_cooler_request is 1-indexed (front-left=1, front-right=2), + unlike the 0-indexed Seat enum used for the seat heaters. + """ + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + metadata = deepcopy(METADATA) + metadata["vehicles"][VEHICLE_VIN]["config"] = {"has_seat_cooling": True} + mock_metadata.return_value = metadata + + await setup_platform(hass, [Platform.SELECT]) + + with patch( + "tesla_fleet_api.teslemetry.Vehicle.remote_seat_cooler_request", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW}, + blocking=True, + ) + assert hass.states.get(entity_id).state == LOW + call.assert_called_once_with(seat_position, LEVEL[LOW]) + + +async def test_seat_cooler_polling( + hass: HomeAssistant, + mock_metadata: AsyncMock, + mock_vehicle_data: AsyncMock, +) -> None: + """Test the seat cooler entities read polled state from seat_fan_front_*.""" + metadata = deepcopy(METADATA) + metadata["vehicles"][VEHICLE_VIN]["polling"] = True + metadata["vehicles"][VEHICLE_VIN]["config"] = {"has_seat_cooling": True} + mock_metadata.return_value = metadata + + data = deepcopy(VEHICLE_DATA_ALT) + data["response"]["climate_state"]["seat_fan_front_left"] = 2 + data["response"]["climate_state"]["seat_fan_front_right"] = 0 + mock_vehicle_data.return_value = data + + await setup_platform(hass, [Platform.SELECT]) + + assert hass.states.get("select.test_seat_cooler_front_left").state == MEDIUM + assert hass.states.get("select.test_seat_cooler_front_right").state == OFF + + @pytest.mark.parametrize("response", COMMAND_ERRORS) async def test_select_command_errors( hass: HomeAssistant, mock_vehicle_data: AsyncMock, response: dict @@ -286,6 +359,79 @@ async def test_select_streaming( assert hass.states.get("select.test_steering_wheel_heater").state == "off" +async def _drive_polling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + value: int, +) -> None: + """Push a steering wheel level through the polling path.""" + data = deepcopy(VEHICLE_DATA_ALT) + data["response"]["climate_state"]["steering_wheel_heat_level"] = value + mock_vehicle_data.return_value = data + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + + +async def _drive_streaming( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + value: int, +) -> None: + """Push a steering wheel level through the streaming path.""" + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": {Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: value}, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + + +@pytest.mark.parametrize( + ("metadata", "driver"), + [ + pytest.param(METADATA_LEGACY, _drive_polling, id="polling"), + pytest.param(METADATA, _drive_streaming, id="streaming"), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param(2, HIGH, id="level_2_clamped"), + pytest.param(3, HIGH, id="level_3_clamped"), + ], +) +async def test_steering_wheel_heat_levels( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_vehicle_data: AsyncMock, + mock_metadata: AsyncMock, + mock_add_listener: AsyncMock, + metadata: dict, + driver: Callable[ + [HomeAssistant, FrozenDateTimeFactory, AsyncMock, AsyncMock, int], + Awaitable[None], + ], + value: int, + expected: str, +) -> None: + """A level beyond the last modeled option clamps to that option, high.""" + freezer.move_to("2024-01-01 00:00:00+00:00") + mock_metadata.return_value = metadata + + await setup_platform(hass, [Platform.SELECT]) + + await driver(hass, freezer, mock_vehicle_data, mock_add_listener, value) + await hass.async_block_till_done() + + state = hass.states.get("select.test_steering_wheel_heater") + assert state.state == expected + + async def test_export_rule_restore( hass: HomeAssistant, mock_site_info: AsyncMock, diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index af6c9d847f16..6c3d48f61cdb 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -11,14 +11,14 @@ from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.update import INSTALLING from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL -from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache async def test_update( @@ -113,6 +113,8 @@ async def test_update_streaming( await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 50 assert state == snapshot(name="downloading") mock_add_listener.send( @@ -130,6 +132,9 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + # Install percentages up to 10% reflect Tesla's pre-installation step, not real progress + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="ready") mock_add_listener.send( @@ -147,8 +152,30 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 50 assert state == snapshot(name="installing") + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + # 100% installed is complete, not in progress + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None + assert state == snapshot(name="install_complete") + mock_add_listener.send( { "vin": VEHICLE_DATA_ALT["response"]["vin"], @@ -164,9 +191,193 @@ async def test_update_streaming( ) await hass.async_block_till_done() state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="updated") await reload_platform(hass, entry, [Platform.UPDATE]) state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is False + assert state.attributes["update_percentage"] is None assert state == snapshot(name="restored") + + +async def test_update_streaming_scheduled_not_clobbered( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Test that a scheduled install stays in progress until real progress or cancellation.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.UPDATE]) + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: 1735689600, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] is None + assert state == snapshot(name="scheduled") + + # Download begins and the schedule clears in the same payload: progress must win + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 5, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 5 + assert state == snapshot(name="downloading_after_schedule_cleared") + + +async def test_update_streaming_restore( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, +) -> None: + """Test that the streaming update entity restores update_percentage, not install_percentage.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entity_id = "update.test_update" + mock_restore_cache( + hass, + ( + State( + entity_id, + STATE_ON, + attributes={ + "in_progress": True, + "update_percentage": 42, + "installed_version": "2025.1.1", + "latest_version": "2025.2.1", + }, + ), + ), + ) + + await setup_platform(hass, [Platform.UPDATE]) + + state = hass.states.get(entity_id) + assert state.attributes["in_progress"] is True + assert state.attributes["update_percentage"] == 42 + + +@pytest.mark.parametrize( + ("data", "expected_in_progress", "expected_percentage"), + [ + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 0, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="download_0pct_is_idle", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + True, + 1, + id="download_1pct_is_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="download_100pct_is_complete", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_1pct_is_not_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 10, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_10pct_is_not_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 11, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + True, + 11, + id="install_11pct_is_in_progress", + ), + pytest.param( + { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + }, + False, + None, + id="install_100pct_is_complete", + ), + ], +) +async def test_update_streaming_progress_thresholds( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, + data: dict[Signal, int | None], + expected_in_progress: bool, + expected_percentage: int | None, +) -> None: + """Test download/install progress threshold edge cases.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.UPDATE]) + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": data, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state.attributes["in_progress"] is expected_in_progress + assert state.attributes["update_percentage"] == expected_percentage diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr index 429624290317..30d548927ec4 100644 --- a/tests/components/tessie/snapshots/test_device_tracker.ambr +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -46,8 +46,8 @@ 'heading': 185, : list([ ]), - : -30.222626, - : -97.6236871, + : -30.222626, + : -97.6236871, : , 'speed': None, : , @@ -106,8 +106,8 @@ : 0, : list([ ]), - : 30.2226265, - : -97.6236871, + : 30.2226265, + : -97.6236871, : , : , }), diff --git a/tests/components/tessie/snapshots/test_text.ambr b/tests/components/tessie/snapshots/test_text.ambr new file mode 100644 index 000000000000..4064adbbbf86 --- /dev/null +++ b/tests/components/tessie/snapshots/test_text.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_text_entities[text.test_navigation_destination-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 255, + : 1, + : , + : None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'text', + 'entity_category': None, + 'entity_id': 'text.test_navigation_destination', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Navigation destination', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Navigation destination', + 'platform': 'tessie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'navigation_destination', + 'unique_id': 'VINVINVIN-navigation_destination', + 'unit_of_measurement': None, + }) +# --- +# name: test_text_entities[text.test_navigation_destination-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Navigation destination', + : 255, + : 1, + : , + : None, + }), + 'context': , + 'entity_id': 'text.test_navigation_destination', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tessie/test_text.py b/tests/components/tessie/test_text.py new file mode 100644 index 000000000000..e19ae0e32be6 --- /dev/null +++ b/tests/components/tessie/test_text.py @@ -0,0 +1,75 @@ +"""Test the Tessie text platform.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_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 .common import ERROR_UNKNOWN, assert_entities, setup_platform + +NAVIGATION_ENTITY_ID = "text.test_navigation_destination" + + +async def test_text_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the navigation text entity is set up correctly.""" + entry = await setup_platform(hass, [Platform.TEXT]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_set_navigation_destination(hass: HomeAssistant) -> None: + """Test sending a navigation destination to the vehicle.""" + await setup_platform(hass, [Platform.TEXT]) + + with patch( + "tesla_fleet_api.tessie.Vehicle.navigation_request", + ) as mock_nav: + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: NAVIGATION_ENTITY_ID, + ATTR_VALUE: "1 Infinite Loop, Cupertino, CA", + }, + blocking=True, + ) + mock_nav.assert_called_once_with("1 Infinite Loop, Cupertino, CA") + + +async def test_set_navigation_destination_error(hass: HomeAssistant) -> None: + """Test that a transport error is translated to HomeAssistantError.""" + await setup_platform(hass, [Platform.TEXT]) + + with ( + patch( + "tesla_fleet_api.tessie.Vehicle.navigation_request", + side_effect=ERROR_UNKNOWN, + ) as mock_nav, + pytest.raises(HomeAssistantError) as error, + ): + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: NAVIGATION_ENTITY_ID, + ATTR_VALUE: "Times Square, New York", + }, + blocking=True, + ) + + mock_nav.assert_called_once() + assert error.value.translation_domain == "tessie" + assert error.value.translation_key == "cannot_connect" diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 0f92a0c0e68e..92bbb62fcd95 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -265,18 +265,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, threshold_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the threshold config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() @@ -288,15 +280,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.threshold.async_unload_entry", wraps=threshold.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -305,6 +294,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") assert threshold_entity_entry.device_id is None + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -470,7 +462,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes threshold config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" threshold_config_entry = MockConfigEntry( data={}, @@ -488,22 +480,13 @@ async def test_migration_1_1( ) threshold_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(threshold_config_entry.entry_id) await hass.async_block_till_done() assert threshold_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index 12ee4ca3e6e3..0289c9fac02b 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -49,8 +49,8 @@ 'is_lost': False, 'last_lost_timestamp': datetime.datetime(1970, 1, 1, 3, 0, tzinfo=datetime.timezone.utc), 'last_timestamp': datetime.datetime(2020, 8, 13, 0, 55, 26, tzinfo=datetime.timezone.utc), - : 1, - : 1, + : 1, + : 1, 'ring_state': 'STOPPED', : , : , diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr index 9e2620313a0d..d86c2fccbbf3 100644 --- a/tests/components/tile/snapshots/test_init.ambr +++ b/tests/components/tile/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_info DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Wallet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '01.12.14.0', 'via_device_id': None, diff --git a/tests/components/todo/test_llm.py b/tests/components/todo/test_llm.py new file mode 100644 index 000000000000..e0a373634cf6 --- /dev/null +++ b/tests/components/todo/test_llm.py @@ -0,0 +1,129 @@ +"""Tests for the todo LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component, todo +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.todo import llm as todo_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import config_validation as cv, llm +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + +ENTITY_ID = "todo.test_list" + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a to-do list.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "todo", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "0", {"friendly_name": "Mock Todo List Name"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_get_tools_no_exposed_todo(hass: HomeAssistant) -> None: + """Test no todo tool is offered when no to-do list is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + assert "todo_get_items" not in [tool.name for tool in result.tools] + assert todo_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert todo_llm.async_get_tools(hass, _llm_context(), "other") is None + + +async def test_todo_get_items_tool(hass: HomeAssistant) -> None: + """Test the todo get items tool is exposed and works via the platform.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next((tool for tool in result.tools if tool.name == "todo_get_items"), None) + assert tool is not None + assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ + ENTITY_ID: { + "items": [ + {"uid": "1234", "summary": "Buy milk", "status": "needs_action"}, + ] + } + }, + ) + + result = await tool.async_call( + hass, + llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), + llm_context, + ) + + assert len(calls) == 1 + assert calls[0].data == {"entity_id": [ENTITY_ID], "status": ["needs_action"]} + assert result == { + "success": True, + "result": [{"uid": "1234", "status": "needs_action", "summary": "Buy milk"}], + } + + +@pytest.mark.parametrize( + ("status", "expected_data"), + [ + # "all" is sent without a status filter, so the service returns every item. + ("all", {"entity_id": [ENTITY_ID]}), + ("needs_action", {"entity_id": [ENTITY_ID], "status": ["needs_action"]}), + ("completed", {"entity_id": [ENTITY_ID], "status": ["completed"]}), + ], +) +async def test_todo_get_items_status_filter( + hass: HomeAssistant, status: str, expected_data: dict[str, list[str]] +) -> None: + """Test the status filter is translated into the service call.""" + llm_context = _llm_context() + result = await llm_component.async_get_tools(hass, llm_context, "assist") + tool = next(tool for tool in result.tools if tool.name == "todo_get_items") + + calls = async_mock_service( + hass, + domain=todo.DOMAIN, + service=todo.TodoServices.GET_ITEMS, + schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), + response={ENTITY_ID: {"items": []}}, + ) + await tool.async_call( + hass, + llm.ToolInput( + "todo_get_items", {"todo_list": "Mock Todo List Name", "status": status} + ), + llm_context, + ) + assert calls[0].data == expected_data + + +async def test_todo_list_intents_exposed(hass: HomeAssistant) -> None: + """Test the todo list intents are exposed as tools when a list is exposed.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + names = {tool.name for tool in result.tools} + assert "HassListAddItem" in names + assert "HassListCompleteItem" in names + assert "HassListRemoveItem" in names diff --git a/tests/components/todo/test_trigger.py b/tests/components/todo/test_trigger.py index b3af229f6948..e63493f1bb8b 100644 --- a/tests/components/todo/test_trigger.py +++ b/tests/components/todo/test_trigger.py @@ -93,8 +93,12 @@ def target_todo_lists( label_list_one = label_registry.async_create("label_list_one") label_list_two = label_registry.async_create("label_list_two") - device_list_one = dr.DeviceEntry(id="device_list_one") - device_list_two = dr.DeviceEntry(id="device_list_two") + device_list_one = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device_list_one" + ) + device_list_two = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device_list_two" + ) mock_device_registry( hass, { diff --git a/tests/components/togrill/snapshots/test_init.ambr b/tests/components/togrill/snapshots/test_init.ambr index e4208e702ccb..dab4387e538f 100644 --- a/tests/components/togrill/snapshots/test_init.ambr +++ b/tests/components/togrill/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup_device_present DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'Pro-05', 'name': 'Pro-05', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index e2e4f37c2621..17a67905ff30 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -419,8 +419,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -445,7 +445,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index 4d0149b13483..b654ca41dc25 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -611,8 +611,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -637,7 +637,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index 0f9158c0e9d5..b6404ac7da79 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -55,8 +55,8 @@ # name: test_states[my_camera-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -81,7 +81,6 @@ 'model_id': None, 'name': 'my_camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index f40b2e512da3..bba51badaf66 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -69,8 +69,8 @@ # name: test_states[thermostat-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -91,7 +91,6 @@ 'model_id': None, 'name': 'thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': , diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index 993a8d671613..38818dc04a8f 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -173,8 +173,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -199,7 +199,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index f1fdeba67238..8b3dcca8e269 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index a5e70e452e57..6ae6dd424ae0 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 2c845448bb0b..b6163e9e0a52 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index b0dec2f49acc..06e036d4ddaa 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -2,8 +2,8 @@ # name: test_states[hub-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 3654e37c8b2b..e5137655d40b 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index 19344d96c889..a057c3523817 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -2,8 +2,8 @@ # name: test_states[my_vacuum-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'my_vacuum', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -70,7 +69,7 @@ 'platform': 'tplink', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': '123456789ABCDEFGH-vacuum', 'unit_of_measurement': None, @@ -79,15 +78,13 @@ # name: test_states[vacuum.my_vacuum-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-charging-100', - 'battery_level': 100, : 'max', : list([ 'quiet', 'max', ]), : 'my_vacuum', - : , + : , }), 'context': , 'entity_id': 'vacuum.my_vacuum', diff --git a/tests/components/tplink/test_vacuum.py b/tests/components/tplink/test_vacuum.py index 649eaa7f5aea..3c7f165aae18 100644 --- a/tests/components/tplink/test_vacuum.py +++ b/tests/components/tplink/test_vacuum.py @@ -5,7 +5,6 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, DOMAIN as VACUUM_DOMAIN, SERVICE_LOCATE, @@ -62,7 +61,6 @@ async def test_vacuum( assert state.state == VacuumActivity.DOCKED assert state.attributes[ATTR_FAN_SPEED] == "max" - assert state.attributes[ATTR_BATTERY_LEVEL] == 100 result = translation.async_translate_state( hass, "max", "vacuum", "tplink", "vacuum.state_attributes.fan_speed", None ) diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py new file mode 100644 index 000000000000..6d830b6c8d6e --- /dev/null +++ b/tests/components/traccar/test_device_tracker.py @@ -0,0 +1,72 @@ +"""The tests for the Traccar device tracker platform.""" + +import pytest + +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + TrackerEntityStateAttribute, +) +from homeassistant.components.device_tracker.legacy import Device +from homeassistant.components.traccar import DOMAIN +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_WEBHOOK_ID, + STATE_NOT_HOME, + EntityStateAttribute, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_restore_cache + +DEVICE_ID = "device_1" +ENTITY_ID = f"{DEVICE_TRACKER_DOMAIN}.{DEVICE_ID}" + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: + """Mock device tracker config loading.""" + + +async def test_restore_state(hass: HomeAssistant) -> None: + """Test that the previous location is restored for a known device.""" + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, {}) + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_WEBHOOK_ID: "webhook_id"}) + entry.add_to_hass(hass) + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, DEVICE_ID)}, + ) + + mock_restore_cache( + hass, + [ + State( + ENTITY_ID, + STATE_NOT_HOME, + { + EntityStateAttribute.LATITUDE: 1.0, + EntityStateAttribute.LONGITUDE: 2.0, + TrackerEntityStateAttribute.GPS_ACCURACY: 30, + ATTR_BATTERY_LEVEL: 40, + "altitude": 50, + "bearing": 60, + "speed": 70, + }, + ) + ], + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[EntityStateAttribute.LATITUDE] == 1.0 + assert state.attributes[EntityStateAttribute.LONGITUDE] == 2.0 + assert state.attributes[TrackerEntityStateAttribute.GPS_ACCURACY] == 30 + assert state.attributes[ATTR_BATTERY_LEVEL] == 40 + assert state.attributes["altitude"] == 50 + assert state.attributes["bearing"] == 60 + assert state.attributes["speed"] == 70 diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr index 6ec0373decfa..bfab86d92e01 100644 --- a/tests/components/tractive/snapshots/test_device_tracker.ambr +++ b/tests/components/tractive/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 99, : list([ ]), - : 22.333, - : 44.555, + : 22.333, + : 44.555, : , : , }), diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 689074c463fa..c6f9a783ef97 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -190,18 +190,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, trend_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, ) -> None: - """Test the trend config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed but the source device is not removed.""" assert await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() @@ -213,15 +205,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source entity, this does not remove the source device with patch( "homeassistant.components.trend.async_unload_entry", wraps=trend.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_called_once() @@ -229,6 +218,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d # Check that the helper entity is removed assert not entity_registry.async_get("binary_sensor.my_trend") + # Check that the source device is not removed + assert device_registry.async_get(sensor_device.id) is not None + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -394,7 +386,7 @@ async def test_migration_1_1( sensor_entity_entry: er.RegistryEntry, sensor_device: dr.DeviceEntry, ) -> None: - """Test migration from v1.1 removes trend config entry from device.""" + """Test migration from v1.1 keeps the helper entity linked to the source device.""" trend_config_entry = MockConfigEntry( data={}, @@ -410,22 +402,13 @@ async def test_migration_1_1( ) trend_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=trend_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(trend_config_entry.entry_id) await hass.async_block_till_done() assert trend_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper - # entity is linked to the source device + # Check that the helper config entry is not in the device and the helper entity + # is linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") diff --git a/tests/components/trmnl/snapshots/test_init.ambr b/tests/components/trmnl/snapshots/test_init.ambr index 64e84eda1a00..0da5c5be56b6 100644 --- a/tests/components/trmnl/snapshots/test_init.ambr +++ b/tests/components/trmnl/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'Test TRMNL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 6c9cbee97bde..30be6ff71032 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry[0hlcxgoadnrh03yaqkydsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'ay30hrndaogxclh0', 'name': 'LCDÕ▒ŵ©®µ╣┐Õ║ªõ©çÞâ¢ÚüѵĺÕÖ¿', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_device_registry[0qtza8cv6q5rdxpxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'xpxdr5q6vc8aztq0', 'name': 'Weather station', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_device_registry[0wep74vtderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': '47pew0', 'name': 'TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_device_registry[18yvbamhgkjc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': 'hmabvy81', 'name': 'Interruptor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_device_registry[1nw1rysgyj8th1l5qbnxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': '5l1ht8jygsyr1wn1', 'name': 'Panneaux solaires 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_device_registry[2k8wyjo7iidkohuczc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': 'cuhokdii7ojyw8k2', 'name': 'Buitenverlichting', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +182,8 @@ # name: test_device_registry[2myxayqtud9aqbizsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': 'zibqa9dutqyaxym2', 'name': 'Dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -219,8 +212,8 @@ # name: test_device_registry[2pxfek1jjrtctiyglam] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': 'gyitctrjj1kefxp2', 'name': 'Multifunction alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -250,8 +242,8 @@ # name: test_device_registry[2w46jyhngklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': 'nhyj64w2', 'name': 'Tapparelle studio', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -281,8 +272,8 @@ # name: test_device_registry[2x473nefusdo7af6zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': '6fa7odsufen374x2', 'name': 'Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -312,8 +302,8 @@ # name: test_device_registry[3d4yosotwk27nqxvzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': 'vxqn72kwtosoy4d3', 'name': 'Garage Socket', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -343,8 +332,8 @@ # name: test_device_registry[3kdnp0ajo7zdolfxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': 'xflodz7oja0pndk3', 'name': 'Sensor T & H Server Home', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -374,8 +362,8 @@ # name: test_device_registry[3phkffywh5nnlj5vbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -396,7 +384,6 @@ 'model_id': 'v5jlnn5hwyffkhp3', 'name': 'Production', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -405,8 +392,8 @@ # name: test_device_registry[3uqk1csjqplf3uxqscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -427,7 +414,6 @@ 'model_id': 'qxu3flpqjsc1kqu3', 'name': 'Garage Contact Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -436,8 +422,8 @@ # name: test_device_registry[49m7h9lh3t8pq6ftzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -458,7 +444,6 @@ 'model_id': 'tf6qp8t3hl9h7m94', 'name': 'Consommation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -467,8 +452,8 @@ # name: test_device_registry[4bxfp3kgncpcgx5uycjzs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -489,7 +474,6 @@ '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, @@ -498,8 +482,8 @@ # name: test_device_registry[4fO1qIzYbcdMUHqAjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -520,7 +504,6 @@ 'model_id': 'AqHUMdcbYzIq1Of4', 'name': 'Landing', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -529,8 +512,8 @@ # name: test_device_registry[4hbnivc4w2rsw966lc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -551,7 +534,6 @@ 'model_id': '669wsr2w4cvinbh4', 'name': 'VIVIDSTORM SCREEN', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -560,8 +542,8 @@ # name: test_device_registry[4pa1uobdjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -582,7 +564,6 @@ 'model_id': 'dbou1ap4', 'name': 'Lumy Garage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +572,8 @@ # name: test_device_registry[4q5c2am8n1bwb6bszc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +594,6 @@ 'model_id': 'sb6bwb1n8ma2c5q4', 'name': 'Socket4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -622,8 +602,8 @@ # name: test_device_registry[51tdkcsamisw9ukycp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -644,7 +624,6 @@ 'model_id': 'yku9wsimasckdt15', 'name': 'Framboisier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -653,8 +632,8 @@ # name: test_device_registry[53apxfah2qoxb1cgkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -675,7 +654,6 @@ 'model_id': 'gc1bxoq2hafxpa35', 'name': 'Полотенцосушитель', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -684,8 +662,8 @@ # name: test_device_registry[53fnjncm3jywuaznps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -706,7 +684,6 @@ 'model_id': 'nzauwyj3mcnjnf35', 'name': 'Garage Camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -715,8 +692,8 @@ # name: test_device_registry[5ebss29hqqmse7t5psm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -737,7 +714,6 @@ 'model_id': '5t7esmqqh92ssbe5', 'name': 'Slimme kattenbak', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -746,8 +722,8 @@ # name: test_device_registry[5gfyvvg48bsxbbnjzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -768,7 +744,6 @@ 'model_id': 'jnbbxsb84gvvyfg5', 'name': 'Bathroom Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -777,8 +752,8 @@ # name: test_device_registry[63cninaczt9dwo7v2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -799,7 +774,6 @@ 'model_id': 'v7owd9tzcaninc36', 'name': 'Gateway2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -808,8 +782,8 @@ # name: test_device_registry[69dth3rxgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -830,7 +804,6 @@ 'model_id': 'xr3htd96', 'name': 'Humy toilettes RDC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -839,8 +812,8 @@ # name: test_device_registry[6ffyxwrjsuydxhqrqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -861,7 +834,6 @@ 'model_id': 'rqhxdyusjrwxyff6', 'name': 'Smart IR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -870,8 +842,8 @@ # name: test_device_registry[6gsqieoh1yzjvxlnjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -892,7 +864,6 @@ 'model_id': 'nlxvjzy1hoeiqsg6', 'name': 'hall 💡 ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -901,8 +872,8 @@ # name: test_device_registry[6h8boeqxorpsmtj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -923,7 +894,6 @@ 'model_id': 'xqeob8h6', 'name': 'S1-TY-BLE-PRO 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -932,8 +902,8 @@ # name: test_device_registry[6o148laaosbf0g4djd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -954,7 +924,6 @@ 'model_id': 'd4g0fbsoaal841o6', 'name': 'WC D1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -963,8 +932,8 @@ # name: test_device_registry[6pd3bkidqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -985,7 +954,6 @@ 'model_id': 'dikb3dp6', 'name': 'Medidor de Energia', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -994,8 +962,8 @@ # name: test_device_registry[6tbtkuv3tal1aesfjxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1016,7 +984,6 @@ 'model_id': 'fsea1lat3vuktbt6', 'name': 'BR 7-in-1 WLAN Wetterstation Anthrazit', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1025,8 +992,8 @@ # name: test_device_registry[6wxksqu35c61sce9dsf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1047,7 +1014,6 @@ 'model_id': '9ecs16c53uqskxw6', 'name': 'ceiling fan/Light v2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1056,8 +1022,8 @@ # name: test_device_registry[73ov8i8iedtylkzrqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1078,7 +1044,6 @@ 'model_id': 'rzklytdei8i8vo37', 'name': 'balkonbewässerung', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1087,8 +1052,8 @@ # name: test_device_registry[7axah58vfydd8cphjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1109,7 +1074,6 @@ 'model_id': 'hpc8ddyfv85haxa7', 'name': 'Garage', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1118,8 +1082,8 @@ # name: test_device_registry[7jxnjpiltmj2zyaijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1140,7 +1104,6 @@ 'model_id': 'iayz2jmtlipjnxj7', 'name': 'LED Porch 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1149,8 +1112,8 @@ # name: test_device_registry[7obpyhy8scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1171,7 +1134,6 @@ 'model_id': '8yhypbo7', 'name': 'Boîte aux lettres - arrière', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1180,8 +1142,8 @@ # name: test_device_registry[7xpq8plg06p46j7ygklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1202,7 +1164,6 @@ 'model_id': 'y7j64p60glp8qpx7', 'name': 'Fenster Küche', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1211,8 +1172,8 @@ # name: test_device_registry[7zogt3pcwhxhu8upqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1233,7 +1194,6 @@ 'model_id': 'pu8uhxhwcp3tgoz7', 'name': 'Socket3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1242,8 +1202,8 @@ # name: test_device_registry[86kdcut3hiqqddlijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1264,7 +1224,6 @@ 'model_id': 'ilddqqih3tucdk68', 'name': 'Ieskas', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1273,8 +1232,8 @@ # name: test_device_registry[87yarxyp23ap1vazjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1295,7 +1254,6 @@ 'model_id': 'zav1pa32pyxray78', 'name': 'Gengske 💡 ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1304,8 +1262,8 @@ # name: test_device_registry[8m3ggyvgycjwz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1326,7 +1284,6 @@ 'model_id': 'gvygg3m8', 'name': 'humid pelargonia', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1335,8 +1292,8 @@ # name: test_device_registry[8u5ftxkt52smougesc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1357,7 +1314,6 @@ 'model_id': 'eguoms25tkxtf5u8', 'name': 'Arida Stavern ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1366,8 +1322,8 @@ # name: test_device_registry[97k3pwirjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1388,7 +1344,6 @@ 'model_id': 'riwp3k79', 'name': 'LED KEUKEN 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1397,8 +1352,8 @@ # name: test_device_registry[9AzrW5XtELTySJxqzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1419,7 +1374,6 @@ 'model_id': 'qxJSyTLEtX5WrzA9', 'name': 'LivR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1428,8 +1382,8 @@ # name: test_device_registry[9Ry4oUpdAYq8Pe0Bkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1450,7 +1404,6 @@ 'model_id': 'B0eP8qYAdpUo4yR9', 'name': 'ITC-308-WIFI Thermostat', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1459,8 +1412,8 @@ # name: test_device_registry[9c1vlsxoscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1481,7 +1434,6 @@ 'model_id': 'oxslv1c9', 'name': 'Window downstairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1490,8 +1442,8 @@ # name: test_device_registry[9oh1h1uyalfykgg4bdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1512,7 +1464,6 @@ 'model_id': '4ggkyflayu1h1ho9', 'name': 'XOCA-DAC212XC V2-S1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1521,8 +1472,8 @@ # name: test_device_registry[9wlo8cpzprhiclrkgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1543,7 +1494,6 @@ 'model_id': 'krlcihrpzpc8olw9', 'name': 'IFS-STD002', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1552,8 +1502,8 @@ # name: test_device_registry[AUTwCwqDY9EjlQSocm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1574,7 +1524,6 @@ 'model_id': 'oSQljE9YDqwCwTUA', 'name': 'Kippenluik', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1583,8 +1532,8 @@ # name: test_device_registry[CyD4ctKVrAFSSXSbjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1605,7 +1554,6 @@ 'model_id': 'bSXSSFArVKtc4DyC', 'name': 'bedroom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1614,8 +1562,8 @@ # name: test_device_registry[HzsAAAKFLPABVi8nzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1636,7 +1584,6 @@ 'model_id': 'n8iVBAPLFKAAAszH', 'name': 'Steckdose 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1645,8 +1592,8 @@ # name: test_device_registry[JLWRUpPiwMTwKXtTtq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1667,7 +1614,6 @@ 'model_id': 'TtXKwTMwiPpURWLJ', 'name': 'Dining-Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1676,8 +1622,8 @@ # name: test_device_registry[LJ9zTFQTfMgsG2Ahzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1698,7 +1644,6 @@ 'model_id': 'hA2GsgMfTQFTz9JL', 'name': 'Spot 4', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1707,8 +1652,8 @@ # name: test_device_registry[LS6FfVBVU1vzBRBHzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1729,7 +1674,6 @@ 'model_id': 'HBRBzv1UVBVfF6SL', 'name': 'Rewireable Plug 6930HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1738,8 +1682,8 @@ # name: test_device_registry[LmLMc0ht1KW2zYAIkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1760,7 +1704,6 @@ '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, @@ -1769,8 +1712,8 @@ # name: test_device_registry[NVjuXIQ6QH9eZLHCzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1791,7 +1734,6 @@ 'model_id': 'CHLZe9HQ6QIXujVN', 'name': 'schuur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1800,8 +1742,8 @@ # name: test_device_registry[O8QpxJwdme33sqn4gk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1822,7 +1764,6 @@ 'model_id': '4nqs33emdwJxpQ8O', 'name': 'office lights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1831,8 +1772,8 @@ # name: test_device_registry[VA4QyBNZHkJ2Xa4hjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1853,7 +1794,6 @@ 'model_id': 'h4aX2JkHZNByQ4AV', 'name': 'Entry Stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1862,8 +1802,8 @@ # name: test_device_registry[YQLkAe7nyyAxXHiAzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1884,7 +1824,6 @@ 'model_id': 'AiHXxAyyn7eAkLQY', 'name': 'Solar Heater Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1893,8 +1832,8 @@ # name: test_device_registry[ZDldMHS0tjmQgGxEzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1915,7 +1854,6 @@ 'model_id': 'ExGgQmjt0SHMdlDZ', 'name': 'Casa1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1924,8 +1862,8 @@ # name: test_device_registry[ZgXzZULP6dDp4Atvgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1946,7 +1884,6 @@ 'model_id': 'vtA4pDd6PLUZzXgZ', 'name': 'Humy bain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1955,8 +1892,8 @@ # name: test_device_registry[a3qtb7pulkcc6jdjqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1977,7 +1914,6 @@ 'model_id': 'jdj6ccklup7btq3a', 'name': 'Eau Chaude', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1986,8 +1922,8 @@ # name: test_device_registry[a4zeazrz1ata9mbggk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2008,7 +1944,6 @@ 'model_id': 'gbm9ata1zrzaez4a', 'name': 'QT-Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2017,8 +1952,8 @@ # name: test_device_registry[a6ugbo3of3hqf4jojd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2039,7 +1974,6 @@ 'model_id': 'oj4fqh3fo3obgu6a', 'name': 'L├ímpara Ati', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2048,8 +1982,8 @@ # name: test_device_registry[aa99hccfnzvypr3zjsywc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2070,7 +2004,6 @@ 'model_id': 'z3rpyvznfcch99aa', 'name': 'PIXI Smart Drinking Fountain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2079,8 +2012,8 @@ # name: test_device_registry[addr6y4u8gb43nl8brnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2101,7 +2034,6 @@ 'model_id': '8ln34bg8u4y6rdda', 'name': 'Madimack Elite V3 Pool Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2110,8 +2042,8 @@ # name: test_device_registry[ai9swgb6tyinbwbxjxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2132,7 +2064,6 @@ '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, @@ -2141,8 +2072,8 @@ # name: test_device_registry[aiag5pku0x39rkfllc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2163,7 +2094,6 @@ 'model_id': 'lfkr93x0ukp5gaia', 'name': 'Projector Screen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2172,8 +2102,8 @@ # name: test_device_registry[aje5kxgmhhxdihqizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2194,7 +2124,6 @@ 'model_id': 'iqhidxhhmgxk5eja', 'name': 'Powerplug 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2203,8 +2132,8 @@ # name: test_device_registry[ajkdo1bm2rcmpuufjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2225,7 +2154,6 @@ 'model_id': 'fuupmcr2mb1odkja', 'name': 'Slaapkamer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2234,8 +2162,8 @@ # name: test_device_registry[ake0bre784zriw0usc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2256,7 +2184,6 @@ 'model_id': 'u0wirz487erb0eka', 'name': 'Déshumidificateur Silencieux OmniDry 20L avec Mode Linge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2265,8 +2192,8 @@ # name: test_device_registry[ao3z3oeyvepe8o3xqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2287,7 +2214,6 @@ 'model_id': 'x3o8epevyeo3z3oa', 'name': 'Interior Bedroom Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2296,8 +2222,8 @@ # name: test_device_registry[aoyweq8xbx7qfndijd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2318,7 +2244,6 @@ 'model_id': 'idnfq7xbx8qewyoa', 'name': 'AB1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2327,8 +2252,8 @@ # name: test_device_registry[ase6htln9tdni2sijxq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2349,7 +2274,6 @@ 'model_id': 'is2indt9nlth6esa', 'name': 'Frysen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2358,8 +2282,8 @@ # name: test_device_registry[b6e05dfy4qhpgea1qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2380,7 +2304,6 @@ 'model_id': '1aegphq4yfd50e6b', 'name': 'jardin Fraises', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2389,8 +2312,8 @@ # name: test_device_registry[bFFsO8HimyAJGIj7scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2411,7 +2334,6 @@ 'model_id': '7jIGJAymiH8OsFFb', 'name': 'Door Garage ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2420,8 +2342,8 @@ # name: test_device_registry[bak2crzmabancwqvjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2442,7 +2364,6 @@ 'model_id': 'vqwcnabamzrc2kab', 'name': 'Strip 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2451,8 +2372,8 @@ # name: test_device_registry[bcyciyhhu1g2gk9rqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2473,7 +2394,6 @@ 'model_id': 'r9kg2g1uhhyicycb', 'name': 'P1 Energia Elettrica', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2482,8 +2402,8 @@ # name: test_device_registry[bescacsciyam3aouqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2504,7 +2424,6 @@ 'model_id': 'uoa3mayicscacseb', 'name': 'Living room left', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2513,8 +2432,8 @@ # name: test_device_registry[bfpewgk8r6fhmissdyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2535,7 +2454,6 @@ 'model_id': 'ssimhf6r8kgwepfb', 'name': 'BlissRadia ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2544,8 +2462,8 @@ # name: test_device_registry[bgnj6bafrdgb1xmajd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2566,7 +2484,6 @@ 'model_id': 'amx1bgdrfab6jngb', 'name': 'Lumy Hall', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2575,8 +2492,8 @@ # name: test_device_registry[bjum5isf7h6xpbrvzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2597,7 +2514,6 @@ 'model_id': 'vrbpx6h7fsi5mujb', 'name': '接HA双向计量插座', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2606,8 +2522,8 @@ # name: test_device_registry[bl5cuqxnqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2628,7 +2544,6 @@ 'model_id': 'nxquc5lb', 'name': 'Smart Water Timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2637,8 +2552,8 @@ # name: test_device_registry[btpss2f6kwfi294rqsj] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2659,7 +2574,6 @@ 'model_id': 'r492ifwk6f2ssptb', 'name': 'KLARTA HUMEA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2668,8 +2582,8 @@ # name: test_device_registry[btyk53n3v10z7a97zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2690,7 +2604,6 @@ 'model_id': '79a7z01v3n35kytb', 'name': 'Double Digital Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2699,8 +2612,8 @@ # name: test_device_registry[buzituffc13pgb1jjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2721,7 +2634,6 @@ 'model_id': 'j1bgp31cffutizub', 'name': 'Ceiling Portal', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2730,8 +2642,8 @@ # name: test_device_registry[bxfkpxjgux2fgwnazc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2752,7 +2664,6 @@ 'model_id': 'anwgf2xugjxpkfxb', 'name': 'Security Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2761,8 +2672,8 @@ # name: test_device_registry[c1tfgunpf6optybisf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2783,7 +2694,6 @@ 'model_id': 'ibytpo6fpnugft1c', 'name': 'Ventilador Cama', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2792,8 +2702,8 @@ # name: test_device_registry[c9nbmrweturkgqktdyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2814,7 +2724,6 @@ 'model_id': 'tkqgkrutewrmbn9c', 'name': 'White Noise Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2823,8 +2732,8 @@ # name: test_device_registry[cd6bezcadvjngj5jrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2845,7 +2754,6 @@ '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, @@ -2854,8 +2762,8 @@ # name: test_device_registry[cijerqyssiwrf7deqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2876,7 +2784,6 @@ 'model_id': 'ed7frwissyqrejic', 'name': '接HA水阀', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2885,8 +2792,8 @@ # name: test_device_registry[cju47ovcbeuapei2zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2907,7 +2814,6 @@ 'model_id': '2iepauebcvo74ujc', 'name': 'Aubess Cooker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2916,8 +2822,8 @@ # name: test_device_registry[codvtvgtjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2938,7 +2844,6 @@ 'model_id': 'tgvtvdoc', 'name': 'Tournesol', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2947,8 +2852,8 @@ # name: test_device_registry[couukaypjdnyt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2969,7 +2874,6 @@ 'model_id': 'pyakuuoc', 'name': 'Solar zijpad', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -2978,8 +2882,8 @@ # name: test_device_registry[cq4hzlrnqn4qi0mqzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3000,7 +2904,6 @@ 'model_id': 'qm0iq4nqnrlzh4qc', 'name': 'Elivco Kitchen Socket', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3009,8 +2912,8 @@ # name: test_device_registry[cvowstbid97lokayjb2oc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3031,7 +2934,6 @@ 'model_id': 'yakol79dibtswovc', 'name': 'PTH-9CW 32', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3040,8 +2942,8 @@ # name: test_device_registry[cwwk68dyfsh2eqi4jbqr] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3062,7 +2964,6 @@ 'model_id': '4iqe2hsfyd86kwwc', 'name': 'Gas sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3071,8 +2972,8 @@ # name: test_device_registry[cxbmhihohohk5bmeqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3093,7 +2994,6 @@ 'model_id': 'emb5khohohihmbxc', 'name': 'Server Fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3102,8 +3002,8 @@ # name: test_device_registry[dBFBdywk9gTihUQmzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3124,7 +3024,6 @@ 'model_id': 'mQUhiTg9kwydBFBd', 'name': 'Waschmaschine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3133,8 +3032,8 @@ # name: test_device_registry[dNBnmtjLU8eRWHf0zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3155,7 +3054,6 @@ 'model_id': '0fHWRe8ULjtmnBNd', 'name': 'Weihnachten3', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3164,8 +3062,8 @@ # name: test_device_registry[dj8foneugkjc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3186,7 +3084,6 @@ 'model_id': 'uenof8jd', 'name': 'Smart Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3195,8 +3092,8 @@ # name: test_device_registry[dke76hazlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3217,7 +3114,6 @@ 'model_id': 'zah67ekd', 'name': 'Kitchen Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3226,8 +3122,8 @@ # name: test_device_registry[dn7cjik6kw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3248,7 +3144,6 @@ 'model_id': '6kijc7nd', 'name': 'Кабінет', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3257,8 +3152,8 @@ # name: test_device_registry[dt4whlrosmnldadvtk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3279,7 +3174,6 @@ 'model_id': 'vdadlnmsorlhw4td', 'name': 'Sove', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3288,8 +3182,8 @@ # name: test_device_registry[dvdtmcoil5yopaljjzm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3310,7 +3204,6 @@ 'model_id': 'jlapoy5liocmtdvd', 'name': 'ISV-100W2.0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3319,8 +3212,8 @@ # name: test_device_registry[e2sbdwuga5jorvejtkdy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3341,7 +3234,6 @@ 'model_id': 'jevroj5aguwdbs2e', 'name': 'DOLCECLIMA 10 HP WIFI', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3350,8 +3242,8 @@ # name: test_device_registry[ej2zsznihehztkzqcaderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3372,7 +3264,6 @@ 'model_id': 'qzktzhehinzsz2je', 'name': 'Air', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3381,8 +3272,8 @@ # name: test_device_registry[eway2kw92ncuecarzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3403,7 +3294,6 @@ 'model_id': 'raceucn29wk2yawe', 'name': 'Bathroom Mirror', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3412,8 +3302,8 @@ # name: test_device_registry[f4vvhmhvseuiqs6pqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3434,7 +3324,6 @@ 'model_id': 'p6sqiuesvhmhvv4f', 'name': 'Entrance Door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3443,8 +3332,8 @@ # name: test_device_registry[fasvixqysw1lxvjprd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3465,7 +3354,6 @@ 'model_id': 'pjvxl1wsyqxivsaf', 'name': 'Sunbeam Bedding', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3474,8 +3362,8 @@ # name: test_device_registry[fbya6s6rhaoyvl8hqgcwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3496,7 +3384,6 @@ 'model_id': 'h8lvyoahr6s6aybf', 'name': 'Rainwater Tank Level', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3505,8 +3392,8 @@ # name: test_device_registry[fc2ngmpckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3527,7 +3414,6 @@ 'model_id': 'cpmgn2cf', 'name': 'Bathroom radiator', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3536,8 +3422,8 @@ # name: test_device_registry[fcacn8iqbocuow7dsr] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3558,7 +3444,6 @@ '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, @@ -3567,8 +3452,8 @@ # name: test_device_registry[fcdadqsiax2gvnt0qld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3589,7 +3474,6 @@ 'model_id': '0tnvg2xaisqdadcf', 'name': '一路带计量磁保持通断器', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3598,8 +3482,8 @@ # name: test_device_registry[fjdyw5ld2f5f5ddsps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3620,7 +3504,6 @@ 'model_id': 'sdd5f5f2dl5wydjf', 'name': 'C9', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3629,8 +3512,8 @@ # name: test_device_registry[fov1huugujgfyl0xqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3651,7 +3534,6 @@ 'model_id': 'x0lyfgjuguuh1vof', 'name': 'Smart IR+RF Remote Control', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3660,8 +3542,8 @@ # name: test_device_registry[frmfrbds0jixxyaljbngd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3682,7 +3564,6 @@ 'model_id': 'layxxij0sdbrfmrf', 'name': 'WiFi smart online 8 in 1 tester', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3691,8 +3572,8 @@ # name: test_device_registry[ftvxinxevpy21tbelc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3713,7 +3594,6 @@ 'model_id': 'ebt12ypvexnixvtf', 'name': 'Kitchen Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3722,8 +3602,8 @@ # name: test_device_registry[fvywp3b5mu4zay8lgkxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3744,7 +3624,6 @@ 'model_id': 'l8yaz4um5b3pwyvf', 'name': 'Bathroom Smart Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3753,8 +3632,8 @@ # name: test_device_registry[g0edqq0wzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3775,7 +3654,6 @@ 'model_id': 'w0qqde0g', 'name': 'Lave linge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3784,8 +3662,8 @@ # name: test_device_registry[g1efxsqnp33cg8r3lc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3806,7 +3684,6 @@ 'model_id': '3r8gc33pnqsxfe1g', 'name': 'Lounge Dark Blind', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3815,8 +3692,8 @@ # name: test_device_registry[g1fmm26qhhrimmbitk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3837,7 +3714,6 @@ 'model_id': 'ibmmirhhq62mmf1g', 'name': 'Master Bedroom AC', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3846,8 +3722,8 @@ # name: test_device_registry[g1qorlffoy2iyo9bsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3868,7 +3744,6 @@ 'model_id': 'b9oyi2yofflroq1g', 'name': 'Living room dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3877,8 +3752,8 @@ # name: test_device_registry[g5uso5ajgkxw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3899,7 +3774,6 @@ 'model_id': 'ja5osu5g', 'name': 'Bouton tempo extérieur', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3908,8 +3782,8 @@ # name: test_device_registry[g7af6lrt4miugbstcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3930,7 +3804,6 @@ 'model_id': 'tsbguim4trl6fa7g', 'name': 'Keller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3939,8 +3812,8 @@ # name: test_device_registry[g9h9sblxpb5wdwzkqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3961,7 +3834,6 @@ 'model_id': 'kzwdw5bpxlbs9h9g', 'name': 'IR Minero', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -3970,8 +3842,8 @@ # name: test_device_registry[gbq8kiahk57ct0bpncjynx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -3992,7 +3864,6 @@ 'model_id': 'pb0tc75khaik8qbg', 'name': 'CBE Pro 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4001,8 +3872,8 @@ # name: test_device_registry[ggimpv4dqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4023,7 +3894,6 @@ 'model_id': 'd4vpmigg', 'name': 'Garden Valve Yard', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4032,8 +3902,8 @@ # name: test_device_registry[ggwxkj8bwn5y63flgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4054,7 +3924,6 @@ 'model_id': 'lf36y5nwb8jkxwgg', 'name': 'Greenhouse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4063,8 +3932,8 @@ # name: test_device_registry[gi69tunb0esxcnefzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4085,7 +3954,6 @@ 'model_id': 'fencxse0bnut96ig', 'name': 'Spa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4094,8 +3962,8 @@ # name: test_device_registry[giqs1xhsekjelfibsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4116,7 +3984,6 @@ 'model_id': 'biflejkeshx1sqig', 'name': 'D825A I', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4125,8 +3992,8 @@ # name: test_device_registry[gjnpc0eojd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4147,7 +4014,6 @@ 'model_id': 'oe0cpnjg', 'name': 'Front right Lighting trap', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4156,8 +4022,8 @@ # name: test_device_registry[glsehgu8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4178,7 +4044,6 @@ 'model_id': '8ugheslg', 'name': 'POWERASIA R2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4187,8 +4052,8 @@ # name: test_device_registry[gluaktf5gk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4209,7 +4074,6 @@ 'model_id': '5ftkaulg', 'name': 'bathroom light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4218,8 +4082,8 @@ # name: test_device_registry[gm0whbftkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4240,7 +4104,6 @@ 'model_id': 'tfbhw0mg', 'name': 'Salon', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4249,8 +4112,8 @@ # name: test_device_registry[gnZOKztbAtcBkEGPzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4271,7 +4134,6 @@ 'model_id': 'PGEkBctAbtzKOZng', 'name': 'Din', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4280,8 +4142,8 @@ # name: test_device_registry[gnqwzcph94wj2sl5nq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4302,7 +4164,6 @@ 'model_id': '5ls2jw49hpczwqng', 'name': 'Mr. Pure', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4311,8 +4172,8 @@ # name: test_device_registry[gt1q9tldv1opojrtcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4333,7 +4194,6 @@ 'model_id': 'trjopo1vdlt9q1tg', 'name': 'Terras', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4342,8 +4202,8 @@ # name: test_device_registry[gtcinipmdp5rgx3nlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4364,7 +4224,6 @@ 'model_id': 'n3xgr5pdmpinictg', 'name': 'Estore Sala', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4373,8 +4232,8 @@ # name: test_device_registry[gvxxy4jitzltz5xhscm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4395,7 +4254,6 @@ 'model_id': 'hx5ztlztij4yxxvg', 'name': 'Steel cage door', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4404,8 +4262,8 @@ # name: test_device_registry[hfqeljop3aihnm73zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4426,7 +4284,6 @@ 'model_id': '37mnhia3pojleqfh', 'name': 'Sapphire ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4435,8 +4292,8 @@ # name: test_device_registry[hkm4px9ohzozxma3rip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4457,7 +4314,6 @@ 'model_id': '3amxzozho9xp4mkh', 'name': 'rat trap hedge', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4466,8 +4322,8 @@ # name: test_device_registry[hxbonj4yzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4488,7 +4344,6 @@ 'model_id': 'y4jnobxh', 'name': 'AuVeLiCo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4497,8 +4352,8 @@ # name: test_device_registry[hyda5jsihokacvaqjzm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4519,7 +4374,6 @@ 'model_id': 'qavcakohisj5adyh', 'name': 'Sous Vide', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4528,8 +4382,8 @@ # name: test_device_registry[hz4pau766eavmxhqsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4550,7 +4404,6 @@ 'model_id': 'qhxmvae667uap4zh', 'name': 'DryFix', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4559,8 +4412,8 @@ # name: test_device_registry[i6xywcsymer1kmb6ps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4581,7 +4434,6 @@ 'model_id': '6bmk1remyscwyx6i', 'name': 'Mirilla puerta', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4590,8 +4442,8 @@ # name: test_device_registry[iaagy4qigcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4612,7 +4464,6 @@ 'model_id': 'iq4ygaai', 'name': 'Bassin', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4621,8 +4472,8 @@ # name: test_device_registry[idztlaspsms815moqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4643,7 +4494,6 @@ 'model_id': 'om518smspsaltzdi', 'name': 'Smart IR Theater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4652,8 +4502,8 @@ # name: test_device_registry[ifzgvpgoodrfw2aksc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4674,7 +4524,6 @@ 'model_id': 'ka2wfrdoogpvgzfi', 'name': 'Dehumidifer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4683,8 +4532,8 @@ # name: test_device_registry[igkrtodqg14xvfxlqswwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4705,7 +4554,6 @@ 'model_id': 'lxfvx41gqdotrkgi', 'name': 'Cat Feeder', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4714,8 +4562,8 @@ # name: test_device_registry[ijne16zv8vpqmubnjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4736,7 +4584,6 @@ 'model_id': 'nbumqpv8vz61enji', 'name': 'b2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4745,8 +4592,8 @@ # name: test_device_registry[ijzjlqwmv1blwe0gsf] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4767,7 +4614,6 @@ 'model_id': 'g0ewlb1vmwqljzji', 'name': 'Ceiling Fan With Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4776,8 +4622,8 @@ # name: test_device_registry[ikbbdbnqsd70pc1glc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4798,7 +4644,6 @@ 'model_id': 'g1cp07dsqnbdbbki', 'name': 'Persiana do Quarto', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4807,8 +4652,8 @@ # name: test_device_registry[iks13mcaiyie3rryjb2oc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4829,7 +4674,6 @@ 'model_id': 'yrr3eiyiacm31ski', 'name': 'AQI', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4838,8 +4682,8 @@ # name: test_device_registry[ilms5pwjzzsxuxmvsc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4860,7 +4704,6 @@ 'model_id': 'vmxuxszzjwp5smli', 'name': 'Dehumidifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4869,8 +4712,8 @@ # name: test_device_registry[im3fum2zt73boagkjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4891,7 +4734,6 @@ 'model_id': 'kgaob37tz2muf3mi', 'name': 'Parker Ceiling Fan 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4900,8 +4742,8 @@ # name: test_device_registry[ingdwog22gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4922,7 +4764,6 @@ 'model_id': '2gowdgni', 'name': 'Mesh-Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4931,8 +4772,8 @@ # name: test_device_registry[iomszlsve0yyzkfwqswwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4953,7 +4794,6 @@ 'model_id': 'wfkzyy0evslzsmoi', 'name': 'Cleverio PF100', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4962,8 +4802,8 @@ # name: test_device_registry[j6mn1t4ut5end6ifkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -4984,7 +4824,6 @@ 'model_id': 'fi6dne5tu4t1nm6j', 'name': 'WiFi Smart Gas Boiler Thermostat ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -4993,8 +4832,8 @@ # name: test_device_registry[jfpdpavoqgoqsn3cjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5015,7 +4854,6 @@ 'model_id': 'c3nsqogqovapdpfj', 'name': 'Arbeitszimmer led', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5024,8 +4862,8 @@ # name: test_device_registry[jfydgffzmhjed9fgjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5046,7 +4884,6 @@ 'model_id': 'gf9dejhmzffgdyfj', 'name': ' Smoke detector upstairs ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5055,8 +4892,8 @@ # name: test_device_registry[jgsopsvzh2ec3itjzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5077,7 +4914,6 @@ 'model_id': 'jti3ce2hzvsposgj', 'name': 'Dehumidifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5086,8 +4922,8 @@ # name: test_device_registry[jlduh7vigcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5108,7 +4944,6 @@ 'model_id': 'iv7hudlj', 'name': 'Basement temperature', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5117,8 +4952,8 @@ # name: test_device_registry[jm2fsqtzuhqtbo5ykw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5139,7 +4974,6 @@ 'model_id': 'y5obtqhuztqsf2mj', 'name': 'Term - Prizemi', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5148,8 +4982,8 @@ # name: test_device_registry[jzpap0inhkykqtlwgklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5170,7 +5004,6 @@ '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, @@ -5179,8 +5012,8 @@ # name: test_device_registry[kcdngswaxs8hm52bnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5201,7 +5034,6 @@ 'model_id': 'b25mh8sxawsgndck', 'name': 'ZigBee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5210,8 +5042,8 @@ # name: test_device_registry[kffnst1epj6vr8xnzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5232,7 +5064,6 @@ 'model_id': 'nx8rv6jpe1tsnffk', 'name': 'Spot 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5241,8 +5072,8 @@ # name: test_device_registry[kjr0pqg7eunn4vlujbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5263,7 +5094,6 @@ 'model_id': 'ulv4nnue7gqp0rjk', 'name': 'Siren veranda ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5272,8 +5102,8 @@ # name: test_device_registry[kkande5hk6sfdkoxjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5294,7 +5124,6 @@ 'model_id': 'xokdfs6kh5ednakk', 'name': 'ERKER 1-Gold ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5303,8 +5132,8 @@ # name: test_device_registry[kkcwqzlvgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5325,7 +5154,6 @@ 'model_id': 'vlzqwckk', 'name': 'Temperature Humidity Sensor abelhas pasillo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5334,8 +5162,8 @@ # name: test_device_registry[kkgbskmfejn67l1orip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5356,7 +5184,6 @@ 'model_id': 'o1l76njefmksbgkk', 'name': 'PIR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5365,8 +5192,8 @@ # name: test_device_registry[klgxmpwvdhw7tzs8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5387,7 +5214,6 @@ 'model_id': '8szt7whdvwpmxglk', 'name': 'Porch light E', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5396,8 +5222,8 @@ # name: test_device_registry[ksy8guiy64acbbpnqkynw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5418,7 +5244,6 @@ 'model_id': 'npbbca46yiug8ysk', 'name': 'Bedroom IR', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5427,8 +5252,8 @@ # name: test_device_registry[kta28zbwj6u0xa6lbsgy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5449,7 +5274,6 @@ 'model_id': 'l6ax0u6jwbz82atk', 'name': 'Pond', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5458,8 +5282,8 @@ # name: test_device_registry[kvnsoqyfltmf0bknzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5480,7 +5304,6 @@ 'model_id': 'nkb0fmtlfyqosnvk', 'name': 'Bassin', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5489,8 +5312,8 @@ # name: test_device_registry[kx8dncf1qzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5511,7 +5334,6 @@ 'model_id': '1fcnd8xk', 'name': 'Valve Controller 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5520,8 +5342,8 @@ # name: test_device_registry[kxwleaa2sph] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5542,7 +5364,6 @@ 'model_id': '2aaelwxk', 'name': 'Human presence Office', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5551,8 +5372,8 @@ # name: test_device_registry[kxxrbv93k2vvkconqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5573,7 +5394,6 @@ 'model_id': 'nockvv2k39vbrxxk', 'name': 'Seating side 6-ch Smart Switch ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5582,8 +5402,8 @@ # name: test_device_registry[l8uxezzkc7c5a0jhzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5604,7 +5424,6 @@ 'model_id': 'hj0a5c7ckzzexu8l', 'name': 'droger', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5613,8 +5432,8 @@ # name: test_device_registry[lflvu8cazha8af9jsk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5635,7 +5454,6 @@ 'model_id': 'j9fa8ahzac8uvlfl', 'name': 'Tower Fan CA-407G Smart', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5644,8 +5462,8 @@ # name: test_device_registry[llw1rhcau4y3othdzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5666,7 +5484,6 @@ 'model_id': 'dhto3y4uachr1wll', 'name': 'Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5675,8 +5492,8 @@ # name: test_device_registry[lnjsbx45z3p7s59zbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5697,7 +5514,6 @@ 'model_id': 'z95s7p3z54xbsjnl', 'name': 'WIFI Dual Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5706,8 +5522,8 @@ # name: test_device_registry[mgcpxpmovasazerdps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5728,7 +5544,6 @@ 'model_id': 'drezasavompxpcgm', 'name': 'CAM GARAGE', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5737,8 +5552,8 @@ # name: test_device_registry[mjhwalv51czt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5759,7 +5574,6 @@ 'model_id': '5vlawhjm', 'name': 'INTELAR IR288', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5768,8 +5582,8 @@ # name: test_device_registry[mpowx36sgqexmtes2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5790,7 +5604,6 @@ 'model_id': 'setmxeqgs63xwopm', 'name': 'Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5799,8 +5612,8 @@ # name: test_device_registry[mvsdcwtskkezlnw5tk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5821,7 +5634,6 @@ 'model_id': '5wnlzekkstwcdsvm', 'name': 'Air Conditioner', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5830,8 +5642,8 @@ # name: test_device_registry[mwsaod7fa3gjyh6ids] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5852,7 +5664,6 @@ 'model_id': 'i6hyjg3af7doaswm', 'name': 'Hoover', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5861,8 +5672,8 @@ # name: test_device_registry[nc4e9nlZPTuTNfYEzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5883,7 +5694,6 @@ 'model_id': 'EYfNTuTPZln9e4cn', 'name': 'ZAS-01', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5892,8 +5702,8 @@ # name: test_device_registry[ncl7oi5d6hqmf1g0zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5914,7 +5724,6 @@ 'model_id': '0g1fmqh6d5io7lcn', 'name': 'Apollo light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5923,8 +5732,8 @@ # name: test_device_registry[ngcubvaqoraolsmtjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5945,7 +5754,6 @@ 'model_id': 'tmsloaroqavbucgn', 'name': 'Pokerlamp 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5954,8 +5762,8 @@ # name: test_device_registry[nnqlg0rxryraf8ezbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -5976,7 +5784,6 @@ 'model_id': 'ze8faryrxr0glqnn', 'name': 'Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -5985,8 +5792,8 @@ # name: test_device_registry[nr26obpclc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6007,7 +5814,6 @@ 'model_id': 'cpbo62rn', 'name': 'blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6016,8 +5822,8 @@ # name: test_device_registry[nt3mpibadxfqkegldyg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6038,7 +5844,6 @@ 'model_id': 'lgekqfxdabipm3tn', 'name': 'Colorful PIR Night Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6047,8 +5852,8 @@ # name: test_device_registry[nxdcy0uidplnhkazjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6069,7 +5874,6 @@ 'model_id': 'zakhnlpdiu0ycdxn', 'name': 'Stoel', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6078,8 +5882,8 @@ # name: test_device_registry[nyriu7sjgj9oruzmpsm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6100,7 +5904,6 @@ 'model_id': 'mzuro9jgjs7uiryn', 'name': 'Poopy Nano 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6109,8 +5912,8 @@ # name: test_device_registry[o4hpbl5uarjfbzheps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6131,7 +5934,6 @@ 'model_id': 'ehzbfjrau5lbph4o', 'name': 'Dolní vchod - západ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6140,8 +5942,8 @@ # name: test_device_registry[o5kqedcacfng0plpnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6162,7 +5964,6 @@ 'model_id': 'plp0gnfcacdeqk5o', 'name': 'Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6171,8 +5972,8 @@ # name: test_device_registry[o71einxvuuktuljcjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6193,7 +5994,6 @@ 'model_id': 'cjlutkuuvxnie17o', 'name': 'Rauchmelder Alexsandro ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6202,8 +6002,8 @@ # name: test_device_registry[obb7p55c0us6rdxkqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6224,7 +6024,6 @@ 'model_id': 'kxdr6su0c55p7bbo', 'name': 'Metering_3PN_WiFi_stable', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6233,8 +6032,8 @@ # name: test_device_registry[ohefbbk9gcdl] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6255,7 +6054,6 @@ 'model_id': '9kbbfeho', 'name': 'Luminosité', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6264,8 +6062,8 @@ # name: test_device_registry[okwwus27jhqqe2mijbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6286,7 +6084,6 @@ 'model_id': 'im2eqqhj72suwwko', 'name': 'Siren', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6295,8 +6092,8 @@ # name: test_device_registry[ol8xwtcj42eg18bdbrnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6317,7 +6114,6 @@ 'model_id': 'db81ge24jctwx8lo', 'name': 'Hot Water Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6326,8 +6122,8 @@ # name: test_device_registry[oq9ksabjz6tip49tkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6348,7 +6144,6 @@ 'model_id': 't94pit6zjbask9qo', 'name': 'Floor Thermostat Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6357,8 +6152,8 @@ # name: test_device_registry[oqyhsaqwsph] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6379,7 +6174,6 @@ 'model_id': 'wqashyqo', 'name': 'Soil moisture sensor #1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6388,8 +6182,8 @@ # name: test_device_registry[orotles4ucq8rxwn2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6410,7 +6204,6 @@ 'model_id': 'nwxr8qcu4seltoro', 'name': 'X5 Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6419,8 +6212,8 @@ # name: test_device_registry[ouabwhlarnczogyfqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6441,7 +6234,6 @@ 'model_id': 'fygozcnralhwbauo', 'name': 'SPM02_WiFi', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6450,8 +6242,8 @@ # name: test_device_registry[owozxdzgbibizu4sjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6472,7 +6264,6 @@ 'model_id': 's4uzibibgzdxzowo', 'name': 'ION1000PRO', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6481,8 +6272,8 @@ # name: test_device_registry[oxi73pj9a0ubr60pjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6503,7 +6294,6 @@ 'model_id': 'p06rbu0a9jp37ixo', 'name': 'Jardim Casa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6512,8 +6302,8 @@ # name: test_device_registry[p2gnclbiqxrbboagdd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6534,7 +6324,6 @@ 'model_id': 'gaobbrxqiblcng2p', 'name': 'TV Sync Backlights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6543,8 +6332,8 @@ # name: test_device_registry[p5ger7bqlcjtmmqgbdnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6565,7 +6354,6 @@ 'model_id': 'gqmmtjclqb7reg5p', 'name': 'Wi-Fi Meter(Bi-Directional)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6574,8 +6362,8 @@ # name: test_device_registry[p8xoxccrjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6596,7 +6384,6 @@ 'model_id': 'rccxox8p', 'name': 'Smoke Alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6605,8 +6392,8 @@ # name: test_device_registry[paxijfx9fkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6627,7 +6414,6 @@ 'model_id': '9xfjixap', 'name': 'Empore', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6636,8 +6422,8 @@ # name: test_device_registry[pdasfna8fswh4a0tzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6658,7 +6444,6 @@ 'model_id': 't0a4hwsf8anfsadp', 'name': 'wallwasher front', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6667,8 +6452,8 @@ # name: test_device_registry[pdnimgsb3w0xko3kjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6689,7 +6474,6 @@ 'model_id': 'k3okx0w3bsgmindp', 'name': 'Portal Casa Carro Jalimy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6698,8 +6482,8 @@ # name: test_device_registry[pfhwb1v3i7cifa2tcp] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6720,7 +6504,6 @@ 'model_id': 't2afic7i3v1bwhfp', 'name': 'Bubbelbad', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6729,8 +6512,8 @@ # name: test_device_registry[ppgdpsq1xaxlyzryjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6751,7 +6534,6 @@ 'model_id': 'yrzylxax1qspdgpp', 'name': 'Bree', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6760,8 +6542,8 @@ # name: test_device_registry[pykascx9yfqrxtbgzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6782,7 +6564,6 @@ 'model_id': 'gbtxrqfy9xcsakyp', 'name': '3DPrinter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6791,8 +6572,8 @@ # name: test_device_registry[pz2xuth8hczv6zrwzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6813,7 +6594,6 @@ 'model_id': 'wrz6vzch8htux2zp', 'name': 'Elivco TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6822,8 +6602,8 @@ # name: test_device_registry[q304vac40br8nlkajsywc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6844,7 +6624,6 @@ 'model_id': 'akln8rb04cav403q', 'name': 'Water Fountain', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6853,8 +6632,8 @@ # name: test_device_registry[q3iie9vjd4wfqyy1qzkmkc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6875,7 +6654,6 @@ 'model_id': '1yyqfw4djv9eii3q', 'name': 'Garage door ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6884,8 +6662,8 @@ # name: test_device_registry[q62sg0p3s52thp6zzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6906,7 +6684,6 @@ 'model_id': 'z6pht25s3p0gs26q', 'name': '6294HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6915,8 +6692,8 @@ # name: test_device_registry[q8dncqpgin4yympisc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6937,7 +6714,6 @@ 'model_id': 'ipmyy4nigpqcnd8q', 'name': 'Pro Breeze 30L Compressor Dehumidifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6946,8 +6722,8 @@ # name: test_device_registry[qe8vvtx4nl21wjd3dytkx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6968,7 +6744,6 @@ 'model_id': '3djw12ln4xtvv8eq', 'name': 'Genio Nebula & Blue Star Projector', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -6977,8 +6752,8 @@ # name: test_device_registry[qhgghufzqtwloqoqjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -6999,7 +6774,6 @@ 'model_id': 'qoqolwtqzfuhgghq', 'name': 'Smart Bulb RGBCW', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7008,8 +6782,8 @@ # name: test_device_registry[qi94v9dmdx4fkpncqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7030,7 +6804,6 @@ 'model_id': 'cnpkf4xdmd9v49iq', 'name': '断路器HA', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7039,8 +6812,8 @@ # name: test_device_registry[qifhbafbqubbp3b6qbnnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7061,7 +6834,6 @@ 'model_id': '6b3pbbuqbfabhfiq', 'name': 'Wi-Fi solar grid micro inverter (GT)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7070,8 +6842,8 @@ # name: test_device_registry[qt0o5jlatiqf2rscps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7092,7 +6864,6 @@ 'model_id': 'csr2fqitalj5o0tq', 'name': 'Intercom', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7101,8 +6872,8 @@ # name: test_device_registry[queafegmhhmtivdxjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7123,7 +6894,6 @@ 'model_id': 'xdvitmhhmgefaeuq', 'name': 'druckerhell', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7132,8 +6902,8 @@ # name: test_device_registry[qwExlkou9h2USezrjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7154,7 +6924,6 @@ 'model_id': 'rzeSU2h9uoklxEwq', 'name': 'Inondation', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7163,8 +6932,8 @@ # name: test_device_registry[qyy1auihjyoogvb7zdccq] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7185,7 +6954,6 @@ 'model_id': '7bvgooyjhiua1yyq', 'name': 'AC charging control box', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7194,8 +6962,8 @@ # name: test_device_registry[r4yrlr705ei31ikmjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7216,7 +6984,6 @@ 'model_id': 'mki13ie507rlry4r', 'name': 'Garage light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7225,8 +6992,8 @@ # name: test_device_registry[rdq0bn4dzuwx2qfujd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7247,7 +7014,6 @@ 'model_id': 'ufq2xwuzd4nb0qdr', 'name': 'Sjiethoes', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7256,8 +7022,8 @@ # name: test_device_registry[ri7eegdifufzdi54dyzb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7278,7 +7044,6 @@ 'model_id': '45idzfufidgee7ir', 'name': 'Smart White Noise Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7287,8 +7052,8 @@ # name: test_device_registry[rirsc4vhpbv2whkp2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7309,7 +7074,6 @@ 'model_id': 'pkhw2vbphv4csrir', 'name': 'C30', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7318,8 +7082,8 @@ # name: test_device_registry[rl39uwgaqwjwc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7340,7 +7104,6 @@ 'model_id': 'agwu93lr', 'name': 'Smart Odor Eliminator-Pro', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7349,8 +7112,8 @@ # name: test_device_registry[rojky4l6yyjreeilnocfw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7371,7 +7134,6 @@ 'model_id': 'lieerjyy6l4ykjor', 'name': 'Zigbee Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7380,8 +7142,8 @@ # name: test_device_registry[rsjdwgnbqky] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7402,7 +7164,6 @@ 'model_id': 'bngwdjsr', 'name': 'Télécommande lumières ZigBee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7411,8 +7172,8 @@ # name: test_device_registry[rvsneuipzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7433,7 +7194,6 @@ 'model_id': 'piuensvr', 'name': 'Signal repeater', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7442,8 +7202,8 @@ # name: test_device_registry[rwp6kdezm97s2nktzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7464,7 +7224,6 @@ 'model_id': 'tkn2s79mzedk6pwr', 'name': 'Weihnachtsmann ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7473,8 +7232,8 @@ # name: test_device_registry[rzt2knqamsxjp8f9ycjjh] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7495,7 +7254,6 @@ 'model_id': '9f8pjxsmaqnk2tzr', 'name': 'MT15/MT29', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7504,8 +7262,8 @@ # name: test_device_registry[s3zzjdcfrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7526,7 +7284,6 @@ 'model_id': 'fcdjzz3s', 'name': 'Motion sensor lidl zigbee', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7535,8 +7292,8 @@ # name: test_device_registry[s5ah3novtabe4tfdhb] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7557,7 +7314,6 @@ 'model_id': 'dft4ebatvon3ha5s', 'name': 'Smart Kettle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7566,8 +7322,8 @@ # name: test_device_registry[sb3zdertrw50bgogkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7588,7 +7344,6 @@ 'model_id': 'gogb05wrtredz3bs', 'name': 'smart thermostats', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7597,8 +7352,8 @@ # name: test_device_registry[sdq2flqkq0lblcah2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7619,7 +7374,6 @@ 'model_id': 'haclbl0qkqlf2qds', 'name': 'Home Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7628,8 +7382,8 @@ # name: test_device_registry[shga3pmbkwhthvqxgklc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7650,7 +7404,6 @@ 'model_id': 'xqvhthwkbmp3aghs', 'name': 'Pergola', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7659,8 +7412,8 @@ # name: test_device_registry[sifg4pfqsylsayg0jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7681,7 +7434,6 @@ 'model_id': '0gyaslysqfp4gfis', 'name': 'Study 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7690,8 +7442,8 @@ # name: test_device_registry[sj55nxhjftilowkejd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7712,7 +7464,6 @@ 'model_id': 'ekwolitfjhxn55js', 'name': 'ab6', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7721,8 +7472,8 @@ # name: test_device_registry[slkkzcxa7yjqmetqlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7743,7 +7494,6 @@ 'model_id': 'qtemqjy7axczkkls', 'name': 'Dining 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7752,8 +7502,8 @@ # name: test_device_registry[snbu4b3vekhywztwqgcwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7774,7 +7524,6 @@ 'model_id': 'wtzwyhkev3b4ubns', 'name': 'House Water Level', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7783,8 +7532,8 @@ # name: test_device_registry[sq6fbd3pfkw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7805,7 +7554,6 @@ 'model_id': 'p3dbf6qs', 'name': 'Anbau', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7814,8 +7562,8 @@ # name: test_device_registry[srbr1lpaydiq7l5sgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7836,7 +7584,6 @@ 'model_id': 's5l7qidyapl1rbrs', 'name': 'Ventus test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7845,8 +7592,8 @@ # name: test_device_registry[srp7cfjtn6sshwmt2gw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7867,7 +7614,6 @@ 'model_id': 'tmwhss6ntjfc7prs', 'name': 'Gateway', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7876,8 +7622,8 @@ # name: test_device_registry[svjjuwykgijjedurps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7898,7 +7644,6 @@ 'model_id': 'rudejjigkywujjvs', 'name': 'Bürocam', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7907,8 +7652,8 @@ # name: test_device_registry[sw1ejdomlmfubapizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7929,7 +7674,6 @@ 'model_id': 'ipabufmlmodje1ws', 'name': 'Värmelampa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7938,8 +7682,8 @@ # name: test_device_registry[swhtzki3qrz5ydchjboc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7960,7 +7704,6 @@ 'model_id': 'hcdy5zrq3ikzthws', 'name': 'Smogo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -7969,8 +7712,8 @@ # name: test_device_registry[sxa4ealyi9cotiugzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -7991,7 +7734,6 @@ 'model_id': 'guitoc9iylae4axs', 'name': 'HA Socket Delta Test', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8000,8 +7742,8 @@ # name: test_device_registry[syep74caderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8022,7 +7764,6 @@ 'model_id': '47peys', 'name': 'Ar', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8031,8 +7772,8 @@ # name: test_device_registry[t5zosev6h6wmwyrajbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8053,7 +7794,6 @@ 'model_id': 'arywmw6h6vesoz5t', 'name': 'Rauchmelder Drucker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8062,8 +7802,8 @@ # name: test_device_registry[t7bvnnvplkwhdqm9qtn] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8084,7 +7824,6 @@ 'model_id': '9mqdhwklpvnnvb7t', 'name': 'Бризер Зал', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8093,8 +7832,8 @@ # name: test_device_registry[t88qaeyydamm9xhsddx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8115,7 +7854,6 @@ 'model_id': 'shx9mmadyyeaq88t', 'name': 'Plafond bureau ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8124,8 +7862,8 @@ # name: test_device_registry[tcdk0skzcpisexj2zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8146,7 +7884,6 @@ 'model_id': '2jxesipczks0kdct', 'name': 'HVAC Meter', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8155,8 +7892,8 @@ # name: test_device_registry[thdfxdqqlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8177,7 +7914,6 @@ 'model_id': 'qqdxfdht', 'name': 'bedroom blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8186,8 +7922,8 @@ # name: test_device_registry[trffx1ktlyu3tnmljd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8208,7 +7944,6 @@ 'model_id': 'lmnt3uyltk1xffrt', 'name': 'DirectietKamer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8217,8 +7952,8 @@ # name: test_device_registry[tskafaotnfigad6oqzkfs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8239,7 +7974,6 @@ 'model_id': 'o6dagifntoafakst', 'name': 'Sprinkler Cesare', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8248,8 +7982,8 @@ # name: test_device_registry[tvgoe1s3fabebcskjbwy] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8270,7 +8004,6 @@ 'model_id': 'kscbebaf3s1eogvt', 'name': 'WIFI Smoke alarm', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8279,8 +8012,8 @@ # name: test_device_registry[u8h3bty7qgg] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8301,7 +8034,6 @@ 'model_id': '7ytb3h8u', 'name': 'GIEX Watering Timer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8310,8 +8042,8 @@ # name: test_device_registry[uBLyTOvlhoRWXKjrps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8332,7 +8064,6 @@ 'model_id': 'rjKXWRohlvOTyLBu', 'name': 'CAM PORCH', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8341,8 +8072,8 @@ # name: test_device_registry[uYmmlWz6zs0dIgYDjbgs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8363,7 +8094,6 @@ 'model_id': 'DYgId0sz6zWlmmYu', 'name': 'Siren', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8372,8 +8102,8 @@ # name: test_device_registry[uc9fL2NpR79iCzGIzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8394,7 +8124,6 @@ 'model_id': 'IGzCi97RpN2Lf9cu', 'name': 'N4-Auto', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8403,8 +8132,8 @@ # name: test_device_registry[uew54dymycjwz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8425,7 +8154,6 @@ 'model_id': 'myd45weu', 'name': 'Patates', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8434,8 +8162,8 @@ # name: test_device_registry[urm7i0rtdlabqiqygcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8456,7 +8184,6 @@ 'model_id': 'yqiqbaldtr0i7mru', 'name': 'WiFi Temperature & Humidity Sensor', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8465,8 +8192,8 @@ # name: test_device_registry[uvh6oeqrfliovfiwzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8487,7 +8214,6 @@ 'model_id': 'wifvoilfrqeo6hvu', 'name': 'Licht drucker', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8496,8 +8222,8 @@ # name: test_device_registry[vADxMzNytofrgbm4zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8518,7 +8244,6 @@ 'model_id': '4mbgrfotyNzMxDAv', 'name': 'Air Purifier ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8527,8 +8252,8 @@ # name: test_device_registry[vayhq2aj3p3z6y2ggcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8549,7 +8274,6 @@ 'model_id': 'g2y6z3p3ja2qhyav', 'name': 'NP DownStairs North', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8558,8 +8282,8 @@ # name: test_device_registry[vcrfgwvbuybgnj3zqld] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8580,7 +8304,6 @@ 'model_id': 'z3jngbyubvwgfrcv', 'name': 'Edesanya Energy', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8589,8 +8312,8 @@ # name: test_device_registry[ve3ctzrqgcdsw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8611,7 +8334,6 @@ '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, @@ -8620,8 +8342,8 @@ # name: test_device_registry[vnj3sa6mqahro6phjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8642,7 +8364,6 @@ 'model_id': 'hp6orhaqm6as3jnv', 'name': 'Master bedroom TV lights', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8651,8 +8372,8 @@ # name: test_device_registry[vpfdskpi8pr8cbtfzjs] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8673,7 +8394,6 @@ 'model_id': 'ftbc8rp8ipksdfpv', 'name': 'mesa', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8682,8 +8402,8 @@ # name: test_device_registry[vrhdtr5fawoiyth9qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8704,7 +8424,6 @@ 'model_id': '9htyiowaf5rtdhrv', 'name': 'Framboisiers', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8713,8 +8432,8 @@ # name: test_device_registry[vx2owjsg86g2ys93zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8735,7 +8454,6 @@ 'model_id': '39sy2g68gsjwo2xv', 'name': 'Ineox SP2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8744,8 +8462,8 @@ # name: test_device_registry[vzu7lkknqjz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8766,7 +8484,6 @@ 'model_id': 'nkkl7uzv', 'name': 'Zigby répéteur ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8775,8 +8492,8 @@ # name: test_device_registry[w8oht6v8aauqa0y8jd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8797,7 +8514,6 @@ 'model_id': '8y0aquaa8v6tho8w', 'name': 'dressoir spot', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8806,8 +8522,8 @@ # name: test_device_registry[w9hdtm88xj5crtc1qdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8828,7 +8544,6 @@ 'model_id': '1ctrc5jx88mtdh9w', 'name': 'Puerta Casa ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8837,8 +8552,8 @@ # name: test_device_registry[wc6mumew8inrivi9zc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8859,7 +8574,6 @@ 'model_id': '9ivirni8wemum6cw', 'name': 'Garáž čerpadlo', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8868,8 +8582,8 @@ # name: test_device_registry[weozorgv28n2scribswh] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8890,7 +8604,6 @@ 'model_id': 'ircs2n82vgrozoew', 'name': 'InverFlow', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8899,8 +8612,8 @@ # name: test_device_registry[x4nogasbi8ggpb3lcd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8921,7 +8634,6 @@ 'model_id': 'l3bpgg8ibsagon4x', 'name': 'LSC Party String Light RGBIC+CCT ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8930,8 +8642,8 @@ # name: test_device_registry[x7quooqakw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8952,7 +8664,6 @@ 'model_id': 'aqoouq7x', 'name': 'Clima cucina', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8961,8 +8672,8 @@ # name: test_device_registry[xR2ASpOQgAAqu7Drlc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -8983,7 +8694,6 @@ 'model_id': 'rD7uqAAgQOpSA2Rx', 'name': 'Kit-Blinds', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -8992,8 +8702,8 @@ # name: test_device_registry[xenxir4a0tn0p1qcqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9014,7 +8724,6 @@ 'model_id': 'cq1p0nt0a4rixnex', 'name': '4-433', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9023,8 +8732,8 @@ # name: test_device_registry[xihygtyd0d1faknkps] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9045,7 +8754,6 @@ 'model_id': 'knkaf1d0dytgyhix', 'name': 'Security Camera', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9054,8 +8762,8 @@ # name: test_device_registry[xms6qowipdvjnkdgqdt] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9076,7 +8784,6 @@ 'model_id': 'gdknjvdpiwoq6smx', 'name': 'Jardim frontal ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9085,8 +8792,8 @@ # name: test_device_registry[y1dkg3disbacmqfyjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9107,7 +8814,6 @@ 'model_id': 'yfqmcabsid3gkd1y', 'name': 'Shop Light 5', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9116,8 +8822,8 @@ # name: test_device_registry[y7eeatfzbtbyllk0qbnnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9138,7 +8844,6 @@ 'model_id': '0kllybtbzftaee7y', 'name': 'Soria', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9147,8 +8852,8 @@ # name: test_device_registry[ycttanlnpa0aivbfzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9169,7 +8874,6 @@ 'model_id': 'fbvia0apnlnattcy', 'name': 'AK1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9178,8 +8882,8 @@ # name: test_device_registry[yky6kunazmaitupzjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9200,7 +8904,6 @@ 'model_id': 'zputiamzanuk6yky', 'name': 'Floodlight', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9209,8 +8912,8 @@ # name: test_device_registry[yo2karkjuhzztxsfjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9231,7 +8934,6 @@ 'model_id': 'fsxtzzhujkrak2oy', 'name': 'Kalado Air Purifier', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9240,8 +8942,8 @@ # name: test_device_registry[yohkwjjdjlzludd3psm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9262,7 +8964,6 @@ 'model_id': '3ddulzljdjjwkhoy', 'name': 'Kattenbak', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9271,8 +8972,8 @@ # name: test_device_registry[yuanswy6scm] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9293,7 +8994,6 @@ 'model_id': '6ywsnauy', 'name': 'Fenêtre cuisine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9302,8 +9002,8 @@ # name: test_device_registry[yybgnzr3ztws] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9324,7 +9024,6 @@ 'model_id': '3rzngbyy', 'name': 'Grillhőmérő', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9333,8 +9032,8 @@ # name: test_device_registry[z7cu5t8bl9tt9fabjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9355,7 +9054,6 @@ 'model_id': 'baf9tt9lb8t5uc7z', 'name': 'Pokerlamp 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9364,8 +9062,8 @@ # name: test_device_registry[z8woiryqydmzonjdjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9386,7 +9084,6 @@ 'model_id': 'djnozmdyqyriow8z', 'name': 'Fakkel 8', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9395,8 +9092,8 @@ # name: test_device_registry[zaszonjgzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9417,7 +9114,6 @@ 'model_id': 'gjnozsaz', 'name': 'Raspy4 - Home Assistant', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9426,8 +9122,8 @@ # name: test_device_registry[zf8vgiwoa07jwegtjd] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9448,7 +9144,6 @@ 'model_id': 'tgewj70aowigv8fz', 'name': 'Stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9457,8 +9152,8 @@ # name: test_device_registry[zfHZQ7tZUBxAWjACjk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9479,7 +9174,6 @@ 'model_id': 'CAjWAxBUZt7QZHfz', 'name': 'HL400', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9488,8 +9182,8 @@ # name: test_device_registry[zgiyrxflahjowpcckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9510,7 +9204,6 @@ 'model_id': 'ccpwojhalfxryigz', 'name': 'Boiler Temperature Controller', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9519,8 +9212,8 @@ # name: test_device_registry[zjh9xhtm3gibs9kizc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9541,7 +9234,6 @@ 'model_id': 'ik9sbig3mthx9hjz', 'name': 'Aubess Washing Machine', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9550,8 +9242,8 @@ # name: test_device_registry[zoytcemodrn39zqwrip] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9572,7 +9264,6 @@ 'model_id': 'wqz93nrdomectyoz', 'name': 'PIR outside stairs', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9581,8 +9272,8 @@ # name: test_device_registry[zrrraytdoanz33rlds] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9603,7 +9294,6 @@ 'model_id': 'lr33znaodtyarrrz', 'name': 'V20', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9612,8 +9302,8 @@ # name: test_device_registry[zspc4q1ut7swycnyzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9634,7 +9324,6 @@ 'model_id': 'yncyws7tu1q4cpsz', 'name': 'Wi-Fi hub', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9643,8 +9332,8 @@ # name: test_device_registry[zspxfhsvgn2hgtndzc] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9665,7 +9354,6 @@ 'model_id': 'dntgh2ngvshfxpsz', 'name': 'fakkel veranda ', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9674,8 +9362,8 @@ # name: test_device_registry[zuqudhznfzttizpgbrnz] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9696,7 +9384,6 @@ 'model_id': 'gpzittzfnzhduquz', 'name': 'Inverter Pool Heat Pump', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9705,8 +9392,8 @@ # name: test_device_registry[zwnoax1om13nulplvtderarfni] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9727,7 +9414,6 @@ 'model_id': 'lplun31mo1xaonwz', 'name': 'TV', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9736,8 +9422,8 @@ # name: test_device_registry[zxmrfsffcearbajpjfx] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9758,7 +9444,6 @@ 'model_id': 'pjabraecffsfrmxz', 'name': 'Register booster fan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9767,8 +9452,8 @@ # name: test_device_registry[zyutbek7wdm1b4cgzckw] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9789,7 +9474,6 @@ 'model_id': 'gc4b1mdw7kebtuyz', 'name': 'pid_relay_2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -9798,8 +9482,8 @@ # name: test_device_registry[zzz87dkfce6pdqxwtk] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -9820,7 +9504,6 @@ 'model_id': 'wxqdp6ecfkd78zzz', 'name': 'Mini-Split', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/tuya/test_init.py b/tests/components/tuya/test_init.py index ca68b7ad941e..817ac8311f20 100644 --- a/tests/components/tuya/test_init.py +++ b/tests/components/tuya/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest +import requests from syrupy.assertion import SnapshotAssertion from tuya_device_handlers import TUYA_QUIRKS_REGISTRY from tuya_sharing import CustomerDevice, Manager @@ -15,6 +16,7 @@ from homeassistant.components.tuya.const import ( DOMAIN, ) from homeassistant.components.tuya.diagnostics import _REDACTED_DPCODES +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -369,3 +371,18 @@ async def test_fixtures_valid(hass: HomeAssistant) -> None: f"Please mark `data['status']['{key}']` as `**REDACTED**`" f" in {device_code}.json" ) + + +async def test_network_error_retries_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a network error during setup results in a setup retry.""" + manager = create_manager() + manager.update_device_cache.side_effect = requests.exceptions.ConnectionError( + "Failed to resolve 'apigw.tuyaeu.com'" + ) + + await initialize_entry(hass, manager, mock_config_entry, []) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 8365f6a79156..b3df44bdac2d 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -84,8 +84,8 @@ # name: test_waste_pickup_calendar.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -106,7 +106,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 8de837cab936..3fbe5efd26c7 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -53,8 +53,8 @@ # name: test_sensors[sensor.twente_milieu_christmas_tree_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -75,7 +75,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -135,8 +134,8 @@ # name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -157,7 +156,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -217,8 +215,8 @@ # name: test_sensors[sensor.twente_milieu_organic_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -239,7 +237,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -299,8 +296,8 @@ # name: test_sensors[sensor.twente_milieu_packages_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -321,7 +318,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -381,8 +377,8 @@ # name: test_sensors[sensor.twente_milieu_paper_waste_pickup].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -403,7 +399,6 @@ 'model_id': None, 'name': 'Twente Milieu', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 81e3e4a9338d..8a162fe5eba8 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -25,7 +25,7 @@ from homeassistant.components.unifi.const import ( ) from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util from .conftest import ( @@ -610,6 +610,83 @@ async def test_restoring_client( assert not hass.states.get("device_tracker.not_restored") +@pytest.mark.parametrize("client_payload", [[WIRED_CLIENT_1]]) +@pytest.mark.parametrize( + "clients_all_payload", + [ + [ + { + "hostname": "recent", + "is_wired": True, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:05", + }, + { + "hostname": "stale", + "is_wired": True, + "last_seen": 1562600145, # 2019, well beyond the retention window + "mac": "00:00:00:00:00:06", + }, + ] + ], +) +async def test_pruning_stale_restored_clients( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + config_entry_factory: ConfigEntryFactoryType, + clients_all_payload: list[dict[str, Any]], +) -> None: + """Restore recently seen inactive clients but prune stale ones and their device.""" + recent, stale = clients_all_payload + # Client with a tracker but absent from clients_all, e.g. a failed fetch + absent_mac = "00:00:00:00:00:07" + + entries: dict[str, er.RegistryEntry] = {} + for mac, hostname in ( + (recent["mac"], "recent"), + (stale["mac"], "stale"), + (absent_mac, "absent"), + ): + entries[mac] = entity_registry.async_get_or_create( + TRACKER_DOMAIN, + DOMAIN, + f"site_id-{mac}", + suggested_object_id=hostname, + config_entry=config_entry, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + ) + + await config_entry_factory() + + # Recently seen client is restored with its tracker and device intact + assert hass.states.get("device_tracker.recent") + assert entity_registry.async_get(entries[recent["mac"]].entity_id) + assert device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, recent["mac"])} + ) + + # Stale client is pruned together with its device + assert not hass.states.get("device_tracker.stale") + assert entity_registry.async_get(entries[stale["mac"]].entity_id) is None + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, stale["mac"])} + ) + is None + ) + + # Client absent from clients_all is left untouched, never pruned on missing data + assert entity_registry.async_get(entries[absent_mac].entity_id) + assert device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, absent_mac)} + ) + + @pytest.mark.parametrize( ("config_entry_options", "counts", "expected"), [ diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 8d2df550be58..85eef64c8da0 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.components.unifi.const import ( CONF_DETECTION_TIME, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, + CONF_TRACK_WIRED_CLIENTS, DEFAULT_DETECTION_TIME, DEVICE_STATES, ) @@ -35,7 +36,7 @@ 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.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util @@ -579,6 +580,34 @@ async def test_wired_client_speed_sensor( assert hass.states.get("sensor.wired_client_link_speed").state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_TRACK_CLIENTS: False, + CONF_TRACK_WIRED_CLIENTS: False, + CONF_TRACK_DEVICES: False, + } + ], +) +@pytest.mark.parametrize("client_payload", [[WIRED_CLIENT]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_wired_client_speed_sensor_not_created_when_untracked( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client_payload: list[dict[str, Any]], +) -> None: + """Verify untracked wired clients create neither a link speed sensor nor a device.""" + assert entity_registry.async_get("sensor.wired_client_link_speed") is None + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])} + ) + is None + ) + + @pytest.mark.parametrize( "config_entry_options", [{CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True}], diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index cf212ed7357e..986818c6cad2 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -6,7 +6,6 @@ from functools import partial from ipaddress import IPv4Address from pathlib import Path from tempfile import gettempdir -from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -22,6 +21,8 @@ from uiprotect.data import ( CloudAccount, Light, Liveview, + ModelType, + ProtectModelWithId, Sensor, SmartDetectObjectType, StateType, @@ -45,26 +46,10 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from . import _patch_discovery -from .utils import MockUFPFixture +from .utils import MockUFPFixture, make_public_camera, public_rtsps_for from tests.common import MockConfigEntry, load_json_object_fixture - -def _public_rtsps_for(camera: Any) -> RTSPSStreams | None: - """Build a camera's primed RTSPS streams from its RTSP-enabled channels. - - Mirrors what the library writes onto ``PublicCamera.rtsps_streams`` during - ``update_public()`` — only RTSP-enabled channels carry an active URL, and a - camera with none is left streamless (``None``). - """ - urls = { - channel.rtsps_quality: channel.rtsps_url - for channel in camera.channels - if channel.is_rtsp_enabled and channel.rtsps_quality is not None - } - return RTSPSStreams(**urls) if urls else None - - MAC_ADDR = "aa:bb:cc:dd:ee:ff" # Common test data constants @@ -182,6 +167,7 @@ def mock_ufp_client(bootstrap: Bootstrap): client.update_public = AsyncMock() client.async_disconnect_ws = AsyncMock() client.has_public_bootstrap = True + client.is_public_only = False # The library owns RTSPS streams on ``PublicCamera.rtsps_streams`` and primes # them in ``update_public()``; the integration reads them synchronously. Start @@ -193,15 +179,25 @@ def mock_ufp_client(bootstrap: Bootstrap): client.public_bootstrap.sirens = {} client.public_bootstrap.arm_profiles = {} client.public_bootstrap.arm_mode = None - # No paired public device by default; tests opt in via setup_public_* helpers. - client.public_bootstrap.get = Mock(return_value=None) + + # Cameras resolve to their primed public model (see ``update_public`` in + # ``mock_entry``); other device types opt in via the ``setup_public_*`` + # helpers, so they default to no paired public object. + def _public_bootstrap_get( + model: ModelType, obj_id: str + ) -> ProtectModelWithId | None: + if model is ModelType.CAMERA: + return client.public_bootstrap.cameras.get(obj_id) + return None + + client.public_bootstrap.get = Mock(side_effect=_public_bootstrap_get) async def get_camera_rtsps_streams( camera_id: str, *args: Any, **kwargs: Any ) -> RTSPSStreams | None: """Fetch a camera's RTSPS streams (used by the repair flow).""" camera = client.bootstrap.cameras.get(camera_id) - return _public_rtsps_for(camera) if camera is not None else None + return public_rtsps_for(camera) if camera is not None else None client.get_camera_rtsps_streams = AsyncMock(side_effect=get_camera_rtsps_streams) client.create_camera_rtsps_streams = AsyncMock(return_value=None) @@ -265,23 +261,20 @@ def mock_entry( ufp_client.subscribe_devices_websocket_state = subscribe_devices_websocket_state async def update_public() -> Any: - # Mirror the library prime: populate PublicCamera.rtsps_streams for - # every camera from the private bootstrap (connected cameras only, so - # a disconnected camera stays streamless), keyed by id. + # Mirror the library prime: build each camera's public model from the + # private bootstrap and attach its RTSPS streams (connected cameras + # only, so a disconnected camera stays streamless), keyed by id. pb = ufp_client.public_bootstrap - pb.cameras = { - camera.id: SimpleNamespace( - id=camera.id, - name=camera.display_name, - state=camera.state, - rtsps_streams=( - _public_rtsps_for(camera) - if camera.state is StateType.CONNECTED - else None - ), + cameras: dict[str, Any] = {} + for camera in ufp_client.bootstrap.cameras.values(): + public = make_public_camera(camera) + public.rtsps_streams = ( + public_rtsps_for(camera) + if camera.state is StateType.CONNECTED + else None ) - for camera in ufp_client.bootstrap.cameras.values() - } + cameras[camera.id] = public + pb.cameras = cameras return pb ufp_client.update_public = AsyncMock(side_effect=update_public) diff --git a/tests/components/unifiprotect/snapshots/test_init.ambr b/tests/components/unifiprotect/snapshots/test_init.ambr index e53b25c4ad34..3a3e7558a835 100644 --- a/tests/components/unifiprotect/snapshots/test_init.ambr +++ b/tests/components/unifiprotect/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_setup_creates_nvr_device DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://127.0.0.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': 'UNVR-PRO', 'name': 'UnifiProtect', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '6.0.0', 'via_device_id': None, diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index c05b89b56078..4496af6cc7f2 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -15,7 +15,7 @@ from uiprotect.data import ( Sensor, SmartDetectObjectType, ) -from uiprotect.data.nvr import EventMetadata +from uiprotect.data.public_devices import SensorFeatureCapability from uiprotect.websocket import WebsocketState from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -25,10 +25,12 @@ from homeassistant.components.unifiprotect.binary_sensor import ( LIGHT_SENSORS, MOUNTABLE_SENSE_SENSORS, SENSE_SENSORS, + ProtectBinaryEntityDescription, ) from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, + DOMAIN, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -48,9 +50,11 @@ from .utils import ( assert_entity_counts, ids_from_device_description, init_entry, + make_public_light, make_public_sensor, public_device_ws_message, remove_entities, + setup_public_light, setup_public_sensor, ) @@ -60,6 +64,9 @@ LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2] SENSE_SENSORS_WRITE = SENSE_SENSORS[:3] BATTERY_LOW = next(d for d in SENSE_SENSORS if d.key == "battery_low") SENSE_MOTION = next(d for d in SENSE_SENSORS if d.key == "motion") +SENSE_DOOR = MOUNTABLE_SENSE_SENSORS[0] +SENSE_LEAK = next(d for d in SENSE_SENSORS if d.key == "leak") +SENSE_TAMPERING = next(d for d in SENSE_SENSORS if d.key == "tampering") async def test_binary_sensor_camera_remove( @@ -112,6 +119,7 @@ async def test_binary_sensor_setup_light( ) -> None: """Test binary_sensor entity setup for light devices.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) @@ -386,6 +394,271 @@ async def test_binary_sensor_sense_motion_unavailable_without_public( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE +async def test_binary_sensor_sense_door_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The contact sensor reads is_opened from a public WS update.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_DOOR + ) + assert hass.states.get(entity_id).state == STATE_OFF + + public = make_public_sensor(sensor_all, is_opened=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_binary_sensor_sense_door_unmounted_unavailable( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """A sensor without a contact mount reports the contact sensor unavailable.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_DOOR + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_sensor(sensor_all, mount_type=MountType.NONE) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_binary_sensor_sense_door_device_class_follows_public_mount( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The contact sensor derives its device class from the public mount type.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_DOOR + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value + + public = make_public_sensor(sensor_all, mount_type=MountType.WINDOW) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW.value + + +async def test_binary_sensor_sense_leak_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The leak sensor reads is_leak_detected from a public WS update.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_LEAK + ) + + public = make_public_sensor( + sensor_all, mount_type=MountType.LEAK, is_leak_detected=True + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize( + ("mount_type", "capabilities", "internal", "external", "expected"), + [ + pytest.param( + MountType.LEAK, None, False, False, STATE_OFF, id="leak-mount-parity" + ), + pytest.param( + MountType.NONE, + {SensorFeatureCapability.WATER_LEAK}, + True, + False, + STATE_OFF, + id="capability-internal", + ), + pytest.param( + MountType.NONE, + {SensorFeatureCapability.WATER_LEAK}, + False, + True, + STATE_OFF, + id="capability-external", + ), + pytest.param( + MountType.NONE, + None, + True, + True, + STATE_UNAVAILABLE, + id="settings-without-capability-map", + ), + pytest.param( + MountType.NONE, + set(), + True, + True, + STATE_UNAVAILABLE, + id="settings-without-capability", + ), + pytest.param( + MountType.NONE, + {SensorFeatureCapability.WATER_LEAK}, + False, + False, + STATE_UNAVAILABLE, + id="capability-without-channel", + ), + ], +) +async def test_binary_sensor_sense_leak_enablement_gate( + hass: HomeAssistant, + ufp: MockUFPFixture, + sensor_all: Sensor, + mount_type: MountType, + capabilities: set[SensorFeatureCapability] | None, + internal: bool, + external: bool, + expected: str, +) -> None: + """The leak gate honors leak mount, water_leak capability and channel settings.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_LEAK + ) + + public = make_public_sensor( + sensor_all, + mount_type=mount_type, + capabilities=capabilities, + leak_internal_enabled=internal, + leak_external_enabled=external, + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == expected + + +async def test_binary_sensor_sense_capability_creation_filter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """A capability map limits entity creation to the advertised capabilities.""" + setup_public_sensor( + ufp, + capabilities={SensorFeatureCapability.OPEN, SensorFeatureCapability.TAMPER}, + ) + await init_entry(hass, ufp, [sensor_all]) + + for description in (SENSE_DOOR, SENSE_TAMPERING, BATTERY_LOW): + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description + ) + assert entity_registry.async_get(entity_id) is not None + + for description in (SENSE_MOTION, SENSE_LEAK): + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description + ) + assert entity_registry.async_get(entity_id) is None + + +async def test_binary_sensor_sense_capability_registry_cleanup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """A console upgrade removes registry entries for unsupported capabilities.""" + stale = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, + DOMAIN, + f"{sensor_all.mac}_{SENSE_LEAK.key}", + config_entry=ufp.entry, + ) + setup_public_sensor( + ufp, + capabilities={SensorFeatureCapability.OPEN, SensorFeatureCapability.TAMPER}, + ) + await init_entry(hass, ufp, [sensor_all], regenerate_ids=False) + + assert entity_registry.async_get(stale.entity_id) is None + + +async def test_binary_sensor_sense_no_capability_map_creates_all( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """Without a capability map (older firmware) every sense entity is created.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + for description in (SENSE_DOOR, SENSE_TAMPERING, SENSE_MOTION, SENSE_LEAK): + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description + ) + assert entity_registry.async_get(entity_id) is not None + + +async def test_binary_sensor_sense_tampering_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The tampering sensor reads is_tampering_detected from a public WS update.""" + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, SENSE_TAMPERING + ) + assert hass.states.get(entity_id).state == STATE_OFF + + public = make_public_sensor(sensor_all, is_tampering_detected=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize( + "description", + [ + pytest.param(SENSE_DOOR, id="door"), + pytest.param(SENSE_LEAK, id="leak"), + pytest.param(SENSE_TAMPERING, id="tampering"), + ], +) +async def test_binary_sensor_sense_unavailable_without_public( + hass: HomeAssistant, + ufp: MockUFPFixture, + sensor_all: Sensor, + description: ProtectBinaryEntityDescription, +) -> None: + """The migrated sense sensors are unavailable without a public object.""" + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, sensor_all, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_binary_sensor_battery_low_unavailable_on_public_ws_disconnect( hass: HomeAssistant, ufp: MockUFPFixture, @@ -458,47 +731,38 @@ async def test_binary_sensor_update_motion( async def test_binary_sensor_update_light_motion( - hass: HomeAssistant, ufp: MockUFPFixture, light: Light, fixed_now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test binary_sensor motion entity.""" + """Test the light motion binary_sensor reads PIR motion from the public API.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) _, entity_id = await ids_from_device_description( hass, Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) + assert hass.states.get(entity_id).state == STATE_OFF - event_metadata = EventMetadata(light_id=light.id) - event = Event( - model=ModelType.EVENT, - id="test_event_id", - type=EventType.MOTION_LIGHT, - start=fixed_now - timedelta(seconds=1), - end=None, - score=100, - smart_detect_types=[], - smart_detect_event_ids=[], - metadata=event_metadata, - api=ufp.api, - ) - - new_light = light.model_copy() - new_light.is_pir_motion_detected = True - new_light.last_motion_event_id = event.id - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - - ufp.api.bootstrap.lights = {new_light.id: new_light} - ufp.api.bootstrap.events = {event.id: event} - ufp.ws_msg(mock_msg) + public = make_public_light(light, is_pir_motion_detected=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_ON + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_binary_sensor_light_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated light binary_sensors are unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + for description in LIGHT_SENSOR_WRITE: + _, entity_id = await ids_from_device_description( + hass, Platform.BINARY_SENSOR, light, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_binary_sensor_update_mount_type_window( diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 50684ad406fe..cb058870708e 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -1,12 +1,21 @@ """Test the UniFi Protect camera platform.""" -from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock, patch +from aiohttp.client_exceptions import ServerDisconnectedError import pytest -from uiprotect.data import AiPort, Camera as ProtectCamera, StateType +from uiprotect.data import ( + AiPort, + Camera as ProtectCamera, + ChannelQuality, + DeviceState, + ModelType, + StateType, + WSAction, +) from uiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.websocket import WebsocketState from homeassistant.components.camera import ( CameraEntityFeature, @@ -16,9 +25,14 @@ from homeassistant.components.camera import ( from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN from homeassistant.components.unifiprotect.utils import get_camera_base_name from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from . import patch_ufp_method from .utils import ( @@ -27,14 +41,18 @@ from .utils import ( assert_entity_counts, enable_entity, init_entry, + make_public_camera, + public_device_ws_message, + public_rtsps_for, remove_entities, ) def _channel_entity_id(camera_obj: ProtectCamera, channel_id: int) -> str: """Return the entity_id for a camera channel.""" - channel = camera_obj.channels[channel_id] - base_name = get_camera_base_name(channel) + quality = camera_obj.channels[channel_id].rtsps_quality + assert quality is not None + base_name = get_camera_base_name(quality) return f"camera.{camera_obj.name}_{base_name}".replace(" ", "_").lower() @@ -242,36 +260,29 @@ async def test_package_camera_without_stream( assert ufp.api.get_public_api_camera_snapshot.call_args.kwargs["package"] is True -async def test_package_only_camera( - hass: HomeAssistant, - ufp: MockUFPFixture, - camera: ProtectCamera, - issue_registry: ir.IssueRegistry, -) -> None: - """A camera with only a package channel still exposes a snapshot entity.""" - package = camera.channels[0].model_copy() - package.id = 3 - package.fps = 2 - package.is_rtsp_enabled = False - camera.channels = [package] - - await init_entry(hass, ufp, [camera]) - - # only the package snapshot entity (disabled by default); no main fallback, no repair - assert_entity_counts(hass, Platform.CAMERA, 1, 0) - _assert_entity(hass, camera, 0, enabled=False) - assert issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera.id}") is None - - -async def test_no_channels( +async def test_camera_not_in_public_bootstrap( hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ) -> None: - """A camera without channels yet creates no entities.""" - camera.channels = [] + """A camera not yet mirrored into the public bootstrap is deferred.""" + + async def _prime_without_camera() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_without_camera) await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 0, 0) + # the public mirror arriving on the devices websocket creates the entity + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + ufp.api.public_bootstrap.cameras = {camera.id: public} + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + async def test_streams_unavailable( hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera @@ -280,11 +291,9 @@ async def test_streams_unavailable( async def _prime_streamless() -> Any: pb = ufp.api.public_bootstrap - pb.cameras = { - camera_all.id: SimpleNamespace( - id=camera_all.id, state=camera_all.state, rtsps_streams=None - ) - } + public = make_public_camera(camera_all) + public.rtsps_streams = None + pb.cameras = {camera_all.id: public} return pb ufp.api.update_public = AsyncMock(side_effect=_prime_streamless) @@ -383,39 +392,260 @@ async def test_aiport_no_camera_entities( assert_entity_counts(hass, Platform.CAMERA, 0, 0) -async def test_aiport_stream_issue_cleanup( - hass: HomeAssistant, - ufp: MockUFPFixture, - aiport: AiPort, - issue_registry: ir.IssueRegistry, +async def test_public_only_camera( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ) -> None: - """Stale public-stream issues for AI Ports are cleaned up on setup.""" - await init_entry(hass, ufp, [aiport]) + """A public-only camera builds a working entity with degraded diagnostics.""" + # This camera is intentionally kept out of the private bootstrap; wire the + # channel api so its public RTSPS URLs resolve, as add_device would. + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) - issue_id = f"rtsp_disabled_{aiport.id}" - # Simulate a legacy issue created directly (bypass translation validation). - issue_registry.issues[(DOMAIN, issue_id)] = ir.IssueEntry( - active=True, - breaks_in_ha_version=None, - created=None, - data=None, - dismissed_version=None, - domain=DOMAIN, - is_fixable=True, - is_persistent=False, - issue_domain=None, - issue_id=issue_id, - learn_more_url=None, - severity=ir.IssueSeverity.WARNING, - translation_key="rtsp_disabled", - translation_placeholders=None, + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + # No private cameras in the bootstrap: the public object is the only source. + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + + entity_id = _channel_entity_id(camera, 0) + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + # diagnostics have no public equivalent and degrade to None + assert state.attributes["fps"] is None + + # device identity degrades to name-only; the NVR link is omitted (resolving + # the NVR identity publicly is wired with the config-mode setup) + device_registry = dr.async_get(hass) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, public.mac)} ) - assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + assert device is not None + assert device.via_device_id is None + assert device.name == camera.display_name + assert device.model == camera.type - await hass.config_entries.async_reload(ufp.entry.entry_id) + assert ( + await async_get_stream_source(hass, entity_id) + == camera.channels[0].rtsps_no_srtp_url + ) + + ufp.api.get_public_api_camera_snapshot = AsyncMock() + await async_get_image(hass, entity_id) + ufp.api.get_public_api_camera_snapshot.assert_called_once() + + # a frame without a merged object re-reads the public bootstrap and stays up + none_msg = Mock() + none_msg.changed_data = {} + none_msg.new_obj = None + none_msg.old_obj = public + ufp.devices_ws_subscription(none_msg) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # a public devices websocket update drives availability + public.state = DeviceState.DISCONNECTED + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_adopt_before_public_bootstrap( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A camera adopted before the public bootstrap mirrors it is deferred.""" + + async def _prime_empty() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_empty) + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + # adopt the camera while it is still absent from the public bootstrap + camera._api = ufp.api + await adopt_devices(hass, ufp, [camera], fully_adopt=True) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + # the public mirror arriving on the devices websocket creates the entity + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + ufp.api.public_bootstrap.cameras = {camera.id: public} + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + + +async def test_public_only_camera_removed( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A public-only camera removed from the public bootstrap goes unavailable.""" + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + entity_id = _channel_entity_id(camera, 0) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # on a delete the library has already dropped the object; the re-read + # comes up empty and the entity goes unavailable + ufp.api.public_bootstrap.cameras = {} + delete_msg = Mock() + delete_msg.changed_data = {} + delete_msg.new_obj = None + delete_msg.old_obj = public + ufp.devices_ws_subscription(delete_msg) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # the camera reappearing on the websocket recovers it + ufp.api.public_bootstrap.cameras = {camera.id: public} + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + +async def test_public_only_camera_ws_state_availability( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """Public devices websocket state drives a public-only camera without private reads.""" + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + entity_id = _channel_entity_id(camera, 0) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # tripwire: from here on nothing may read the private bootstrap + ufp.api.bootstrap = None + + # a public NVR frame has no private NVR to signal and is ignored + nvr_msg = Mock() + nvr_msg.changed_data = {} + nvr_msg.old_obj = None + nvr_msg.new_obj = Mock(model=ModelType.NVR) + ufp.devices_ws_subscription(nvr_msg) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # a websocket drop marks the camera unavailable, a reconnect recovers it + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + +async def test_camera_motion_detection_uses_replaced_device( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """Motion commands act on the refreshed device after a bootstrap replacement.""" + await init_entry(hass, ufp, [camera]) + entity_id = _channel_entity_id(camera, 0) + + new_camera = camera.model_copy() + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + with patch_ufp_method( + new_camera, "set_motion_detection", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + "camera", + "enable_motion_detection", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_method.assert_called_once_with(True) + + +@pytest.mark.parametrize( + "service", ["enable_motion_detection", "disable_motion_detection"] +) +async def test_public_only_camera_motion_detection_raises( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera, service: str +) -> None: + """Motion detection cannot be changed without a private session.""" + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + entity_id = _channel_entity_id(camera, 0) + + with pytest.raises(HomeAssistantError, match="public API"): + await hass.services.async_call( + "camera", + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_hybrid_public_camera_without_private_deferred( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """Hybrid: a public camera without its private twin is left to the adopt flow.""" + public = make_public_camera(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + + # Not public-only mode (conftest default): the entity must not be built + # private-less; the later adopt dispatch creates it with its private fill. + await init_entry(hass, ufp, []) assert_entity_counts(hass, Platform.CAMERA, 0, 0) @@ -435,3 +665,415 @@ async def test_snapshot_low_quality_without_stream( assert ( ufp.api.get_public_api_camera_snapshot.call_args.kwargs["high_quality"] is False ) + + +async def test_private_enumeration_upgrade_keeps_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + camera_all: ProtectCamera, +) -> None: + """Old private-enumeration registry entries survive the public enumeration. + + Same unique_ids, entity_ids, and enabled split — including across a reload. + """ + seeded: dict[str, str] = {} + for channel_id, disabled_by in ( + (0, None), + (1, er.RegistryEntryDisabler.INTEGRATION), + (2, er.RegistryEntryDisabler.INTEGRATION), + ): + entry = entity_registry.async_get_or_create( + Platform.CAMERA, + DOMAIN, + f"{camera_all.mac}_{channel_id}", + config_entry=ufp.entry, + suggested_object_id=f"my_renamed_cam_{channel_id}", + disabled_by=disabled_by, + ) + seeded[entry.unique_id] = entry.entity_id + + await init_entry(hass, ufp, [camera_all], regenerate_ids=False) + + # Same totals as a fresh setup: nothing duplicated, nothing orphaned. + assert_entity_counts(hass, Platform.CAMERA, 3, 1) + for unique_id, entity_id in seeded.items(): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.unique_id == unique_id + + # The customized (enabled) entity is live and streams from the public API. + high_id = seeded[f"{camera_all.mac}_0"] + assert hass.states.get(high_id) is not None + assert ( + await async_get_stream_source(hass, high_id) + == camera_all.channels[0].rtsps_no_srtp_url + ) + + await hass.config_entries.async_reload(ufp.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.CAMERA, 3, 1) + for unique_id, entity_id in seeded.items(): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.unique_id == unique_id + assert hass.states.get(high_id) is not None + + +async def test_streamless_camera_reenumerated_on_rtsps_prime( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + camera_all: ProtectCamera, + caplog: pytest.LogCaptureFixture, +) -> None: + """A camera primed after enumeration gains its active-quality entities. + + A disconnected camera enumerates streamless (only the snapshot fallback). + When it comes online the library primes its RTSPS streams in the background + and announces the change with an ``rtsps_streams`` devices-WS frame; the + integration must re-enumerate so the now-active tiers get their entities, + without re-adding the live fallback entity, and the existing entity must + pick up its now-available stream URL. + """ + camera_all.state = StateType.DISCONNECTED + await init_entry(hass, ufp, [camera_all]) + + # Streamless: only the snapshot fallback (high) exists, without a stream. + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + high_id = _channel_entity_id(camera_all, 0) + assert await async_get_stream_source(hass, high_id) is None + assert entity_registry.async_get(_channel_entity_id(camera_all, 1)) is None + assert entity_registry.async_get(_channel_entity_id(camera_all, 2)) is None + + # The camera comes online and the library primes its streams, announced by + # an rtsps_streams change on the public devices websocket (sent twice: the + # re-enumeration must not attempt to re-add live entities). + camera_all.state = StateType.CONNECTED + public = ufp.api.public_bootstrap.cameras[camera_all.id] + public.state = DeviceState.CONNECTED + public.rtsps_streams = public_rtsps_for(camera_all) + msg = public_device_ws_message(public) + msg.changed_data = {"rtsps_streams": public.rtsps_streams} + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + # The active medium/low tiers now have entities, the existing high entity + # streams, and no duplicate-add errors were logged. + assert_entity_counts(hass, Platform.CAMERA, 3, 1) + _assert_entity(hass, camera_all, 0, enabled=True) + _assert_entity(hass, camera_all, 1, enabled=False) + _assert_entity(hass, camera_all, 2, enabled=False) + assert ( + await async_get_stream_source(hass, high_id) + == camera_all.channels[0].rtsps_no_srtp_url + ) + assert "does not generate unique IDs" not in caplog.text + + +async def test_public_only_camera_added_after_setup( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """In public-only mode a camera added later is discovered from its frame. + + There is no private adopt path without a local user, so the public devices + websocket ``add`` frame is the only discovery signal. + """ + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + # A new camera appears on the public devices websocket. + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + ufp.api.public_bootstrap.cameras = {camera.id: public} + msg = public_device_ws_message(public) + msg.action = WSAction.ADD + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + state = hass.states.get(_channel_entity_id(camera, 0)) + assert state + assert state.state != STATE_UNAVAILABLE + + +async def test_public_only_streamless_camera_gets_repair( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + issue_registry: ir.IssueRegistry, +) -> None: + """A streamless public-only camera raises the RTSP repair. + + The fix flow verifies and creates the stream entirely through the public + API, so it works without a private session. + """ + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = None + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + assert ( + issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera.id}") is not None + ) + + +async def test_stream_capability_published_on_prime( + hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera +) -> None: + """Gaining a stream publishes the STREAM capability when nothing else changes.""" + camera_all.channels = [c.model_copy() for c in camera_all.channels] + for channel in camera_all.channels: + channel.is_rtsp_enabled = False + + await init_entry(hass, ufp, [camera_all]) + + high_id = _channel_entity_id(camera_all, 0) + state = hass.states.get(high_id) + assert state + assert state.attributes["supported_features"] == CameraEntityFeature(0) + + # The library primes the streams; availability, recording, and motion are + # unchanged, so the capability flip is the only observable difference. + camera_all.channels[0].is_rtsp_enabled = True + public = ufp.api.public_bootstrap.cameras[camera_all.id] + public.rtsps_streams = public_rtsps_for(camera_all) + msg = public_device_ws_message(public) + msg.changed_data = {"rtsps_streams": public.rtsps_streams} + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + state = hass.states.get(high_id) + assert state + assert state.attributes["supported_features"] == CameraEntityFeature.STREAM + + +async def test_camera_without_main_tiers_skipped_with_warning( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + caplog: pytest.LogCaptureFixture, +) -> None: + """A camera violating the main-tier contract is skipped loudly, not fatally.""" + for channel in camera.channels: + channel._api = ufp.api + healthy = make_public_camera(camera) + healthy.rtsps_streams = public_rtsps_for(camera) + broken = make_public_camera(camera) + broken.id = "broken-camera" + broken.mac = "FFEEDDCCBBAA" + broken.display_name = "Broken" + broken.rtsps_streams = None + broken.hardware_stream_qualities.return_value = [ChannelQuality.PACKAGE] + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: healthy, broken.id: broken} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + + # The healthy camera enumerates; the broken one is skipped with a warning + # instead of aborting the platform setup. + assert "reports no main stream tiers" in caplog.text + assert_entity_counts(hass, Platform.CAMERA, 1, 1) + assert hass.states.get(_channel_entity_id(camera, 0)) is not None + + +async def test_public_only_camera_deleted_during_gap( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A camera deleted while the websocket was down reads as unavailable. + + The library resyncs its public bootstrap on reconnect but applies the + snapshot silently; the integration must re-read behind a fresh snapshot + rather than resurrect the entity from the pre-disconnect cache. + """ + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + entity_id = _channel_entity_id(camera, 0) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # The websocket drops; the camera is deleted during the gap, so the + # reconnect resync returns a snapshot without it. + async def _prime_empty() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_empty) + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + # Not resurrected from the stale cache: the fresh snapshot has no camera. + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("error", "expect_reauth"), + [ + pytest.param(NotAuthorized("revoked"), True, id="revoked_key"), + pytest.param(ServerDisconnectedError(), False, id="transport_error"), + ], +) +async def test_reconnect_refresh_failures( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + error: Exception, + expect_reauth: bool, +) -> None: + """A failed reconnect refresh starts reauth on 401 and retries on transport.""" + for channel in camera.channels: + channel._api = ufp.api + public = make_public_camera(camera) + public.rtsps_streams = public_rtsps_for(camera) + + async def _prime_public_only() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera.id: public} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_public_only) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + + ufp.api.update_public = AsyncMock(side_effect=error) + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + with patch.object(ufp.entry, "async_start_reauth") as mock_reauth: + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + assert mock_reauth.called is expect_reauth + + +async def test_hybrid_camera_lost_public_mirror_logs( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: ProtectCamera, + caplog: pytest.LogCaptureFixture, +) -> None: + """A vanished public mirror is observable instead of a silent no-op.""" + await init_entry(hass, ufp, [camera]) + + ufp.api.public_bootstrap.cameras = {} + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = camera + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + assert "has no public mirror" in caplog.text + + +async def test_public_only_camera_added_during_gap( + hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera +) -> None: + """A camera added while the websocket was down enumerates on reconnect. + + The resync snapshot is applied silently and no add frame ever arrives for + a camera that appeared during the gap, so the reconnect refresh must + dispatch it for enumeration itself. + """ + for channel in camera_all.channels: + channel._api = ufp.api + first = make_public_camera(camera_all) + first.rtsps_streams = public_rtsps_for(camera_all) + + async def _prime_one() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera_all.id: first} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_one) + ufp.api.is_public_only = True + + await init_entry(hass, ufp, []) + assert_entity_counts(hass, Platform.CAMERA, 3, 1) + + # A second camera appears during the gap; the reconnect resync includes it. + second = make_public_camera(camera_all) + second.id = "gap-camera" + second.mac = "FFEEDDCCBB01" + second.name = "Gap Camera" + second.display_name = "Gap Camera" + second.rtsps_streams = first.rtsps_streams + + async def _prime_two() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {camera_all.id: first, second.id: second} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_two) + ufp.devices_ws_state_subscription(WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + ufp.devices_ws_state_subscription(WebsocketState.CONNECTED) + await hass.async_block_till_done() + + # Three tiers each for both cameras; no duplicates for the first one. + assert_entity_counts(hass, Platform.CAMERA, 6, 2) + assert hass.states.get("camera.gap_camera_high_resolution_channel") is not None + + +async def test_unadopted_camera_not_enumerated_from_public_frame( + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera +) -> None: + """A public frame cannot create entities for an unadopted camera.""" + camera.is_adopted = False + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + public = ufp.api.public_bootstrap.cameras[camera.id] + msg = public_device_ws_message(public) + msg.changed_data = {"rtsps_streams": public.rtsps_streams} + ufp.devices_ws_subscription(msg) + await hass.async_block_till_done() + + # still excluded, exactly like the startup enumeration + assert_entity_counts(hass, Platform.CAMERA, 0, 0) diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index a21771f847be..d22fcbc6a652 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -244,11 +244,13 @@ async def test_package_detected( await hass.async_block_till_done() assert len(events) == 1 - # The camera is resolved by device_id, not by the public device_mac, so a - # missing device_mac must still dispatch. + # Subscriptions are keyed by device_id alone: an event still dispatches when + # it carries no device_mac and the device is absent from the private + # bootstrap, proving the public event path does not depend on it. + ufp.api.bootstrap.id_lookup.pop(doorbell.id, None) ufp.events_msg( ProtectEvent( - id="test_package_event_fallback", + id="test_package_event_no_private_device", type=EventType.SMART_DETECT, channel=ProtectEventChannel.DETECTION, device_id=doorbell.id, @@ -262,10 +264,10 @@ async def test_package_detected( await hass.async_block_till_done() assert len(events) == 2 assert events[1].data["new_state"].attributes[ATTR_EVENT_ID] == ( - "test_package_event_fallback" + "test_package_event_no_private_device" ) - # An event for a device absent from the private bootstrap is dropped. + # An event for a device without a matching subscription is dropped. ufp.events_msg( ProtectEvent( id="test_package_event_unknown", diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index ee094d61f422..b224e9ada64d 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -1,9 +1,8 @@ """Test the UniFi Protect light platform.""" -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock -from uiprotect.data import Light -from uiprotect.data.types import LEDLevel +from uiprotect.data import DeviceState, Light from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -12,6 +11,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -22,7 +22,10 @@ from .utils import ( adopt_devices, assert_entity_counts, init_entry, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_light, ) @@ -48,6 +51,7 @@ async def test_light_setup( ) -> None: """Test light entity setup.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -67,21 +71,15 @@ async def test_light_setup( async def test_light_update( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: - """Test light entity update.""" + """Test the light reads on/off and brightness from a public WS update.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) - new_light = light.model_copy() - new_light.is_light_on = True - new_light.light_device_settings.led_level = LEDLevel(3) - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = new_light - - ufp.api.bootstrap.lights = {new_light.id: new_light} - ufp.ws_msg(mock_msg) + # Divergent public values (on, led_level 3 -> 128) prove the read path. + public = make_public_light(light, is_light_on=True, led_level=3) + ufp.devices_ws_subscription(public_device_ws_message(public)) await hass.async_block_till_done() state = hass.states.get("light.test_light") @@ -90,6 +88,56 @@ async def test_light_update( assert state.attributes[ATTR_BRIGHTNESS] == 128 +async def test_light_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """The light is unavailable without a public object.""" + + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + state = hass.states.get("light.test_light") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_light_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """Light availability follows the public object's connection state.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light, unadopted_light]) + + entity_id = "light.test_light" + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_light(light, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_light_brightness_none( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light +) -> None: + """A light without a public LED level reports no brightness.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light, unadopted_light]) + + public = make_public_light(light, is_light_on=True) + public.light_device_settings.led_level = None + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + state = hass.states.get("light.test_light") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] is None + + async def test_light_turn_on( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ) -> None: @@ -98,6 +146,7 @@ async def test_light_turn_on( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -120,6 +169,7 @@ async def test_light_turn_on_with_brightness( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) @@ -146,6 +196,7 @@ async def test_light_turn_off( light._api = ufp.api light.api.update_light_public = AsyncMock() + setup_public_light(ufp) await init_entry(hass, ufp, [light, unadopted_light]) assert_entity_counts(hass, Platform.LIGHT, 1, 1) diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 985b50c5f6f9..08dc5716263f 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -9,7 +9,11 @@ from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from .utils import MockUFPFixture, init_entry @@ -220,6 +224,37 @@ async def test_deprecate_entity_script( assert issue is None +async def test_migrate_remove_aiport_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + ufp: MockUFPFixture, +) -> None: + """A leftover AI Port device/entity is removed by type, bootstrap-independent.""" + mac = "AABBCCDDEEFF" + device = device_registry.async_get_or_create( + config_entry_id=ufp.entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + model_id="AI Port", + ) + entity = entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + f"{mac}_uptime", + config_entry=ufp.entry, + device_id=device.id, + ) + + # AI Port deliberately absent from the bootstrap — cleanup is registry-based + await init_entry(hass, ufp, []) + + assert entity_registry.async_get(entity.entity_id) is None + assert ( + device_registry.async_get_device(connections={(dr.CONNECTION_NETWORK_MAC, mac)}) + is None + ) + + async def test_migrate_insecure_camera_redirected( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 20a77ec0930c..358413c4d995 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -4,12 +4,21 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import Camera, Chime, DeviceState, IRLEDMode, Light, RingSetting +from uiprotect.data import ( + Camera, + Chime, + DeviceState, + IRLEDMode, + Light, + RingSetting, + Sensor, +) from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.number import ( CAMERA_NUMBERS, LIGHT_NUMBERS, + SENSE_NUMBERS, ProtectNumberEntityDescription, ) from homeassistant.const import ( @@ -29,10 +38,14 @@ from .utils import ( assert_entity_counts, ids_from_device_description, init_entry, + make_public_camera, make_public_light, + make_public_sensor, public_device_ws_message, remove_entities, + setup_public_camera, setup_public_light, + setup_public_sensor, ) @@ -106,6 +119,7 @@ async def test_number_setup_camera_all( camera.speaker_settings.volume = 1 camera.feature_flags.is_doorbell = True camera.speaker_settings.ring_volume = 1 + setup_public_camera(ufp) await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.NUMBER, 7, 7) @@ -153,8 +167,9 @@ async def test_number_setup_camera_missing_attr( async def test_number_light_sensitivity( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test sensitivity number entity for lights.""" + """Test sensitivity number entity for lights (public API).""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.NUMBER, 2, 2) @@ -166,7 +181,7 @@ async def test_number_light_sensitivity( ) with patch_ufp_method( - light, "set_sensitivity", new_callable=AsyncMock + light, "set_sensitivity_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "number", @@ -178,6 +193,39 @@ async def test_number_light_sensitivity( mock_method.assert_called_once_with(15.0) +async def test_number_light_sensitivity_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Sensitivity reads from the public object and refreshes on a public WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, LIGHT_NUMBERS[0] + ) + + # A value the private fixture (45) would not produce proves the public source. + public = make_public_light(light, pir_sensitivity=30) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "30" + + +async def test_number_light_sensitivity_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated sensitivity number is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, light, LIGHT_NUMBERS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_number_light_duration( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: @@ -287,6 +335,7 @@ async def test_number_camera_simple( description: ProtectNumberEntityDescription, ) -> None: """Tests simple numbers for cameras using the all features fixture.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [camera_all_features]) assert_entity_counts(hass, Platform.NUMBER, 7, 7) @@ -309,6 +358,145 @@ async def test_number_camera_simple( mock_method.assert_called_once_with(1.0) +async def test_number_camera_mic_volume_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera +) -> None: + """Mic volume reads from the public object and refreshes on a public WS update.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [camera]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, CAMERA_NUMBERS[1] + ) + + # A public value the private fixture (1) would not produce proves the source. + public = make_public_camera(camera, mic_volume=42) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "42" + + +async def test_number_camera_mic_volume_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera +) -> None: + """The migrated mic volume number is unavailable without a public object.""" + + # The default fixture mirrors every camera into the public bootstrap; + # prime it empty to model a camera the public API does not know yet. + async def _prime_empty() -> Mock: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_empty) + await init_entry(hass, ufp, [camera]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, CAMERA_NUMBERS[1] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_number_camera_mic_volume_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera +) -> None: + """Mic volume availability follows the public object's connection state.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [camera]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, camera, CAMERA_NUMBERS[1] + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_camera(camera, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_number_sense_sensitivity_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """Motion sensitivity reads from the public object and refreshes on a WS update.""" + + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] + ) + + # A public value the private fixture (100) would not produce proves the source. + public = make_public_sensor(sensor_all, motion_sensitivity=42) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "42" + + +async def test_number_sense_sensitivity_set( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """Setting the motion sensitivity calls the public API setter.""" + + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] + ) + + with patch_ufp_method( + sensor_all, "set_motion_sensitivity_public", new_callable=AsyncMock + ) as mock_method: + await hass.services.async_call( + "number", + "set_value", + {ATTR_ENTITY_ID: entity_id, "value": 60.0}, + blocking=True, + ) + + mock_method.assert_called_once_with(60.0) + + +async def test_number_sense_sensitivity_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """The migrated motion sensitivity number is unavailable without a public object.""" + + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_number_sense_sensitivity_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +) -> None: + """Motion sensitivity availability follows the public object's connection state.""" + + setup_public_sensor(ufp) + await init_entry(hass, ufp, [sensor_all]) + + _, entity_id = await ids_from_device_description( + hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0] + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_sensor(sensor_all, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + def _setup_chime_with_doorbell( chime: Chime, doorbell: Camera, volume: int = 50 ) -> None: diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index c3a67f49bb96..bb221d448cee 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -1,6 +1,7 @@ """Test the UniFi Protect select platform.""" from copy import copy +from typing import Any from unittest.mock import AsyncMock, Mock import pytest @@ -8,6 +9,7 @@ from uiprotect.data import ( NVR, ArmProfile, Camera, + DeviceState, DoorbellMessageType, IRLEDMode, LCDMessage, @@ -35,7 +37,14 @@ from homeassistant.components.unifiprotect.select import ( PTZ_PATROL_STOP, VIEWER_SELECTS, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_OPTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -47,7 +56,12 @@ from .utils import ( assert_entity_counts, ids_from_device_description, init_entry, + make_public_camera, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_camera, + setup_public_light, ) @@ -102,6 +116,7 @@ async def test_select_setup_light( """Test select entity setup for light devices.""" light.light_mode_settings.enable_at = LightModeEnableType.DARK + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -159,6 +174,7 @@ async def test_select_setup_camera_all( ) -> None: """Test select entity setup for camera devices (all features).""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) @@ -185,6 +201,74 @@ async def test_select_setup_camera_all( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION +async def test_select_camera_hdr_mode_public_update( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """Test the HDR mode select reads updates from the public devices WS.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + public = make_public_camera(doorbell, hdr_type=PublicHdrMode.AUTO) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "auto" + + +async def test_select_camera_hdr_mode_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """The migrated HDR mode select is unavailable without a public object.""" + + async def _prime_without_camera() -> Any: + pb = ufp.api.public_bootstrap + pb.cameras = {} + return pb + + ufp.api.update_public = AsyncMock(side_effect=_prime_without_camera) + + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_select_camera_hdr_mode_unavailable_on_public_disconnect( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +) -> None: + """HDR mode availability follows the public object's connection state.""" + + setup_public_camera(ufp) + await init_entry(hass, ufp, [doorbell]) + + description = next(d for d in CAMERA_SELECTS if d.key == "hdr_mode") + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, doorbell, description + ) + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + public = make_public_camera(doorbell, state=DeviceState.DISCONNECTED) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_select_setup_camera_none( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -193,6 +277,7 @@ async def test_select_setup_camera_none( ) -> None: """Test select entity setup for camera devices (no features).""" + setup_public_camera(ufp) await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -334,8 +419,9 @@ async def test_select_update_doorbell_message( async def test_select_set_option_light_motion( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ) -> None: - """Test Light Mode select.""" + """Test Light Mode select (public API).""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) @@ -344,7 +430,7 @@ async def test_select_set_option_light_motion( ) with patch_ufp_method( - light, "set_light_settings", new_callable=AsyncMock + light, "set_light_mode_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "select", @@ -356,6 +442,64 @@ async def test_select_set_option_light_motion( mock_method.assert_called_once_with(LightModeType.MANUAL, enable_at=None) +async def test_select_light_motion_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Light Mode select reads from the public object and refreshes on a WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + assert hass.states.get(entity_id).state == "motion" + + # The private fixture is full-time motion; when_dark proves the public source. + public = make_public_light( + light, + light_mode=LightModeType.WHEN_DARK, + light_mode_enable_at=LightModeEnableType.DARK, + ) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "when_dark" + + +async def test_select_light_motion_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated light motion select is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_select_light_motion_none( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """A light that does not report a public mode leaves the select unknown.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SELECT, light, LIGHT_SELECTS[0] + ) + + public = make_public_light(light) + public.light_mode_settings.mode = None + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + async def test_select_set_option_light_camera( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, camera: Camera ) -> None: @@ -559,6 +703,7 @@ async def test_select_set_option_camera_hdr_mode( ) -> None: """Test HDR mode select calls public API with mapped value.""" + setup_public_camera(ufp) await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index c86f18a3426d..ab8e9c9fcc3f 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -11,10 +11,13 @@ from uiprotect.data import ( DeviceState, Event, EventType, + Light, ModelType, Sensor, ) from uiprotect.data.nvr import EventMetadata +from uiprotect.data.public_devices import SensorFeatureCapability +from uiprotect.utils import convert_to_datetime from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -22,6 +25,7 @@ from homeassistant.components.unifiprotect.sensor import ( ALL_DEVICES_SENSORS, CAMERA_DISABLED_SENSORS, CAMERA_SENSORS, + LIGHT_SENSORS, MOTION_TRIP_SENSORS, NVR_DISABLED_SENSORS, NVR_SENSORS, @@ -44,10 +48,12 @@ from .utils import ( enable_entity, ids_from_device_description, init_entry, + make_public_light, make_public_sensor, public_device_ws_message, remove_entities, reset_objects, + setup_public_light, setup_public_sensor, time_changed, ) @@ -94,6 +100,36 @@ async def test_sensor_sensor_remove( assert_entity_counts(hass, Platform.SENSOR, 22, 14) +async def test_sensor_sense_capability_creation_filter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, +) -> None: + """A capability map limits sensor entity creation to the advertised capabilities.""" + setup_public_sensor( + ufp, + capabilities={SensorFeatureCapability.OPEN, SensorFeatureCapability.TAMPER}, + ) + await init_entry(hass, ufp, [sensor_all]) + + for key, created in ( + ("battery_level", True), + ("door_last_trip_time", True), + ("tampering_last_trip_time", True), + ("temperature_level", False), + ("humidity_level", False), + ("light_level", False), + ("alarm_sound", False), + ("motion_last_trip_time", False), + ): + description = next(d for d in SENSE_SENSORS if d.key == key) + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, sensor_all, description + ) + assert (entity_registry.async_get(entity_id) is not None) is created, key + + async def test_sensor_setup_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -661,18 +697,17 @@ async def test_sensor_precision( assert hass.states.get(entity_id).state == "17.49" -async def test_aiport_no_camera_sensor_entities( +async def test_aiport_no_sensor_entities( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, aiport: AiPort, ) -> None: - """Test that AI Port devices do not create camera-specific sensor entities.""" + """AI Port devices create no entities (support dropped).""" await init_entry(hass, ufp, [aiport]) - # AI Port should only create base device sensors, not camera-specific sensors - # The exact count may vary, but camera motion/detection sensors should not exist - entity_registry = er.async_get(hass) entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) + assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] # Check no camera-specific sensors like motion detection exist for entity in entities: @@ -680,3 +715,61 @@ async def test_aiport_no_camera_sensor_entities( # Camera-specific sensors should not exist for AI Port assert "detected_object" not in entity.unique_id assert "last_motion" not in entity.unique_id + + +async def test_aiport_no_sensor_entities_on_runtime_adopt( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, + aiport: AiPort, +) -> None: + """An AI Port adopted while running still creates no entities.""" + await init_entry(hass, ufp, [sensor_all]) + + aiport._api = ufp.api + aiport.feature_flags = Mock(is_ptz=False) + await adopt_devices(hass, ufp, [aiport]) + + entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) + assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] + + +async def test_sensor_light_last_motion_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The light's last-motion timestamp reads from the public API.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, light, LIGHT_SENSORS[0] + ) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + # A value the private fixture would not produce proves the public source. + last_motion_ms = 1700000000000 + public = make_public_light(light, last_motion_ms=last_motion_ms) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert ( + hass.states.get(entity_id).state + == convert_to_datetime(last_motion_ms).isoformat() + ) + + +async def test_sensor_light_last_motion_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated last-motion sensor is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SENSOR, light, LIGHT_SENSORS[0] + ) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 3d2ca313c0cd..a0b3f5a371d8 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -24,7 +24,14 @@ from homeassistant.components.unifiprotect.switch import ( PRIVACY_MODE_SWITCH, ProtectSwitchEntityDescription, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -37,7 +44,10 @@ from .utils import ( enable_entity, ids_from_device_description, init_entry, + make_public_light, + public_device_ws_message, remove_entities, + setup_public_light, ) CAMERA_SWITCHES_BASIC = [ @@ -139,6 +149,7 @@ async def test_switch_setup_light( ) -> None: """Test switch entity setup for light devices.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) @@ -269,6 +280,7 @@ async def test_switch_light_status( ) -> None: """Tests status light switch for lights.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) @@ -279,7 +291,7 @@ async def test_switch_light_status( ) with patch_ufp_method( - light, "set_status_light", new_callable=AsyncMock + light, "set_status_light_public", new_callable=AsyncMock ) as mock_method: await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -294,6 +306,40 @@ async def test_switch_light_status( mock_method.assert_called_with(False) +async def test_switch_light_status_public_value( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """Status light switch reads from the public object and refreshes on a WS update.""" + + setup_public_light(ufp) + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, LIGHT_SWITCHES[1] + ) + assert hass.states.get(entity_id).state == STATE_OFF + + # The private fixture has the indicator disabled; the public ON proves the source. + public = make_public_light(light, is_indicator_enabled=True) + ufp.devices_ws_subscription(public_device_ws_message(public)) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_ON + + +async def test_switch_light_status_unavailable_without_public( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +) -> None: + """The migrated status light switch is unavailable without a public object.""" + + await init_entry(hass, ufp, [light]) + + _, entity_id = await ids_from_device_description( + hass, Platform.SWITCH, light, LIGHT_SWITCHES[1] + ) + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + async def test_switch_camera_ssh( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: @@ -569,6 +615,7 @@ async def test_switch_turn_on_client_error( ) -> None: """Test switch turn on with ClientError raises HomeAssistantError.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) description = LIGHT_SWITCHES[1] @@ -580,7 +627,7 @@ async def test_switch_turn_on_client_error( with ( patch_ufp_method( light, - "set_status_light", + "set_status_light_public", new_callable=AsyncMock, side_effect=ClientError("Test error"), ), @@ -596,6 +643,7 @@ async def test_switch_turn_on_not_authorized( ) -> None: """Test switch turn on with NotAuthorized raises HomeAssistantError.""" + setup_public_light(ufp) await init_entry(hass, ufp, [light]) description = LIGHT_SWITCHES[1] @@ -607,7 +655,7 @@ async def test_switch_turn_on_not_authorized( with ( patch_ufp_method( light, - "set_status_light", + "set_status_light_public", new_callable=AsyncMock, side_effect=NotAuthorized("Not authorized"), ), diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index db5564c01226..4ae15eef9acf 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -6,13 +6,17 @@ from datetime import timedelta from unittest.mock import Mock from uiprotect import EventChange, ProtectApiClient, ProtectEvent +from uiprotect.api import RTSPSStreams from uiprotect.data import ( Bootstrap, Camera, + ChannelQuality, DeviceState, Event, EventType, Light, + LightModeEnableType, + LightModeType, ModelType, MountType, ProtectAdoptableDeviceModel, @@ -23,12 +27,17 @@ from uiprotect.data import ( ) from uiprotect.data.bootstrap import ProtectDeviceRef from uiprotect.data.public_devices import ( + PublicCamera, + PublicHdrMode, PublicLight, PublicLightDeviceSettings, + PublicLightModeSettings, PublicSensor, + PublicSensorLeakSettings, PublicSensorMotionSettingsRead, PublicWirelessBatteryStatus, PublicWirelessConnectionState, + SensorFeatureCapability, ) from uiprotect.test_util.anonymize import random_hex from uiprotect.websocket import WebsocketState @@ -225,6 +234,21 @@ async def init_entry( await hass.async_block_till_done() +def public_rtsps_for(camera: Camera) -> RTSPSStreams | None: + """Build a camera's primed RTSPS streams from its RTSP-enabled channels. + + Mirrors what the library writes onto ``PublicCamera.rtsps_streams`` during + ``update_public()`` — only RTSP-enabled channels carry an active URL, and a + camera with none is left streamless (``None``). + """ + urls = { + channel.rtsps_quality: channel.rtsps_url + for channel in camera.channels + if channel.is_rtsp_enabled and channel.rtsps_quality is not None + } + return RTSPSStreams(**urls) if urls else None + + def make_public_sensor( sensor: Sensor, *, @@ -233,14 +257,25 @@ def make_public_sensor( state: DeviceState | None = None, is_motion_detected: bool | None = None, motion_enabled: bool | None = None, + motion_sensitivity: int | None = None, mount_type: MountType | None = None, + is_opened: bool | None = None, + is_leak_detected: bool | None = None, + is_tampering_detected: bool | None = None, + capabilities: set[SensorFeatureCapability] | None = None, + leak_internal_enabled: bool = False, + leak_external_enabled: bool = False, ) -> Mock: """Build a public-API sensor mirroring a private sensor's migrated fields. - Real ``wireless_connection_state`` / ``motion_settings`` models back the - migrated value paths so a wrong ``ufp_public_value`` path fails the test; - identifiers come from the (synthetic) private sensor fixture, never from real - capture data. Each ``*`` override lets a test diverge from the private value. + Real ``wireless_connection_state`` / ``motion_settings`` / ``leak_settings`` + models back the migrated value paths so a wrong ``ufp_public_value`` path + fails the test; identifiers come from the (synthetic) private sensor fixture, + never from real capture data. Each ``*`` override lets a test diverge from + the private value. The mount-derived enablement properties are computed from + the resolved mount type so a ``mount_type`` override stays consistent. + ``capabilities`` mimics the capability map of newer firmware; ``None`` (the + default) models older firmware without a map, where every entity is created. """ public = Mock(spec=PublicSensor) public.id = sensor.id @@ -248,6 +283,31 @@ def make_public_sensor( public.model = ModelType.SENSOR public.state = DeviceState[sensor.state.name] if state is None else state public.mount_type = sensor.mount_type if mount_type is None else mount_type + public.is_contact_sensor_enabled = public.mount_type in { + MountType.DOOR, + MountType.WINDOW, + MountType.GARAGE, + } + public.is_leak_sensor_enabled = public.mount_type is MountType.LEAK + public.is_opened = sensor.is_opened if is_opened is None else is_opened + public.is_leak_detected = ( + sensor.is_leak_detected if is_leak_detected is None else is_leak_detected + ) + public.is_tampering_detected = ( + sensor.is_tampering_detected + if is_tampering_detected is None + else is_tampering_detected + ) + public.has_feature_flags = capabilities is not None + public.supports = Mock( + side_effect=lambda capability: ( + capabilities is not None and capability in capabilities + ) + ) + public.leak_settings = PublicSensorLeakSettings( + is_internal_enabled=leak_internal_enabled, + is_external_enabled=leak_external_enabled, + ) public.is_motion_detected = ( sensor.is_motion_detected if is_motion_detected is None else is_motion_detected ) @@ -256,7 +316,12 @@ def make_public_sensor( sensor.motion_settings.is_enabled if motion_enabled is None else motion_enabled - ) + ), + sensitivity=( + sensor.motion_settings.sensitivity + if motion_sensitivity is None + else motion_sensitivity + ), ) public.wireless_connection_state = PublicWirelessConnectionState( battery_status=PublicWirelessBatteryStatus( @@ -273,39 +338,125 @@ def make_public_light( light: Light, *, state: DeviceState | None = None, + is_light_on: bool | None = None, + is_dark: bool | None = None, + is_pir_motion_detected: bool | None = None, + last_motion_ms: int | None = None, + led_level: int | None = None, pir_duration_ms: int | None = None, + pir_sensitivity: int | None = None, + is_indicator_enabled: bool | None = None, + light_mode: LightModeType | None = None, + light_mode_enable_at: LightModeEnableType | None = None, ) -> Mock: - """Build a public-API light for the migrated PIR auto-shutoff duration number. + """Build a public-API light mirroring the private fixture's migrated fields. - ``light_device_settings`` mirrors the private fixture (the public API reports - ``pir_duration`` in milliseconds); ``pir_duration_ms`` overrides it so a test - can assert a value the private object would not produce. + Every field the FloodLight entities read over the public API is mirrored from + the private light; each ``*`` override lets a test set a value the private + object would not produce, proving the entity reads the public source. The + public API reports ``pir_duration`` and ``last_motion`` in milliseconds. """ lds = light.light_device_settings + lms = light.light_mode_settings public = Mock(spec=PublicLight) public.id = light.id public.mac = light.mac public.model = ModelType.LIGHT public.state = DeviceState[light.state.name] if state is None else state + public.is_light_on = light.is_light_on if is_light_on is None else is_light_on + public.is_dark = light.is_dark if is_dark is None else is_dark + public.is_pir_motion_detected = ( + light.is_pir_motion_detected + if is_pir_motion_detected is None + else is_pir_motion_detected + ) + if last_motion_ms is not None: + public.last_motion = last_motion_ms + elif light.last_motion is not None: + public.last_motion = round(light.last_motion.timestamp() * 1000) + else: + public.last_motion = None + public.light_mode_settings = PublicLightModeSettings( + mode=lms.mode if light_mode is None else light_mode, + enable_at=( + lms.enable_at if light_mode_enable_at is None else light_mode_enable_at + ), + ) public.light_device_settings = PublicLightDeviceSettings( - is_indicator_enabled=lds.is_indicator_enabled, - led_level=lds.led_level, + is_indicator_enabled=( + lds.is_indicator_enabled + if is_indicator_enabled is None + else is_indicator_enabled + ), + led_level=lds.led_level if led_level is None else led_level, pir_duration=( round(lds.pir_duration.total_seconds() * 1000) if pir_duration_ms is None else pir_duration_ms ), - pir_sensitivity=lds.pir_sensitivity, + pir_sensitivity=( + lds.pir_sensitivity if pir_sensitivity is None else pir_sensitivity + ), ) return public -def setup_public_sensor(ufp: MockUFPFixture) -> None: +_HDR_DISPLAY_TO_PUBLIC = { + "auto": PublicHdrMode.AUTO, + "always": PublicHdrMode.ON, + "off": PublicHdrMode.OFF, +} + + +def make_public_camera( + camera: Camera, + *, + state: DeviceState | None = None, + mic_volume: int | None = None, + hdr_type: PublicHdrMode | None = None, +) -> Mock: + """Build a public-API camera mirroring a private camera's migrated fields. + + ``mic_volume`` and ``hdr_type`` default to values derived from the private + fixture so the public mirror matches it; pass an override to assert a value + the private object would not produce. + """ + public = Mock(spec=PublicCamera) + public.id = camera.id + public.mac = camera.mac + public.name = camera.name + public.display_name = camera.display_name + public.type = camera.type + public.model = ModelType.CAMERA + public.state = DeviceState[camera.state.name] if state is None else state + public.mic_volume = camera.mic_volume if mic_volume is None else mic_volume + public.hdr_type = ( + _HDR_DISPLAY_TO_PUBLIC[camera.hdr_mode_display] + if hdr_type is None + else hdr_type + ) + public.has_package_camera = camera.feature_flags.has_package_camera + public.feature_flags = Mock() + public.feature_flags.support_full_hd_snapshot = ( + camera.feature_flags.support_full_hd_snapshot + ) + qualities = [ChannelQuality.HIGH, ChannelQuality.MEDIUM, ChannelQuality.LOW] + if public.has_package_camera: + qualities.append(ChannelQuality.PACKAGE) + public.hardware_stream_qualities.return_value = qualities + return public + + +def setup_public_sensor( + ufp: MockUFPFixture, + capabilities: set[SensorFeatureCapability] | None = None, +) -> None: """Expose private sensors over the public API via a real ``PublicBootstrap``. Lookups go through the real ``PublicBootstrap.get``; the mirror resolves against the private bootstrap at call time, so it is robust to ``init_entry`` - regenerating device ids. + regenerating device ids. ``capabilities`` is forwarded to the mirror to model + newer firmware with a capability map. """ public_bootstrap = PublicBootstrap() pb = Mock(spec=PublicBootstrap) @@ -320,7 +471,9 @@ def setup_public_sensor(ufp: MockUFPFixture) -> None: model is ModelType.SENSOR and (private := ufp.api.bootstrap.sensors.get(obj_id)) is not None ): - public_bootstrap.sensors[obj_id] = make_public_sensor(private) + public_bootstrap.sensors[obj_id] = make_public_sensor( + private, capabilities=capabilities + ) return public_bootstrap.get(model, obj_id) pb.get = _get @@ -355,6 +508,33 @@ def setup_public_light(ufp: MockUFPFixture) -> None: ufp.api.public_bootstrap = pb +def setup_public_camera(ufp: MockUFPFixture) -> None: + """Expose private cameras over the public API via a real ``PublicBootstrap``. + + Mirrors ``setup_public_sensor`` for ``ModelType.CAMERA`` so the migrated + camera config entities read from the public object. + """ + public_bootstrap = PublicBootstrap() + pb = Mock(spec=PublicBootstrap) + pb.cameras = public_bootstrap.cameras + pb.relays = {} + pb.sirens = {} + pb.arm_mode = None + pb.arm_profiles = {} + + def _get(model: ModelType, obj_id: str) -> ProtectModelWithId | None: + if ( + model is ModelType.CAMERA + and (private := ufp.api.bootstrap.cameras.get(obj_id)) is not None + ): + public_bootstrap.cameras[obj_id] = make_public_camera(private) + return public_bootstrap.get(model, obj_id) + + pb.get = _get + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = pb + + def public_device_ws_message(public_obj: Mock) -> Mock: """Build a public devices WS message carrying a public object.""" msg = Mock() diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 5a7e06411d3b..c9f2fdc11911 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -106,6 +106,10 @@ def mock_igd_device(mock_async_create_device) -> IgdDevice: kibibytes_per_sec_sent=None, packets_per_sec_received=None, packets_per_sec_sent=None, + kibibytes_per_sec_received_no_rollover=None, + kibibytes_per_sec_sent_no_rollover=None, + packets_per_sec_received_no_rollover=None, + packets_per_sec_sent_no_rollover=None, port_mapping_number_of_entries=0, ) diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 058534102e63..537d8c2753c0 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -35,6 +35,10 @@ async def test_upnp_binary_sensors( kibibytes_per_sec_sent=None, packets_per_sec_received=None, packets_per_sec_sent=None, + kibibytes_per_sec_received_no_rollover=None, + kibibytes_per_sec_sent_no_rollover=None, + packets_per_sec_received_no_rollover=None, + packets_per_sec_sent_no_rollover=None, port_mapping_number_of_entries=0, ) diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index 177892c24c85..8c8a8ee24355 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -28,6 +29,26 @@ async def test_upnp_sensors( assert hass.states.get("sensor.mock_name_upload_speed").state == "unknown" assert hass.states.get("sensor.mock_name_packet_download_speed").state == "unknown" assert hass.states.get("sensor.mock_name_packet_upload_speed").state == "unknown" + assert ( + hass.states.get("sensor.mock_name_download_speed_no_rollover_handling").state + == "unknown" + ) + assert ( + hass.states.get("sensor.mock_name_upload_speed_no_rollover_handling").state + == "unknown" + ) + assert ( + hass.states.get( + "sensor.mock_name_packet_download_speed_no_rollover_handling" + ).state + == "unknown" + ) + assert ( + hass.states.get( + "sensor.mock_name_packet_upload_speed_no_rollover_handling" + ).state + == "unknown" + ) # Second poll. mock_igd_device: IgdDevice = mock_config_entry.igd_device @@ -45,6 +66,10 @@ async def test_upnp_sensors( kibibytes_per_sec_sent=20.0, packets_per_sec_received=30.0, packets_per_sec_sent=40.0, + kibibytes_per_sec_received_no_rollover=10.0, + kibibytes_per_sec_sent_no_rollover=20.0, + packets_per_sec_received_no_rollover=30.0, + packets_per_sec_sent_no_rollover=40.0, port_mapping_number_of_entries=0, ) @@ -62,3 +87,60 @@ async def test_upnp_sensors( assert hass.states.get("sensor.mock_name_upload_speed").state == "20.0" assert hass.states.get("sensor.mock_name_packet_download_speed").state == "30.0" assert hass.states.get("sensor.mock_name_packet_upload_speed").state == "40.0" + assert ( + hass.states.get("sensor.mock_name_download_speed_no_rollover_handling").state + == "10.0" + ) + assert ( + hass.states.get("sensor.mock_name_upload_speed_no_rollover_handling").state + == "20.0" + ) + assert ( + hass.states.get( + "sensor.mock_name_packet_download_speed_no_rollover_handling" + ).state + == "30.0" + ) + assert ( + hass.states.get( + "sensor.mock_name_packet_upload_speed_no_rollover_handling" + ).state + == "40.0" + ) + + +async def test_upnp_sensors_no_rollover_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test no-rollover sensors are disabled by default and can be enabled.""" + disabled_entity_id = "sensor.mock_name_download_speed_no_rollover_handling" + other_disabled_entity_ids = ( + "sensor.mock_name_upload_speed_no_rollover_handling", + "sensor.mock_name_packet_download_speed_no_rollover_handling", + "sensor.mock_name_packet_upload_speed_no_rollover_handling", + ) + + assert hass.states.get(disabled_entity_id) is None + for entity_id in other_disabled_entity_ids: + assert hass.states.get(entity_id) is None + + entry = entity_registry.async_get(disabled_entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + for entity_id in other_disabled_entity_ids: + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(disabled_entity_id, disabled_by=None) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + enabled_state = hass.states.get(disabled_entity_id) + assert enabled_state is not None + assert enabled_state.state == "unknown" diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 0ac1ec007274..c5b237682700 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -52,8 +52,8 @@ # name: test_uptime_sensor.2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -68,7 +68,6 @@ 'model_id': None, 'name': 'Uptime', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 0cfc54fa3a2c..800f64359d70 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -651,19 +651,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, utility_meter_config_entry: MockConfigEntry, - sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, expected_entities: set[str], ) -> None: - """Test config entry is removed when the source entity is removed.""" - # Add another config entry to the sensor device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source entity is removed while the source device survives.""" assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() @@ -682,15 +674,12 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries - # Remove the source sensor's config entry from the device, this removes the - # source sensor + # Remove the source sensor with patch( "homeassistant.components.utility_meter.async_unload_entry", wraps=utility_meter.async_unload_entry, ) as mock_unload_entry: - device_registry.async_update_device( - sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id - ) + entity_registry.async_remove(sensor_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() mock_unload_entry.assert_not_called() @@ -703,8 +692,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_shared_d ): assert utility_meter_entity.device_id is None - # Check that the utility_meter config entry is not in the device + # Check that the source device survives and does not contain the utility_meter + # config entry sensor_device = device_registry.async_get(sensor_device.id) + assert sensor_device is not None assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Check that the utility_meter config entry is not removed @@ -962,7 +953,7 @@ async def test_migration_2_1( tariffs: list[str], expected_entities: set[str], ) -> None: - """Test migration from v2.1 removes utility_meter config entry from device.""" + """Test migration from v2.1 does not add the utility_meter config entry to the device.""" utility_meter_config_entry = MockConfigEntry( data={}, @@ -983,25 +974,15 @@ async def test_migration_2_1( ) utility_meter_config_entry.add_to_hass(hass) - # Add the helper config entry to the device - device_registry.async_update_device( - sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id - ) - - # Check preconditions - sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries - await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() assert utility_meter_config_entry.state is ConfigEntryState.LOADED - # Check that the helper config entry is removed from the device and the helper + # Check that the helper config entry is not in the device and the helper # entities are linked to the source device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries - # Check that the entities are linked to the other device entities = set() for ( utility_meter_entity diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 847366bf21a3..f70c73280ee9 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -22,7 +22,7 @@ 'unique_id': 'ABC123', 'version': 1, }), - 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", + 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=230, charge_energy=1.8, charge_mode=, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=25, logo_led=75, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", 'host_status': 200, 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"VoltageInstallation":230,"ChargeEnergy":1.8,"ChargeMode":1,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"LightLED":25,"LogoLED":75,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', }) diff --git a/tests/components/vacuum/test_llm.py b/tests/components/vacuum/test_llm.py new file mode 100644 index 000000000000..441b1d8eb6df --- /dev/null +++ b/tests/components/vacuum/test_llm.py @@ -0,0 +1,59 @@ +"""Tests for the vacuum LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.vacuum import llm as vacuum_llm +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "vacuum.test" +INTENTS = {"HassVacuumCleanArea", "HassVacuumReturnToBase", "HassVacuumStart"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a vacuum entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "vacuum", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test vacuum"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the vacuum platform.""" + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed vacuum entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no vacuum entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass) + assert vacuum_llm.async_get_tools(hass, _llm_context(), "assist") is None + + +async def test_no_tools_for_other_api(hass: HomeAssistant) -> None: + """Test the platform returns None for an unsupported API.""" + assert vacuum_llm.async_get_tools(hass, _llm_context(), "other") is None diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 12f61e451de1..643f9f73e7a4 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,7 +1,7 @@ """Fixtures for the Velbus tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import pytest from velbusaio.channels import ( @@ -209,6 +209,7 @@ def mock_buttoncounter() -> AsyncMock: channel.get_state.return_value = 100 channel.get_unit.return_value = "W" channel.get_counter_state.return_value = 100 + type(channel).energy = PropertyMock(return_value=100.0) channel.get_counter_unit.return_value = "kWh" return channel diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index 0383abc0313b..96165c662ff8 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,15 +25,14 @@ 'model_id': '99', 'name': 'Bedroom kid 1', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -54,15 +53,14 @@ 'model_id': '8', 'name': 'Input', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -83,15 +81,14 @@ 'model_id': '123', 'name': 'Kitchen (VMB2BLE)', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '2.0.0', 'via_device_id': None, }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -112,15 +109,14 @@ 'model_id': '9', 'name': 'Dimmer full name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6g7', 'sw_version': '1.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -141,15 +137,14 @@ 'model_id': '10', 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '12345', 'sw_version': '1.0.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -170,15 +165,14 @@ 'model_id': '4', 'name': 'Input', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'a1b2c3d4e5f6', 'sw_version': '1.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -199,15 +193,14 @@ 'model_id': '1', 'name': 'Living room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'asdfghjk', 'sw_version': '3.0.0', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -228,15 +221,14 @@ 'model_id': '3', 'name': 'Kitchen', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'qwerty1234567', 'sw_version': '1.1.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -257,15 +249,14 @@ 'model_id': '2', 'name': 'Living room', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'qwerty123', 'sw_version': '1.0.1', 'via_device_id': , }), DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -286,7 +277,6 @@ 'model_id': '10', 'name': 'Basement', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234', 'sw_version': '1.0.1', 'via_device_id': , diff --git a/tests/components/velbus/test_sensor.py b/tests/components/velbus/test_sensor.py index d89d2de59db6..a546a7622d2c 100644 --- a/tests/components/velbus/test_sensor.py +++ b/tests/components/velbus/test_sensor.py @@ -1,10 +1,10 @@ """Velbus sensor platform tests.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, PropertyMock, patch from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,3 +24,17 @@ async def test_entities( await init_integration(hass, config_entry) await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_vmb8in_counter_energy_unavailable_when_no_energy( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_buttoncounter: AsyncMock, +) -> None: + """Test VMB8IN-20 counter sensor is unknown when energy has not been received.""" + type(mock_buttoncounter).energy = PropertyMock(return_value=None) + await init_integration(hass, config_entry) + + state = hass.states.get("sensor.input_buttoncounter_counter") + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index edc8d4a1ec3b..7e0e14156aa9 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -72,6 +72,8 @@ def mock_window() -> AsyncMock: window.rain_sensor = True window.serial_number = "123456789" window.get_limitation_min.return_value = MagicMock(position_percent=0) + window.get_limitation_max.return_value = MagicMock(position_percent=100) + window.set_position_limitations = AsyncMock() window.device_updated_cbs = [] window.is_opening = False window.is_closing = False @@ -98,6 +100,9 @@ def mock_dual_roller_shutter() -> AsyncMock: position_percent=30, closed=False, known=True ) cover.position = MagicMock(position_percent=30, closed=False, known=True) + cover.get_limitation_min.return_value = MagicMock(position_percent=0) + cover.get_limitation_max.return_value = MagicMock(position_percent=100) + cover.set_position_limitations = AsyncMock() cover.pyvlx = MagicMock() return cover @@ -120,6 +125,9 @@ def mock_blind() -> AsyncMock: blind.close_orientation = AsyncMock() blind.stop_orientation = AsyncMock() blind.set_orientation = AsyncMock() + blind.get_limitation_min.return_value = MagicMock(position_percent=0) + blind.get_limitation_max.return_value = MagicMock(position_percent=100) + blind.set_position_limitations = AsyncMock() blind.pyvlx = MagicMock() return blind @@ -191,6 +199,9 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: cover.position_lower_curtain = MagicMock( position_percent=30, closed=False, known=True ) + cover.get_limitation_min.return_value = MagicMock(position_percent=0) + cover.get_limitation_max.return_value = MagicMock(position_percent=100) + cover.set_position_limitations = AsyncMock() cover.pyvlx = MagicMock() return cover diff --git a/tests/components/velux/snapshots/test_number.ambr b/tests/components/velux/snapshots/test_number.ambr index 652cdc8693f4..83963eef53c9 100644 --- a/tests/components/velux/snapshots/test_number.ambr +++ b/tests/components/velux/snapshots/test_number.ambr @@ -1,4 +1,244 @@ # serializer version: 1 +# name: test_number_setup[number.test_blind_closed_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_blind_closed_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Closed position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Closed position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'closed_position_limitation', + 'unique_id': '4711_closed_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_blind_closed_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Blind Closed position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_blind_closed_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number_setup[number.test_blind_open_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_blind_open_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Open position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'open_position_limitation', + 'unique_id': '4711_open_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_blind_open_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Blind Open position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_blind_open_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_setup[number.test_dual_roller_shutter_closed_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_dual_roller_shutter_closed_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Closed position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Closed position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'closed_position_limitation', + 'unique_id': '987654321_closed_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_dual_roller_shutter_closed_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Dual Roller Shutter Closed position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_dual_roller_shutter_closed_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number_setup[number.test_dual_roller_shutter_open_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_dual_roller_shutter_open_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Open position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'open_position_limitation', + 'unique_id': '987654321_open_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_dual_roller_shutter_open_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Dual Roller Shutter Open position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_dual_roller_shutter_open_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_number_setup[number.test_exterior_heating-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -59,3 +299,123 @@ 'state': '33', }) # --- +# name: test_number_setup[number.test_window_closed_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_window_closed_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Closed position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Closed position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'closed_position_limitation', + 'unique_id': '123456789_closed_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_window_closed_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Window Closed position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_window_closed_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number_setup[number.test_window_open_position_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_window_open_position_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Open position limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open position limit', + 'platform': 'velux', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'open_position_limitation', + 'unique_id': '123456789_open_limitation', + 'unit_of_measurement': , + }) +# --- +# name: test_number_setup[number.test_window_open_position_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Window Open position limit', + : 100, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.test_window_open_position_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index 18657928e492..794e0f4a5246 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -133,6 +133,7 @@ async def test_rain_sensor_unavailability( # Simulate communication error mock_window.get_limitation_min.side_effect = PyVLXException("Connection failed") + mock_window.get_limitation_max.side_effect = PyVLXException("Connection failed") await update_polled_entities(hass, freezer) # Entity should now be unavailable @@ -143,6 +144,7 @@ async def test_rain_sensor_unavailability( # Simulate recovery mock_window.get_limitation_min.side_effect = None mock_window.get_limitation_min.return_value.position_percent = 0 + mock_window.get_limitation_max.side_effect = None await update_polled_entities(hass, freezer) # Entity should be available again state = hass.states.get(test_entity_id) diff --git a/tests/components/velux/test_number.py b/tests/components/velux/test_number.py index 47648ef0c6a7..0dda7c7e8f45 100644 --- a/tests/components/velux/test_number.py +++ b/tests/components/velux/test_number.py @@ -1,9 +1,11 @@ """Test Velux number entities.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest -from pyvlx import Intensity +from pyvlx import Intensity, PyVLXException +from pyvlx.opening_device import Position from homeassistant.components.number import ( ATTR_VALUE, @@ -11,12 +13,12 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, ) from homeassistant.components.velux.const import DOMAIN -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import update_callback_entity +from . import update_callback_entity, update_polled_entities from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform @@ -34,6 +36,7 @@ def get_number_entity_id(mock: AsyncMock) -> str: return f"number.{mock.name.lower().replace(' ', '_')}" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -49,7 +52,7 @@ async def test_number_setup( ) -async def test_number_device_association( +async def test_heating_entity_number_device_association( hass: HomeAssistant, mock_exterior_heating: AsyncMock, entity_registry: er.EntityRegistry, @@ -127,3 +130,196 @@ async def test_set_invalid_value_fails( ) mock_exterior_heating.set_intensity.assert_not_awaited() + + +def closed_limit_entity_id(mock: AsyncMock) -> str: + """Return entity ID of the closed position limit entity.""" + return f"number.{mock.name.lower().replace(' ', '_')}_closed_position_limit" + + +def open_limit_entity_id(mock: AsyncMock) -> str: + """Return entity ID of the open position limit entity.""" + return f"number.{mock.name.lower().replace(' ', '_')}_open_position_limit" + + +async def test_limitation_entity_number_device_association( + hass: HomeAssistant, + mock_window: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Ensure limitation number entity is associated with a device.""" + entity_id = closed_limit_entity_id(mock_window) + + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.device_id is not None + device_entry = device_registry.async_get(entry.device_id) + assert device_entry is not None + assert (DOMAIN, mock_window.serial_number) in device_entry.identifiers + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +async def test_limitation_entities_created( + hass: HomeAssistant, + mock_window: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Open and closed position limit entities are created disabled by default.""" + for get_entity_id in (closed_limit_entity_id, open_limit_entity_id): + entry = entity_registry.async_get(get_entity_id(mock_window)) + assert entry is not None + assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_limitation_entities_enabled_state( + hass: HomeAssistant, + mock_window: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """After enabling, open/closed limit entities reflect HA-side opening semantics.""" + # HA minimum opening comes from pyvlx max, HA maximum opening comes from pyvlx min. + mock_window.get_limitation_min.return_value = MagicMock(position_percent=0) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=100) + await update_polled_entities(hass, freezer) + + assert hass.states.get(closed_limit_entity_id(mock_window)).state == "0" + assert hass.states.get(open_limit_entity_id(mock_window)).state == "100" + + mock_window.get_limitation_min.return_value = MagicMock(position_percent=50) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=30) + await update_polled_entities(hass, freezer) + + assert hass.states.get(closed_limit_entity_id(mock_window)).state == "70" + assert hass.states.get(open_limit_entity_id(mock_window)).state == "50" + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_limitation_entity_bounds_follow_sibling_value( + hass: HomeAssistant, + mock_window: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Reported min/max bounds track the sibling limitation value.""" + mock_window.get_limitation_min.return_value = MagicMock(position_percent=20) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=70) + await update_polled_entities(hass, freezer) + + closed_state = hass.states.get(closed_limit_entity_id(mock_window)) + open_state = hass.states.get(open_limit_entity_id(mock_window)) + + assert closed_state is not None + assert open_state is not None + assert closed_state.attributes["max"] == 80 + assert open_state.attributes["min"] == 30 + + mock_window.get_limitation_min.return_value = MagicMock(position_percent=40) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=90) + await update_polled_entities(hass, freezer) + + closed_state = hass.states.get(closed_limit_entity_id(mock_window)) + open_state = hass.states.get(open_limit_entity_id(mock_window)) + + assert closed_state is not None + assert open_state is not None + assert closed_state.attributes["max"] == 60 + assert open_state.attributes["min"] == 10 + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_min_limitation( + hass: HomeAssistant, + mock_window: AsyncMock, +) -> None: + """Setting HA minimum opening updates pyvlx max and preserves pyvlx min.""" + entity_id = closed_limit_entity_id(mock_window) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 40, "entity_id": entity_id}, + blocking=True, + ) + + mock_window.set_position_limitations.assert_awaited_once_with( + position_min=mock_window.get_limitation_min.return_value, + position_max=Position(position_percent=60), + ) + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_limitation_updates_state_optimistically( + hass: HomeAssistant, + mock_window: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Setting a limitation updates the entity state before the next refresh.""" + mock_window.get_limitation_min.return_value = MagicMock(position_percent=0) + mock_window.get_limitation_max.return_value = MagicMock(position_percent=100) + await update_polled_entities(hass, freezer) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 40, "entity_id": closed_limit_entity_id(mock_window)}, + blocking=True, + ) + + assert hass.states.get(closed_limit_entity_id(mock_window)).state == "40" + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_max_limitation( + hass: HomeAssistant, + mock_window: AsyncMock, +) -> None: + """Setting HA maximum opening updates pyvlx min and preserves pyvlx max.""" + entity_id = open_limit_entity_id(mock_window) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 70, "entity_id": entity_id}, + blocking=True, + ) + + mock_window.set_position_limitations.assert_awaited_once_with( + position_min=Position(position_percent=30), + position_max=mock_window.get_limitation_max.return_value, + ) + + +@pytest.mark.parametrize("mock_pyvlx", ["mock_window"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_limitation_unavailable_on_error( + hass: HomeAssistant, + mock_window: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Entities become unavailable when pyvlx raises an exception.""" + mock_window.get_limitation_min.side_effect = PyVLXException("Connection lost") + mock_window.get_limitation_max.side_effect = PyVLXException("Connection lost") + await update_polled_entities(hass, freezer) + + assert ( + hass.states.get(closed_limit_entity_id(mock_window)).state == STATE_UNAVAILABLE + ) + assert hass.states.get(open_limit_entity_id(mock_window)).state == STATE_UNAVAILABLE + + # Recovery + mock_window.get_limitation_min.side_effect = None + mock_window.get_limitation_min.return_value = MagicMock(position_percent=0) + mock_window.get_limitation_max.side_effect = None + mock_window.get_limitation_max.return_value = MagicMock(position_percent=0) + await update_polled_entities(hass, freezer) + + assert ( + hass.states.get(closed_limit_entity_id(mock_window)).state != STATE_UNAVAILABLE + ) + assert hass.states.get(open_limit_entity_id(mock_window)).state != STATE_UNAVAILABLE diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 689780a8b19f..017b2a203543 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -14,6 +14,7 @@ ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" ENTITY_FAN = "fan.SmartTowerFan" +ENTITY_PEDESTAL_FAN = "fan.corebreeze_432s" ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" @@ -74,6 +75,9 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/cloud/v1/deviceManaged/deviceDetail", "dimmer-detail.json") ], "SmartTowerFan": [("post", "/cloud/v2/deviceManaged/bypassV2", "fan-detail.json")], + "CoreBreeze 432S": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "pedestal-fan-detail.json") + ], "Humidifier 6000s": [ ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-6000s-detail.json") ], @@ -95,9 +99,15 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { def mock_devices_response( - aioclient_mock: AiohttpClientMocker, device_name: str + aioclient_mock: AiohttpClientMocker, + device_name: str, + details_override: dict[str, Any] | None = None, ) -> None: - """Build a response for the Helpers.call_api method.""" + """Build a response for the Helpers.call_api method. + + ``details_override`` is merged into the nested ``result`` payload of the + device detail response, allowing tests to simulate specific device states. + """ device_list = [ device for device in ALL_DEVICES["result"]["list"] @@ -122,9 +132,16 @@ def mock_devices_response( ) for fixture in DEVICE_FIXTURES[device_name]: + detail = load_json_object_fixture(fixture[2], DOMAIN) + if details_override: + assert "result" in detail.get("result", {}), ( + f"Fixture {fixture[2]} does not have the expected " + "result.result payload to apply details_override" + ) + detail["result"]["result"].update(details_override) getattr(aioclient_mock, fixture[0])( f"https://smartapi.vesync.com{fixture[1]}", - json=load_json_object_fixture(fixture[2], DOMAIN), + json=detail, ) mock_firmware(aioclient_mock) diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 8c9e14d0c9e7..7cf61df21e13 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -331,6 +331,29 @@ async def fan_config_entry( return entry +@pytest.fixture(name="pedestal_fan_config_entry") +async def pedestal_fan_config_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `CoreBreeze 432S`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + unique_id="TESTACCOUNTID", + version=1, + minor_version=3, + ) + entry.add_to_hass(hass) + + device_name = "CoreBreeze 432S" + mock_multiple_device_responses(aioclient_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config diff --git a/tests/components/vesync/fixtures/pedestal-fan-detail.json b/tests/components/vesync/fixtures/pedestal-fan-detail.json new file mode 100644 index 000000000000..b2e88fb5ef09 --- /dev/null +++ b/tests/components/vesync/fixtures/pedestal-fan-detail.json @@ -0,0 +1,33 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "powerSwitch": 0, + "workMode": "normal", + "fanSpeedLevel": 1, + "temperature": 717, + "muteSwitch": 1, + "muteState": 1, + "screenState": 0, + "screenSwitch": 0, + "verticalOscillationState": 1, + "horizontalOscillationState": 1, + "childLock": 0, + "errorCode": 0, + "oscillationCoordinate": null, + "oscillationRange": null, + "sleepPreference": { + "sleepPreferenceType": 0, + "oscillationState": 0, + "fallAsleepRemain": 0, + "initFanSpeedLevel": 0 + } + } + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index 6b9445ceace3..c5d5d91bbcd0 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -281,6 +281,21 @@ "subDeviceList": null, "extension": null, "deviceProp": null + }, + { + "deviceRegion": "US", + "isOwner": true, + "cid": "corebreeze432s", + "deviceType": "LPF-R432S-AEU", + "deviceName": "CoreBreeze 432S", + "deviceImg": "", + "type": "", + "connectionType": "", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-555555555555", + "configModule": "configModule" } ] } diff --git a/tests/components/vesync/snapshots/test_binary_sensor.ambr b/tests/components/vesync/snapshots/test_binary_sensor.ambr index 81ab44715077..872d2b3536c1 100644 --- a/tests/components/vesync/snapshots/test_binary_sensor.ambr +++ b/tests/components/vesync/snapshots/test_binary_sensor.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -262,8 +255,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -284,7 +277,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -327,8 +319,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -349,7 +341,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -462,8 +453,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -484,7 +475,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -597,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -619,7 +609,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -704,8 +693,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -726,7 +715,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -741,8 +729,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -763,7 +751,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -778,8 +765,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -800,7 +787,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -815,8 +801,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -837,7 +823,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -848,3 +833,39 @@ list([ ]) # --- +# name: test_sensor_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 1ffd9f9744bb..c3b59abbe3de 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -104,8 +103,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -126,7 +125,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -205,8 +203,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -227,7 +225,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -308,8 +305,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -330,7 +327,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -411,8 +407,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -433,7 +429,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -448,8 +443,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -470,7 +465,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -485,8 +479,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -507,7 +501,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -522,8 +515,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -544,7 +537,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -559,8 +551,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -581,7 +573,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -596,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -618,7 +609,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -633,8 +623,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -655,7 +645,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -670,8 +659,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -692,7 +681,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -707,8 +695,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -729,7 +717,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -813,8 +800,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -835,7 +822,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -850,8 +836,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -872,7 +858,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -883,3 +868,107 @@ list([ ]) # --- +# name: test_fan_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'normal', + 'sleep', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.corebreeze_432s', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'vesync', + 'unique_id': 'corebreeze432s', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[CoreBreeze 432S][fan.corebreeze_432s] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'active_time': None, + 'child_lock': False, + 'display_status': 'off', + : 'CoreBreeze 432S', + 'mode': , + : True, + : 8, + : 8.333333333333334, + : 'normal', + : list([ + 'normal', + 'sleep', + 'turbo', + ]), + : , + }), + 'context': , + 'entity_id': 'fan.corebreeze_432s', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vesync/snapshots/test_humidifier.ambr b/tests/components/vesync/snapshots/test_humidifier.ambr index 4f23f141e1f5..c258a9e8538d 100644 --- a/tests/components/vesync/snapshots/test_humidifier.ambr +++ b/tests/components/vesync/snapshots/test_humidifier.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -262,8 +255,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -284,7 +277,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -299,8 +291,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -321,7 +313,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -401,8 +392,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -423,7 +414,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -507,8 +497,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -529,7 +519,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -611,8 +600,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -633,7 +622,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -648,8 +636,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -670,7 +658,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -685,8 +672,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -707,7 +694,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -722,8 +708,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -744,7 +730,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -755,3 +740,39 @@ list([ ]) # --- +# name: test_humidifier_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_humidifier_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 3a8e52b76540..bc15410d43ce 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -40,8 +39,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -62,7 +61,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -77,8 +75,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -99,7 +97,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -114,8 +111,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -136,7 +133,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -151,8 +147,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -173,7 +169,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -188,8 +183,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -210,7 +205,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -225,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -247,7 +241,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -320,8 +313,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -342,7 +335,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -415,8 +407,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -437,7 +429,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -452,8 +443,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -474,7 +465,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -489,8 +479,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -511,7 +501,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -526,8 +515,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -548,7 +537,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -563,8 +551,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -585,7 +573,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -600,8 +587,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -622,7 +609,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -713,8 +699,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -735,7 +721,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -746,3 +731,39 @@ list([ ]) # --- +# name: test_light_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 93753be3c242..b7a4971431b9 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -140,8 +139,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -162,7 +161,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -229,8 +227,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -251,7 +249,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -419,8 +416,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -441,7 +438,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -609,8 +605,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -631,7 +627,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -934,8 +929,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -956,7 +951,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1251,8 +1245,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1273,7 +1267,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1288,8 +1281,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1310,7 +1303,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1325,8 +1317,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1347,7 +1339,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1415,8 +1406,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1437,7 +1428,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -1613,8 +1603,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1635,7 +1625,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1703,8 +1692,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1725,7 +1714,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2076,8 +2064,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2098,7 +2086,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2113,8 +2100,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2135,7 +2122,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2150,8 +2136,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -2172,7 +2158,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -2183,3 +2168,39 @@ list([ ]) # --- +# name: test_sensor_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[CoreBreeze 432S][entities] + list([ + ]) +# --- diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index c92aff5a58cd..b3f0eba8c265 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -88,8 +87,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -110,7 +109,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -221,8 +219,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -243,7 +241,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -354,8 +351,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -376,7 +373,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -487,8 +483,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -509,7 +505,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -524,8 +519,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -546,7 +541,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -561,8 +555,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -583,7 +577,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -598,8 +591,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -620,7 +613,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -635,8 +627,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -657,7 +649,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -768,8 +759,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -790,7 +781,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -997,8 +987,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1019,7 +1009,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1130,8 +1119,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1152,7 +1141,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1216,8 +1204,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1238,7 +1226,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1301,8 +1288,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1323,7 +1310,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1338,8 +1324,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1360,7 +1346,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1420,3 +1405,135 @@ 'state': 'on', }) # --- +# name: test_switch_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.corebreeze_432s_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Display', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'display', + 'unique_id': 'corebreeze432s-display', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.corebreeze_432s_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Child lock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'corebreeze432s-child_lock', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_switch_state[CoreBreeze 432S][switch.corebreeze_432s_display] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'CoreBreeze 432S Display', + }), + 'context': , + 'entity_id': 'switch.corebreeze_432s_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[CoreBreeze 432S][switch.corebreeze_432s_child_lock] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'CoreBreeze 432S Child lock', + }), + 'context': , + 'entity_id': 'switch.corebreeze_432s_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vesync/snapshots/test_update.ambr b/tests/components/vesync/snapshots/test_update.ambr index d9decbad21cf..04d7ff10d744 100644 --- a/tests/components/vesync/snapshots/test_update.ambr +++ b/tests/components/vesync/snapshots/test_update.ambr @@ -3,8 +3,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -25,7 +25,6 @@ 'model_id': None, 'name': 'Air Purifier 131s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -101,8 +100,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -123,7 +122,6 @@ 'model_id': None, 'name': 'Air Purifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -199,8 +197,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -221,7 +219,6 @@ 'model_id': None, 'name': 'Air Purifier 400s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -297,8 +294,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -319,7 +316,6 @@ 'model_id': None, 'name': 'Air Purifier 600s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -395,8 +391,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -417,7 +413,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Cooking', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -493,8 +488,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -515,7 +510,6 @@ 'model_id': None, 'name': 'CS158-AF Air Fryer Standby', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -591,8 +585,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -613,7 +607,6 @@ 'model_id': None, 'name': 'Dimmable Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -689,8 +682,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -711,7 +704,6 @@ 'model_id': None, 'name': 'Dimmer Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -787,8 +779,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -809,7 +801,6 @@ 'model_id': None, 'name': 'Humidifier 200s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -885,8 +876,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -907,7 +898,6 @@ 'model_id': None, 'name': 'Humidifier 6000s', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -983,8 +973,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1005,7 +995,6 @@ 'model_id': None, 'name': 'Humidifier 600S', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1081,8 +1070,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1103,7 +1092,6 @@ 'model_id': None, 'name': 'Outlet', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1179,8 +1167,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1201,7 +1189,6 @@ 'model_id': None, 'name': 'SmartTowerFan', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1277,8 +1264,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1299,7 +1286,6 @@ 'model_id': None, 'name': 'Temperature Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1375,8 +1361,8 @@ list([ DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -1397,7 +1383,6 @@ 'model_id': None, 'name': 'Wall Switch', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -1469,3 +1454,101 @@ 'state': 'on', }) # --- +# --- +# name: test_update_state[CoreBreeze 432S][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'corebreeze432s', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LPF-R432S-AEU', + 'model_id': None, + 'name': 'CoreBreeze 432S', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_update_state[CoreBreeze 432S][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.corebreeze_432s_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Firmware', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'corebreeze432s', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_update_state[CoreBreeze 432S][update.corebreeze_432s_firmware] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : False, + : 'firmware', + : 0, + : '/api/brands/integration/vesync/icon.png', + : 'CoreBreeze 432S Firmware', + : False, + : None, + : None, + : None, + : None, + : None, + : , + : None, + : None, + }), + 'context': , + 'entity_id': 'update.corebreeze_432s_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 326729d12060..436ab852f52e 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -6,13 +6,27 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fan import ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, ENTITY_FAN, mock_devices_response +from .common import ( + ALL_DEVICE_NAMES, + ENTITY_FAN, + ENTITY_PEDESTAL_FAN, + mock_devices_response, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -183,6 +197,79 @@ async def test_set_preset_mode( update_mock.assert_called_once() +async def test_out_of_range_fan_level( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that an out-of-range fan level produces an unknown percentage.""" + + mock_devices_response( + aioclient_mock, "CoreBreeze 432S", details_override={"fanSpeedLevel": -1} + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_PEDESTAL_FAN) + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.attributes[ATTR_PERCENTAGE] is None + + +@pytest.mark.parametrize( + ("action", "api_response", "expectation"), + [ + ("true", True, NoException), + ("false", True, NoException), + ("true", False, pytest.raises(HomeAssistantError)), + ], +) +async def test_pedestal_fan_oscillation( + hass: HomeAssistant, + pedestal_fan_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + action: str, + api_response: bool, + expectation, +) -> None: + """Test oscillation on and off for pedestal fans. + + Pedestal fans (e.g. CoreBreeze 432S / LPF-R432S) expose vertical and + horizontal oscillation as separate toggles. The HA oscillate switch + should control both axes together. + """ + + with ( + expectation, + patch( + "pyvesync.devices.vesyncfan.VeSyncPedestalFan.toggle_vertical_oscillation", + new_callable=AsyncMock, + return_value=api_response, + ) as vertical_mock, + patch( + "pyvesync.devices.vesyncfan.VeSyncPedestalFan." + "toggle_horizontal_oscillation", + new_callable=AsyncMock, + return_value=api_response, + ) as horizontal_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.async_write_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + "oscillate", + {ATTR_ENTITY_ID: ENTITY_PEDESTAL_FAN, "oscillating": action}, + blocking=True, + ) + + await hass.async_block_till_done() + vertical_mock.assert_called_once() + horizontal_mock.assert_called_once() + update_mock.assert_called_once() + + @pytest.mark.parametrize( ("action", "command"), [ diff --git a/tests/components/vibration/__init__.py b/tests/components/vibration/__init__.py new file mode 100644 index 000000000000..7bb5d6ed877e --- /dev/null +++ b/tests/components/vibration/__init__.py @@ -0,0 +1 @@ +"""Tests for the vibration integration.""" diff --git a/tests/components/vibration/test_condition.py b/tests/components/vibration/test_condition.py new file mode 100644 index 000000000000..a81280df8147 --- /dev/null +++ b/tests/components/vibration/test_condition.py @@ -0,0 +1,196 @@ +"""Test vibration conditions.""" + +from typing import Any + +import pytest + +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + ConditionStateDescription, + assert_condition_behavior_all, + assert_condition_behavior_any, + assert_condition_options_supported, + create_target_condition, + parametrize_condition_states_all, + parametrize_condition_states_any, + parametrize_target_entities, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + ("condition_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vibration.is_detected", {}, True, True), + ("vibration.is_not_detected", {}, True, True), + ], +) +async def test_vibration_condition_options_validation( + hass: HomeAssistant, + condition_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vibration conditions support the expected options.""" + await assert_condition_options_supported( + hass, + condition_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_any( + condition="vibration.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + *parametrize_condition_states_any( + condition="vibration.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + ], +) +async def test_vibration_binary_sensor_condition_behavior_any( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test vibration condition for binary_sensor with 'any' behavior.""" + await assert_condition_behavior_any( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("condition_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("condition", "condition_options", "states"), + [ + *parametrize_condition_states_all( + condition="vibration.is_detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + *parametrize_condition_states_all( + condition="vibration.is_not_detected", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ATTR_DEVICE_CLASS: "vibration"}, + ), + ], +) +async def test_vibration_binary_sensor_condition_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + condition_target_config: dict, + entity_id: str, + entities_in_target: int, + condition: str, + condition_options: dict[str, Any], + states: list[ConditionStateDescription], +) -> None: + """Test vibration condition for binary_sensor with 'all' behavior.""" + await assert_condition_behavior_all( + hass, + target_entities=target_binary_sensors, + condition_target_config=condition_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + condition=condition, + condition_options=condition_options, + states=states, + ) + + +@pytest.mark.parametrize( + ( + "condition_key", + "state_matching", + "state_non_matching", + ), + [ + ( + "vibration.is_detected", + STATE_ON, + STATE_OFF, + ), + ( + "vibration.is_not_detected", + STATE_OFF, + STATE_ON, + ), + ], +) +async def test_vibration_condition_excludes_non_vibration_device_class( + hass: HomeAssistant, + condition_key: str, + state_matching: str, + state_non_matching: str, +) -> None: + """Test vibration condition excludes entities without device_class vibration.""" + entity_id_vibration = "binary_sensor.test_vibration" + entity_id_motion = "binary_sensor.test_motion" + + hass.states.async_set( + entity_id_vibration, state_matching, {ATTR_DEVICE_CLASS: "vibration"} + ) + hass.states.async_set( + entity_id_motion, + state_matching, + {ATTR_DEVICE_CLASS: "motion"}, + ) + await hass.async_block_till_done() + + condition_any = await create_target_condition( + hass, + condition=condition_key, + target={CONF_ENTITY_ID: [entity_id_vibration, entity_id_motion]}, + behavior="any", + ) + + assert condition_any.async_check() is True + + hass.states.async_set( + entity_id_vibration, + state_non_matching, + {ATTR_DEVICE_CLASS: "vibration"}, + ) + await hass.async_block_till_done() + + assert condition_any.async_check() is False diff --git a/tests/components/vibration/test_trigger.py b/tests/components/vibration/test_trigger.py new file mode 100644 index 000000000000..98e355952dec --- /dev/null +++ b/tests/components/vibration/test_trigger.py @@ -0,0 +1,200 @@ +"""Test vibration trigger.""" + +from typing import Any + +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from tests.components.common import ( + TriggerStateDescription, + assert_trigger_behavior_all, + assert_trigger_behavior_each, + assert_trigger_behavior_first, + assert_trigger_options_supported, + parametrize_target_entities, + parametrize_trigger_states, + target_entities, +) + + +@pytest.fixture +async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: + """Create multiple binary sensor entities associated with different targets.""" + return await target_entities(hass, "binary_sensor") + + +@pytest.mark.parametrize( + ("trigger_key", "base_options", "supports_behavior", "supports_duration"), + [ + ("vibration.detected", {}, True, True), + ("vibration.cleared", {}, True, True), + ], +) +async def test_vibration_trigger_options_validation( + hass: HomeAssistant, + trigger_key: str, + base_options: dict[str, Any] | None, + supports_behavior: bool, + supports_duration: bool, +) -> None: + """Test that vibration triggers support the expected options.""" + await assert_trigger_options_supported( + hass, + trigger_key, + base_options, + supports_behavior=supports_behavior, + supports_duration=supports_duration, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_each( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires for binary_sensor entities with device_class vibration.""" + await assert_trigger_behavior_each( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_first( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires on the first binary_sensor state change.""" + await assert_trigger_behavior_first( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) + + +@pytest.mark.parametrize( + ("trigger_target_config", "entity_id", "entities_in_target"), + parametrize_target_entities("binary_sensor"), +) +@pytest.mark.parametrize( + ("trigger", "trigger_options", "states"), + [ + *parametrize_trigger_states( + trigger="vibration.detected", + target_states=[STATE_ON], + other_states=[STATE_OFF], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + *parametrize_trigger_states( + trigger="vibration.cleared", + target_states=[STATE_OFF], + other_states=[STATE_ON], + required_filter_attributes={ + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.VIBRATION + }, + trigger_from_none=False, + ), + ], +) +async def test_vibration_trigger_binary_sensor_behavior_all( + hass: HomeAssistant, + target_binary_sensors: dict[str, list[str]], + trigger_target_config: dict, + entity_id: str, + entities_in_target: int, + trigger: str, + trigger_options: dict[str, Any], + states: list[TriggerStateDescription], +) -> None: + """Test vibration trigger fires when all binary_sensors have changed state.""" + await assert_trigger_behavior_all( + hass, + target_entities=target_binary_sensors, + trigger_target_config=trigger_target_config, + entity_id=entity_id, + entities_in_target=entities_in_target, + trigger=trigger, + trigger_options=trigger_options, + states=states, + ) diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 90dc29f25533..4cb265422f3a 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -38,33 +38,37 @@ class MockPyViCare: """Init a single device from json dump.""" self.devices = [] for idx, fixture in enumerate(fixtures): + service = MockViCareService( + f"installation{idx}", f"gateway{idx}", f"deviceId{idx}", fixture + ) self.devices.append( PyViCareDeviceConfig( - MockViCareService( - f"installation{idx}", f"gateway{idx}", f"device{idx}", fixture - ), - f"deviceId{idx}", + service.accessor, + service, "Vitovalor" if fixture.data_file.endswith("VitoValor.json") else f"model{idx}", "Online", + roles=list(fixture.roles), ) ) # Simulate a device with an unsupported deviceType that PyViCare's # `devices` filter would drop but should still appear in `all_devices` # (used by diagnostics). + unsupported_service = MockViCareService( + "installation_unsupported", + "gateway_unsupported", + "deviceId_unsupported", + Fixture(set(), "vicare/dummy-device-no-serial.json"), + ) self.all_devices = [ *self.devices, PyViCareDeviceConfig( - MockViCareService( - "installation_unsupported", - "gateway_unsupported", - "device_unsupported", - Fixture(set(), "vicare/dummy-device-no-serial.json"), - ), - "deviceId_unsupported", + unsupported_service.accessor, + unsupported_service, "unsupported_model", "Online", + roles=[], ), ] @@ -88,6 +92,7 @@ class MockViCareService: """Initialize the mock from a json dump.""" self._test_data = load_json_object_fixture(fixture.data_file) self.fetch_all_features = Mock(return_value=self._test_data) + self.setProperty = Mock() self.roles = fixture.roles self.accessor = ViCareDeviceAccessor(installation_id, gateway_id, device_id) @@ -95,7 +100,7 @@ class MockViCareService: """Return true if requested roles are assigned.""" return requested_roles and set(requested_roles).issubset(self.roles) - def getProperty(self, property_name: str): + def getProperty(self, accessor: ViCareDeviceAccessor, property_name: str): """Read a property from json dump.""" return readFeature(self._test_data["data"], property_name) diff --git a/tests/components/vicare/fixtures/Vitocal250A_cooling.json b/tests/components/vicare/fixtures/Vitocal250A_cooling.json new file mode 100644 index 000000000000..a8eafbdec8f0 --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal250A_cooling.json @@ -0,0 +1,6583 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.actorSensorTest", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "status": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.actorSensorTest" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.brand", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Viessmann" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.brand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.configuration.houseLocation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "altitude": { + "type": "number", + "unit": "meter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.configuration.houseLocation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.demand.external", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.demand.external" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.lock.external", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.lock.external" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.lock.malfunction", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.lock.malfunction" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.info.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [ + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "I.114", + "priority": "info", + "timestamp": "2025-09-04T10:42:08.000Z" + } + ] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.info.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.service.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.service.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.status.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [ + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.134", + "priority": "status", + "timestamp": "2025-10-05T11:52:33.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.123", + "priority": "status", + "timestamp": "2025-10-05T11:52:30.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.165", + "priority": "status", + "timestamp": "2025-10-05T04:49:42.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.120", + "priority": "status", + "timestamp": "2025-09-04T10:42:08.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.219", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.218", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.217", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + }, + { + "accessLevel": "customer", + "audiences": [ + "IS-SUPPLIER", + "IS-DEVELOPMENT", + "IS-MANUFACTURING", + "IS-AFTERSALES", + "IS-AFTERMARKET", + "IS-DEVELOPER-VEG", + "IS-BIG-DATA", + "IS-MANUFACTURING-VEG" + ], + "errorCode": "S.165", + "priority": "status", + "timestamp": "2025-09-04T10:42:05.000Z" + } + ] + } + }, + "timestamp": "2025-10-05T09:41:49.137Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.messages.status.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.parameterIdentification.version", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "0030.0515.2501.0054" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.parameterIdentification.version" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.consumption.limitation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "14aOff" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.consumption.limitation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.statusReport.consumption", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "limit": { + "type": "number", + "unit": "watt", + "value": 0 + }, + "status": { + "type": "string", + "value": "unlimitedAutonomous" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.statusReport.consumption" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.power.statusReport.production", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "limit": { + "type": "number", + "unit": "watt", + "value": 0 + }, + "status": { + "type": "string", + "value": "init" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.power.statusReport.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productMatrix", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "array", + "value": [ + { + "busAddress": 1, + "busType": "CanExternal", + "productFamily": "B_00027_VC250", + "viessmannIdentificationNumber": "################" + } + ] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.productMatrix" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.remoteReset", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.remoteReset" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "deviceSerialVitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.setDefaultValues", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.setDefaultValues" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "begin": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + }, + "end": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "device.time.daylightSaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "begin": { + "type": "string", + "value": "25-03" + }, + "end": { + "type": "string", + "value": "25-10" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.type", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "mono" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.type" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.zigbee.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.zigbee.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.zigbee.status", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/device.zigbee.status" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:41:58.952Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.pumps.internal.target", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.pumps.internal.target" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 33.1 + } + }, + "timestamp": "2025-10-05T10:05:23.125Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.temperature.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:36.387Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.boiler.temperature.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.9 + } + }, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.9 + } + }, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 39, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "threshold": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -13, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0.2, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 4 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.6 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "reduced", + "maxEntries": 4, + "modes": ["normal", "comfort"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "mon": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "sat": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "sun": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "thu": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "tue": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ], + "wed": [ + { + "end": "03:00", + "mode": "normal", + "position": 0, + "start": "00:00" + }, + { + "end": "05:00", + "mode": "comfort", + "position": 1, + "start": "03:00" + }, + { + "end": "24:00", + "mode": "normal", + "position": 2, + "start": "05:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 39, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["cooling", "heating", "heatingCooling", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "cooling" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normalHeating" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "cooling" + }, + "reason": { + "type": "string", + "value": "summerEco" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "reason": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/activate" + }, + "deactivate": { + "isExecutable": false, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 37, + "min": 3, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "heating" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 18 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.humidity.dewpoint", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.humidity.dewpoint" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 29.8 + } + }, + "timestamp": "2025-10-05T10:09:15.988Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 30 + } + }, + "timestamp": "2025-10-05T09:43:37.884Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 60 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.zone.mode", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.humidity.dewpoint", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.humidity.dewpoint" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:41:43.236Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.configuration.summerEco.absolute", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.configuration.summerEco.absolute" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.cooling.hysteresis", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.cooling.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.cooling.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.cooling.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.hysteresis.switch", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.hysteresis.switch" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.comfortHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.comfortHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.forcedLastFromSchedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.forcedLastFromSchedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.normalHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.normalHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedCoolingEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedCoolingEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedEnergySaving", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedEnergySaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.reducedHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.reducedHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.operating.programs.summerEco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.operating.programs.summerEco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.demand", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.demand" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.3.zone.mode", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.circuits.3.zone.mode" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": { + "setActive": { + "isExecutable": false, + "name": "setActive", + "params": { + "active": { + "constraints": {}, + "required": true, + "type": "boolean" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0/commands/setActive" + }, + "setPhase": { + "isExecutable": false, + "name": "setPhase", + "params": { + "value": { + "constraints": { + "enum": ["off", "preparing", "not-ready", "ready"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0/commands/setPhase" + } + }, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "ready" + } + }, + "timestamp": "2025-10-05T09:39:40.888Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.heater.crankcase", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.heater.crankcase" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.pressure.inlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 6.66 + } + }, + "timestamp": "2025-10-05T10:09:28.719Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.pressure.inlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.inlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 20.9 + } + }, + "timestamp": "2025-10-05T10:04:30.177Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.inlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.motorChamber", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.2 + } + }, + "timestamp": "2025-10-05T10:00:19.787Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.motorChamber" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.oil", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 41.3 + } + }, + "timestamp": "2025-10-05T10:08:15.229Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.oil" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 25.3 + } + }, + "timestamp": "2025-10-05T10:01:46.601Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.sensors.temperature.outlet" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.speed.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "revolutionsPerSecond", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:54.797Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.speed.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 8118 + }, + "starts": { + "type": "number", + "unit": "", + "value": 1502 + } + }, + "timestamp": "2025-10-05T09:15:09.300Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.condensors.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 25.9 + } + }, + "timestamp": "2025-10-05T09:50:27.932Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.condensors.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.bufferCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.bufferCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.centralHeatingCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.centralHeatingCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.highDemand.threshold", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.highDemand.threshold", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.highDemand.threshold" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.highDemand.timeframe", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.highDemand.timeframe", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.highDemand.timeframe" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.configuration.temperature.comfortCharging", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.configuration.dhw.temperature.comfortCharging", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhw.temperature.comfortCharging" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.dhwCylinderSize", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "liter", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.dhwCylinderSize" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.heatingRod.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "useApproved": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.heatingRod.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.heatingRod.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "useApproved": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.heatingRod.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.houseHeatingLoad", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "kilowattHour/year", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseHeatingLoad" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.configuration.houseLocation", + "removalDate": "2025-03-15" + }, + "deviceId": "0", + "feature": "heating.configuration.houseLocation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "altitude": { + "type": "number", + "unit": "meter", + "value": 0 + }, + "latitude": { + "type": "number", + "unit": "degree", + "value": 0 + }, + "longitude": { + "type": "number", + "unit": "degree", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseLocation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.houseOrientation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "horizontal": { + "type": "number", + "unit": "degree", + "value": 0 + }, + "vertical": { + "type": "number", + "unit": "degree", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.houseOrientation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumpOne", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 90 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumpOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumpTwo", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 90 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumpTwo" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.internalPumps", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "defaultLimit": { + "type": "number", + "unit": "percent", + "value": 95 + }, + "maximumLimit": { + "type": "number", + "unit": "percent", + "value": 100 + }, + "minimumLimit": { + "type": "number", + "unit": "percent", + "value": 20 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.internalPumps" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.configuration.temperature.outside.DampingFactor", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "minute", + "value": 10 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.configuration.temperature.outside.DampingFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.time", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:06:38.742Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.device.time" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.variant", + "removalDate": "2025-03-15" + }, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitocal250A" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.actuator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.actuator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.highDemand.threshold", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.highDemand.threshold" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.highDemand.timeframe", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.highDemand.timeframe" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.configuration.temperature.comfortCharging", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.configuration.temperature.comfortCharging" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": false, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/activate" + }, + "disable": { + "isExecutable": false, + "name": "disable", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/disable" + }, + "enable": { + "isExecutable": true, + "name": "enable", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene/commands/enable" + } + }, + "deviceId": "0", + "feature": "heating.dhw.hygiene", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.hygiene.trigger", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.hygiene.trigger" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + }, + "setActive": { + "isExecutable": true, + "name": "setActive", + "params": { + "active": { + "constraints": {}, + "required": true, + "type": "boolean" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/setActive" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["efficientWithMinComfort", "efficient", "off"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "efficient" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.balanced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.balanced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficient", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficient" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.efficientWithMinComfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.efficientWithMinComfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.operating.modes.off", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.operating.modes.off" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-10-05T10:09:08.866Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "mon": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "sat": [ + { + "end": "09:30", + "mode": "on", + "position": 0, + "start": "07:00" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "sun": [ + { + "end": "09:30", + "mode": "on", + "position": 0, + "start": "07:00" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "thu": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "tue": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ], + "wed": [ + { + "end": "09:00", + "mode": "on", + "position": 0, + "start": "05:30" + }, + { + "end": "13:00", + "mode": "on", + "position": 1, + "start": "12:00" + }, + { + "end": "20:00", + "mode": "on", + "position": 2, + "start": "18:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.secondary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.secondary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": true, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 4, + "modes": ["on"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "mon": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "sat": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "sun": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "thu": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "tue": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ], + "wed": [ + { + "end": "03:00", + "mode": "on", + "position": 0, + "start": "01:00" + }, + { + "end": "15:00", + "mode": "on", + "position": 1, + "start": "13:00" + } + ] + } + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 46.8 + } + }, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 46.8 + } + }, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.middle", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.middle", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.middle" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T09:53:51.524Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.tankLoadSystem.return", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.tankLoadSystem.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.tankLoadSystem.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.tankLoadSystem.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hygiene" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 2.5, + "min": 0, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 0 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "default": { + "type": "number", + "unit": "celsius", + "value": 50 + }, + "max": { + "type": "number", + "unit": "celsius", + "value": 10 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 10 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 0, + "efficientUpperBorder": 55, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 55 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.economizers.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 24.1 + } + }, + "timestamp": "2025-10-05T10:07:59.252Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.economizers.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.heater.base", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.heater.base" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.sensors.temperature.liquid", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 13.9 + } + }, + "timestamp": "2025-10-05T10:09:04.503Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.sensors.temperature.liquid" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.evaporators.0.sensors.temperature.overheat", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-10-05T10:05:56.139Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.evaporators.0.sensors.temperature.overheat" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by device.lock.external", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.external.lock", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.external.lock" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.cooling", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 9.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 61.8 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3382.9 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 389.9 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 96.7 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 5903.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heat.production.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 22.8 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 164.3 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 10024.2 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 183.1 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 206.3 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 15472.4 + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heat.production.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heater.condensatePan", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heater.condensatePan" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heater.fanRing", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heater.fanRing" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.maximumOutsideTemperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.maximumOutsideTemperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3.3 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 28 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 29.5 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 53.6 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.heatingRod.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 31 + }, + "starts": { + "type": "number", + "unit": "", + "value": 314 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.heatingRod.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.power.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "ampere", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:48.533Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.power.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.power.output", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "watt", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:39:48.533Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.power.output" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.inverters.0.sensors.temperature.powerModule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-10-05T10:08:59.620Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.inverters.0.sensors.temperature.powerModule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.maxReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.maxReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.notReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.notReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.noise.reduction.levels.slightlyReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.levels.slightlyReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.operating.state", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.maxReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.maxReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.maxReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.notReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.notReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.notReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.noise.reduction.levels.slightlyReduced", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.noise.reduction.operating.programs.slightlyReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.noise.reduction.operating.programs.slightlyReduced" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": true + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holidayAtHome", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "2000-01-01" + }, + "start": { + "type": "string", + "value": "2000-01-01" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holidayAtHome" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.outdoor.defrosting", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.outdoor.defrosting" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.outdoor.defrosting.thermalEnergy", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:08:59.620Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.outdoor.defrosting.thermalEnergy" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [2.6, 5.8, 3.5, 2.3, 1.8, 5.1, 3.4, 5.4] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 16, 89.7, 28.1, 41.7, 31.5, 54.9, 76.3, 144.2, 178.4, 214.3, 210, + 178.7, 128.4 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [24.5, 32.7, 13.8, 22.4, 12.3] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [875.0999999999999, 1536.8] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [4.6, 6.8, 7, 6.1, 7.8, 4.3, 2.8, 4.6] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 32.3, 32.1, 0, 0, 0, 10.4, 205.7, 538.8, 830.5, 915.9, 839.5, + 560.4000000000001, 208.6 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [39.39999999999999, 22.7, 0, 0, 2.3] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [2565.7, 3809.6] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.246Z" + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.cooling", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 0 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 2.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 16 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 875.0999999999999 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 89.7 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 24.5 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 1536.8 + } + }, + "timestamp": "2025-10-05T05:14:18.020Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.summary.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentDay": { + "type": "number", + "unit": "kilowattHour", + "value": 4.6 + }, + "currentMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 32.3 + }, + "currentYear": { + "type": "number", + "unit": "kilowattHour", + "value": 2565.7 + }, + "lastMonth": { + "type": "number", + "unit": "kilowattHour", + "value": 32.1 + }, + "lastSevenDays": { + "type": "number", + "unit": "kilowattHour", + "value": 39.4 + }, + "lastYear": { + "type": "number", + "unit": "kilowattHour", + "value": 3809.6 + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.summary.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.power.consumption.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "day": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 7.199999999999999, 12.6, 10.5, 8.399999999999999, 9.6, + 9.399999999999999, 6.199999999999999, 10 + ] + }, + "dayValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "month": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 48.3, 121.80000000000001, 28.1, 41.7, 31.5, 65.3, 282, 683, 1008.9, + 1130.1999999999998, 1049.5, 739.1000000000001, 337 + ] + }, + "monthValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "week": { + "type": "array", + "unit": "kilowattHour", + "value": [ + 63.89999999999999, 55.400000000000006, 13.8, 22.4, + 14.600000000000001 + ] + }, + "weekValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + }, + "year": { + "type": "array", + "unit": "kilowattHour", + "value": [3440.8, 5346.400000000001] + }, + "yearValueReadAt": { + "type": "string", + "value": "2025-10-05T05:14:16.233Z" + } + }, + "timestamp": "2025-10-05T09:39:30.539Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.power.consumption.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.fans.0.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:44.572Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.fans.0.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.fans.1.current", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "percent", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:44.572Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.fans.1.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 13.9 + } + }, + "timestamp": "2025-10-05T09:52:33.577Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.valves.fourThreeWay", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.valves.fourThreeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.dhw", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.heating", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.spf.total", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.scop.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.scop.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.operation.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "currentValue": { + "type": "string", + "value": "standby" + }, + "targetValue": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2025-10-05T09:39:40.888Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.operation.state" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 32.9 + } + }, + "timestamp": "2025-10-05T10:03:11.852Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.temperature.return.minimum", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 5 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.temperature.return.minimum" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.valves.fourThreeWay", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "current": { + "type": "number", + "unit": "percent", + "value": 52 + }, + "target": { + "type": "number", + "unit": "percent", + "value": 50 + } + }, + "timestamp": "2025-10-05T09:42:04.378Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.valves.fourThreeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "connectionType": { + "type": "string", + "value": "unknown" + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.defrosting", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.defrosting" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.dhw.comfortEnsuring", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.dhw.comfortEnsuring" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.heating.comfortEnsuring", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.heating.comfortEnsuring" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.hygiene", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.hygiene" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.refrigerationCircuitExceeded", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.refrigerationCircuitExceeded" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.runtime", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.runtime" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.configuration.screedDrying", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.configuration.screedDrying" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.energyFactor", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.energyFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.price.low", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.price.low" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.electricity.price.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.electricity.price.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.fossil.energyFactor", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.fossil.energyFactor" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.fossil.price.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.fossil.price.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.state", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.state" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.status", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.status" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.temperature.current", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.temperature.current" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryHeatGenerator.valves.threeWay", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.secondaryHeatGenerator.valves.threeWay" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.seer.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.seer.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.pressure.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "bar", + "value": 1.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.pressure.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 33.8 + } + }, + "timestamp": "2025-10-05T10:08:44.151Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.hydraulicSeparator", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-10-05T10:06:21.715Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.hydraulicSeparator" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 12.2 + } + }, + "timestamp": "2025-10-05T09:35:59.944Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 34.2 + } + }, + "timestamp": "2025-10-05T09:49:34.529Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.volumetricFlow.allengra", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "liter/hour", + "value": 0 + } + }, + "timestamp": "2025-10-05T09:42:08.629Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.sensors.volumetricFlow.allengra" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.8 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.heating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.spf.total", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "", + "value": 3.9 + } + }, + "timestamp": "2025-10-05T05:14:04.320Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.spf.total" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.valves.fourThreeWay.position", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "climatCircuitTwoDefrost" + } + }, + "timestamp": "2025-10-05T09:41:55.621Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/heating.valves.fourThreeWay.position" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "tcu.wifi", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "strength": { + "type": "number", + "unit": "", + "value": -30 + } + }, + "timestamp": "2025-10-05T05:18:18.724Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v2/features/installations/#######/gateways/################/devices/0/features/tcu.wifi" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index fb27ef68d284..4f189f5bc56b 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4715,6 +4715,7 @@ 'id': 'deviceId0', 'modelId': 'model0', 'roles': list([ + 'type:boiler', ]), 'status': 'Online', 'type': None, diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 186bb6680c3b..c203876e7b35 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1218,6 +1218,68 @@ 'state': '46', }) # --- +# name: test_all_entities[sensor.model11_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'cooling', + 'heating', + 'standby', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.model11_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mode', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_mode', + 'unique_id': 'gateway11_zigbee_################-active_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.model11_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'model11 Mode', + : list([ + 'cooling', + 'heating', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.model11_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating', + }) +# --- # name: test_all_entities[sensor.model11_signal_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index ac179456c659..28ae7ae62554 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -6,7 +6,12 @@ import pytest from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError from syrupy.assertion import SnapshotAssertion -from homeassistant.components.climate import ATTR_HVAC_ACTION, HVACAction +from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + HVACAction, + HVACMode, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component, entity_registry as er @@ -165,3 +170,33 @@ async def test_hvac_action_multi_compressor_cooling_takes_precedence( assert climate_states, "no climate entity exposed hvac_action" for state in climate_states: assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + + +async def test_hvac_mode_cooling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """hvac_mode maps the ViCare cooling operating mode to HVACMode.COOL.""" + fixtures: list[Fixture] = [ + Fixture(set(), "vicare/Vitocal250A_cooling.json"), + ] + + with ( + patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + ), + patch( + f"{MODULE}._setup_vicare_api", + return_value=MockPyViCare(fixtures).as_vicare_data(), + ), + patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), + ): + await setup_integration(hass, mock_config_entry) + component: entity_component.EntityComponent = hass.data["climate"] + for entity in component.entities: + await entity.async_update_ha_state(force_refresh=True) + + state = hass.states.get("climate.model0_heating") + assert state is not None + assert state.state == HVACMode.COOL + assert HVACMode.COOL in state.attributes[ATTR_HVAC_MODES] diff --git a/tests/components/vilfo/snapshots/test_init.ambr b/tests/components/vilfo/snapshots/test_init.ambr index 1c33ab98a2c1..500139c8de47 100644 --- a/tests/components/vilfo/snapshots/test_init.ambr +++ b/tests/components/vilfo/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_registry[with_mac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ tuple( @@ -29,7 +29,6 @@ 'model_id': None, 'name': 'Vilfo Router', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1.0', 'via_device_id': None, @@ -38,8 +37,8 @@ # name: test_device_registry[without_mac] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -61,7 +60,6 @@ 'model_id': None, 'name': 'Vilfo Router', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.1.0', 'via_device_id': None, diff --git a/tests/components/vistapool/test_init.py b/tests/components/vistapool/test_init.py index 9d8c6e9d2edb..1522bb60bd6b 100644 --- a/tests/components/vistapool/test_init.py +++ b/tests/components/vistapool/test_init.py @@ -6,11 +6,19 @@ from unittest.mock import AsyncMock, MagicMock from aioaquarite import AquariteError, AuthenticationError +from homeassistant.components.vistapool.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 MOCK_POOL_ID, MOCK_POOL_NAME from tests.common import MockConfigEntry +_SECOND_POOL_ID = "ZYXWVU9876543210" +_SECOND_POOL_NAME = "Spa" +_THIRD_POOL_ID = "QQQQQQ1111111111" + async def test_setup_entry( hass: HomeAssistant, @@ -91,7 +99,7 @@ async def test_setup_entry_subscribe_failure( mock_config_entry: MockConfigEntry, mock_vistapool_client: AsyncMock, ) -> None: - """Test setup retries when the Firestore subscription fails.""" + """Test setup retries when the per-pool Firestore subscription fails.""" mock_vistapool_client.subscribe_pool_resilient.side_effect = AquariteError( "subscribe fail" ) @@ -103,6 +111,176 @@ async def test_setup_entry_subscribe_failure( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_setup_entry_user_pools_subscribe_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test setup retries when the user-pools Firestore subscription fails.""" + mock_vistapool_client.subscribe_user_pools_resilient.side_effect = AquariteError( + "user-pools subscribe fail" + ) + mock_config_entry.add_to_hass(hass) + + assert not 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 + + +async def test_user_pools_snapshot_adds_new_pool( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a user-pools snapshot with a new pool creates its device and entities.""" + 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() + + assert hass.states.get("sensor.my_pool_temperature") is not None + assert hass.states.get("sensor.spa_temperature") is None + + mock_vistapool_client.get_pools.return_value = { + MOCK_POOL_ID: MOCK_POOL_NAME, + _SECOND_POOL_ID: _SECOND_POOL_NAME, + } + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID, _SECOND_POOL_ID]) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is not None + assert device_registry.async_get_device(identifiers={(DOMAIN, _SECOND_POOL_ID)}) + + +async def test_user_pools_snapshot_retries_new_pool_after_refresh_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test a failed first refresh on a new pool is not orphaned and retries next snapshot.""" + 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() + + mock_vistapool_client.get_pools.return_value = { + MOCK_POOL_ID: MOCK_POOL_NAME, + _SECOND_POOL_ID: _SECOND_POOL_NAME, + } + mock_vistapool_client.fetch_pool_data.side_effect = AquariteError("refresh failed") + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID, _SECOND_POOL_ID]) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is None + + mock_vistapool_client.fetch_pool_data.side_effect = None + mock_vistapool_client.fetch_pool_data.return_value = {} + snapshot_cb([MOCK_POOL_ID, _SECOND_POOL_ID]) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is not None + + +async def test_user_pools_snapshot_no_change_is_noop( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, +) -> None: + """Test a snapshot matching the current set does not refetch pool names.""" + 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() + mock_vistapool_client.get_pools.reset_mock() + + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID]) + await hass.async_block_till_done() + + mock_vistapool_client.get_pools.assert_not_called() + + +async def test_user_pools_snapshot_removes_stale_pool( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test a user-pools snapshot missing a pool removes its entities and device.""" + mock_vistapool_client.get_pools.return_value = { + MOCK_POOL_ID: MOCK_POOL_NAME, + _SECOND_POOL_ID: _SECOND_POOL_NAME, + } + 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() + + assert hass.states.get("sensor.spa_temperature") is not None + + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID]) + await hass.async_block_till_done() + + assert hass.states.get("sensor.spa_temperature") is None + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, _SECOND_POOL_ID)}) + is None + ) + + +async def test_user_pools_snapshot_drops_stale_even_if_get_pools_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test stale pool removal still runs when get_pools() raises during reconcile.""" + mock_vistapool_client.get_pools.return_value = { + MOCK_POOL_ID: MOCK_POOL_NAME, + _SECOND_POOL_ID: _SECOND_POOL_NAME, + } + 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() + assert hass.states.get("sensor.spa_temperature") is not None + + mock_vistapool_client.get_pools.side_effect = AquariteError("name lookup down") + snapshot_cb = mock_vistapool_client.subscribe_user_pools_resilient.call_args.args[0] + snapshot_cb([MOCK_POOL_ID, _THIRD_POOL_ID]) + await hass.async_block_till_done() + + # New pool skipped (no name available), stale pool removed regardless. + assert hass.states.get("sensor.spa_temperature") is None + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, _THIRD_POOL_ID)}) is None + ) + + +async def test_setup_prunes_devices_removed_while_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vistapool_client: AsyncMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test setup removes a leftover device for a pool no longer on the account.""" + mock_config_entry.add_to_hass(hass) + stale_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, _SECOND_POOL_ID)}, + ) + assert stale_device is not None + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, MOCK_POOL_ID)}) + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, _SECOND_POOL_ID)}) + is None + ) + + async def test_apply_optimistic_creates_missing_intermediate_dicts( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/vizio/test_remote.py b/tests/components/vizio/test_remote.py index 3c7e3e410265..d3fe4a900c7c 100644 --- a/tests/components/vizio/test_remote.py +++ b/tests/components/vizio/test_remote.py @@ -110,6 +110,12 @@ async def test_turn_on_off( ("BACK", "BACK"), ("ch_up", "CH_UP"), ("SMARTCAST", "SMARTCAST"), + # Aliases + ("closed_captions", "CC_TOGGLE"), + ("channel_up", "CH_UP"), + ("enter", "OK"), + ("volume_down", "VOL_DOWN"), + ("VoLuMe_DoWn", "VOL_DOWN"), ], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") @@ -160,9 +166,13 @@ async def test_send_command_tv_invalid( @pytest.mark.parametrize( ("command", "expected_key"), [ + # Native keys (one lowercase variant tested) ("MUTE_TOGGLE", "MUTE_TOGGLE"), ("pause", "PAUSE"), ("VOL_UP", "VOL_UP"), + # Aliases (only those whose target is a speaker key) + ("mute", "MUTE_TOGGLE"), + ("volume_down", "VOL_DOWN"), ], ) @pytest.mark.usefixtures("vizio_connect", "vizio_update") @@ -189,7 +199,17 @@ async def test_send_command_speaker_valid( mock_remote.assert_called_once_with(expected_key, log_api_exception=False) -@pytest.mark.parametrize("command", ["MENU", "CH_UP", "INVALID_KEY"]) +@pytest.mark.parametrize( + "command", + [ + "MENU", + "CH_UP", + # TV-only alias + "channel_up", + # Completely invalid + "INVALID_KEY", + ], +) @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_send_command_speaker_invalid( hass: HomeAssistant, diff --git a/tests/components/volvo/snapshots/test_device_tracker.ambr b/tests/components/volvo/snapshots/test_device_tracker.ambr index 0eaad5ec01bb..9ec7def8ef50 100644 --- a/tests/components/volvo/snapshots/test_device_tracker.ambr +++ b/tests/components/volvo/snapshots/test_device_tracker.ambr @@ -45,8 +45,8 @@ : 0, : list([ ]), - : 57.72537482589284, - : 11.849843629550225, + : 57.72537482589284, + : 11.849843629550225, : , : , }), @@ -104,8 +104,8 @@ : 0, : list([ ]), - : 57.72537482589284, - : 11.849843629550225, + : 57.72537482589284, + : 11.849843629550225, : , : , }), @@ -163,8 +163,8 @@ : 0, : list([ ]), - : 57.72537482589284, - : 11.849843629550225, + : 57.72537482589284, + : 11.849843629550225, : , : , }), @@ -222,8 +222,8 @@ : 0, : list([ ]), - : 57.72537482589284, - : 11.849843629550225, + : 57.72537482589284, + : 11.849843629550225, : , : , }), diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py index a5c85b57e7a4..7cb642aa9b75 100644 --- a/tests/components/waqi/test_init.py +++ b/tests/components/waqi/test_init.py @@ -201,6 +201,10 @@ async def test_migration_from_v1( "sensor_entity_id": ( "sensor.not_de_jongweg_utrecht_air_quality_index" ), + # Device 2 was created enabled; the migration moves it onto the + # disabled merged config entry, so the move re-evaluates it as disabled + # by CONFIG_ENTRY (the entity keeps its own disabled_by - propagating a + # move-disable to entities is a separate mechanism) "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, "entity_disabled_by": None, "device": 1, diff --git a/tests/components/wattwaechter/snapshots/test_diagnostics.ambr b/tests/components/wattwaechter/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..c293927a0baf --- /dev/null +++ b/tests/components/wattwaechter/snapshots/test_diagnostics.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'device_id': 'ABC123', + 'fw_version': '1.2.3', + 'host': '192.168.1.100', + 'mac': '**REDACTED**', + 'model': 'WW-Plus', + 'token': '**REDACTED**', + }), + 'meter': dict({ + 'datetime_str': '2024-01-01T00:00:00', + 'timestamp': 1704067200, + 'values': dict({ + '1.8.0': dict({ + 'name': 'Total Import', + 'unit': 'kWh', + 'value': 12345.678, + }), + '13.7.0': dict({ + 'name': 'Power Factor', + 'unit': '', + 'value': 0.985, + }), + '14.7.0': dict({ + 'name': 'Frequency', + 'unit': 'Hz', + 'value': 50.01, + }), + '16.7.0': dict({ + 'name': 'Active Power', + 'unit': 'W', + 'value': 1500.5, + }), + '2.8.0': dict({ + 'name': 'Total Export', + 'unit': 'kWh', + 'value': 1234.567, + }), + '31.7.0': dict({ + 'name': 'Current L1', + 'unit': 'A', + 'value': 6.52, + }), + '32.7.0': dict({ + 'name': 'Voltage L1', + 'unit': 'V', + 'value': 230.1, + }), + }), + }), + 'system': dict({ + 'ap': dict({ + }), + 'esp': dict({ + 'esp_id': 'ABC123', + 'os_version': '1.2.3', + }), + 'heap': dict({ + 'free_heap': '120000', + }), + 'uptime': dict({ + 'uptime': '2d 5h 30m', + }), + 'wifi': dict({ + 'ip_address': '192.168.1.100', + 'mac_address': '**REDACTED**', + 'mdns_name': '**REDACTED**', + 'signal_strength': '-45', + 'ssid': '**REDACTED**', + }), + }), + }) +# --- diff --git a/tests/components/wattwaechter/test_diagnostics.py b/tests/components/wattwaechter/test_diagnostics.py new file mode 100644 index 000000000000..6c67343b6a8d --- /dev/null +++ b/tests/components/wattwaechter/test_diagnostics.py @@ -0,0 +1,48 @@ +"""Tests for the WattWächter Plus diagnostics.""" + +from unittest.mock import AsyncMock + +from aio_wattwaechter import WattwaechterConnectionError +from syrupy.assertion import SnapshotAssertion + +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, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +async def test_diagnostics_system_info_unavailable( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, +) -> None: + """Test diagnostics still return config and meter data without system info.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_client.system_info.side_effect = WattwaechterConnectionError("offline") + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result["system"] is None + assert result["meter"] is not None diff --git a/tests/components/wattwaechter/test_sensor.py b/tests/components/wattwaechter/test_sensor.py index 60103cd52748..d20615393038 100644 --- a/tests/components/wattwaechter/test_sensor.py +++ b/tests/components/wattwaechter/test_sensor.py @@ -2,17 +2,20 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from homeassistant.components.wattwaechter.const import DOMAIN +from homeassistant.components.wattwaechter.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .conftest import MOCK_DEVICE_ID, MOCK_METER_DATA_MINIMAL -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_all_entities( @@ -54,3 +57,29 @@ async def test_minimal_meter_data( assert _get_entity_id("2.8.0") is None assert _get_entity_id("32.7.0") is None assert _get_entity_id("31.7.0") is None + + +async def test_sensor_value_unknown_when_obis_stops_reporting( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a sensor reports unknown when its OBIS code is no longer reported.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{MOCK_DEVICE_ID}_2.8.0" + ) + assert entity_id is not None + assert hass.states.get(entity_id).state != STATE_UNKNOWN + + # Device stops reporting the export total OBIS code + mock_client.meter_data.return_value = MOCK_METER_DATA_MINIMAL + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/components/weatherflow_cloud/conftest.py b/tests/components/weatherflow_cloud/conftest.py index 0a2a0bff005e..7c41b305da26 100644 --- a/tests/components/weatherflow_cloud/conftest.py +++ b/tests/components/weatherflow_cloud/conftest.py @@ -151,6 +151,8 @@ async def mock_websocket_api(): mock_ws_instance.connect = AsyncMock() mock_ws_instance.send_message = AsyncMock() mock_ws_instance.register_callback = MagicMock() + mock_ws_instance.stop_all_listeners = AsyncMock() + mock_ws_instance.close = AsyncMock() mock_ws_instance.websocket = mock_websocket with ( diff --git a/tests/components/weatherflow_cloud/test_coordinators.py b/tests/components/weatherflow_cloud/test_coordinators.py index bb38cfacac88..4dd143cfc323 100644 --- a/tests/components/weatherflow_cloud/test_coordinators.py +++ b/tests/components/weatherflow_cloud/test_coordinators.py @@ -34,7 +34,7 @@ async def test_wind_coordinator_setup( mock_websocket_api: AsyncMock, mock_stations_data: Mock, ) -> None: - """Test wind coordinator setup.""" + """Test wind coordinator setup registers callbacks without connecting.""" coordinator = WeatherFlowWindCoordinator( hass=hass, @@ -46,16 +46,12 @@ async def test_wind_coordinator_setup( await coordinator.async_setup() - # Verify websocket setup - mock_websocket_api.connect.assert_called_once() + mock_websocket_api.connect.assert_not_called() mock_websocket_api.register_callback.assert_called_once_with( message_type=EventType.RAPID_WIND, callback=coordinator._handle_websocket_message, ) - # In the refactored code, send_message is called for each device ID assert mock_websocket_api.send_message.called - - # Verify at least one message is of the correct type call_args_list = mock_websocket_api.send_message.call_args_list assert any( isinstance(call.args[0], RapidWindListenStartMessage) for call in call_args_list @@ -69,7 +65,7 @@ async def test_observation_coordinator_setup( mock_websocket_api: AsyncMock, mock_stations_data: Mock, ) -> None: - """Test observation coordinator setup.""" + """Test observation coordinator setup registers callbacks without connecting.""" coordinator = WeatherFlowObservationCoordinator( hass=hass, @@ -81,16 +77,12 @@ async def test_observation_coordinator_setup( await coordinator.async_setup() - # Verify websocket setup - mock_websocket_api.connect.assert_called_once() + mock_websocket_api.connect.assert_not_called() mock_websocket_api.register_callback.assert_called_once_with( message_type=EventType.OBSERVATION, callback=coordinator._handle_websocket_message, ) - # In the refactored code, send_message is called for each device ID assert mock_websocket_api.send_message.called - - # Verify at least one message is of the correct type call_args_list = mock_websocket_api.send_message.call_args_list assert any(isinstance(call.args[0], ListenStartMessage) for call in call_args_list) diff --git a/tests/components/weatherflow_cloud/test_init.py b/tests/components/weatherflow_cloud/test_init.py new file mode 100644 index 000000000000..5030f1481980 --- /dev/null +++ b/tests/components/weatherflow_cloud/test_init.py @@ -0,0 +1,83 @@ +"""Tests for weatherflow_cloud __init__ setup.""" + +from unittest.mock import AsyncMock + +import pytest +from websockets.exceptions import ConnectionClosedError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.util.ssl import client_context + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_rest_api") +async def test_websocket_connect_called_once( + hass: HomeAssistant, + mock_websocket_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the shared websocket is connected exactly once during 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() + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_websocket_api.connect.assert_awaited_once_with(client_context()) + + +@pytest.mark.usefixtures("mock_rest_api") +async def test_entry_unload( + hass: HomeAssistant, + mock_websocket_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that unloading an entry closes the websocket.""" + 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 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 + mock_websocket_api.stop_all_listeners.assert_awaited_once() + mock_websocket_api.close.assert_awaited_once() + + +@pytest.mark.usefixtures("mock_rest_api") +async def test_setup_failure_cleans_up_websocket( + hass: HomeAssistant, + mock_websocket_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test partial setup failure stops listeners and closes the websocket.""" + mock_config_entry.add_to_hass(hass) + mock_websocket_api.send_message.side_effect = ConnectionClosedError(None, None) + + assert not 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 + mock_websocket_api.stop_all_listeners.assert_awaited_once() + mock_websocket_api.close.assert_awaited_once() + + +@pytest.mark.usefixtures("mock_rest_api") +async def test_websocket_connect_failure_sets_entry_not_ready( + hass: HomeAssistant, + mock_websocket_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test websocket connection failure triggers setup retry.""" + mock_config_entry.add_to_hass(hass) + mock_websocket_api.connect.side_effect = OSError("connect failed") + + assert not 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 + mock_websocket_api.connect.assert_awaited_once_with(client_context()) + mock_websocket_api.stop_all_listeners.assert_not_awaited() + mock_websocket_api.close.assert_not_awaited() diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 75a97e2fd54b..b1b172d23bbb 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -38,8 +38,8 @@ # name: test_entity_attributes.1 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -60,7 +60,6 @@ 'model_id': None, 'name': 'LG webOS TV MODEL', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '1234567890', 'sw_version': 'major.minor', 'via_device_id': None, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c87f83a0f273..2fecb7157b0f 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -127,15 +127,30 @@ async def target_entities( area_registry.async_update(label_area.id, labels={label1.label_id}) - device1 = dr.DeviceEntry(id="device1", identifiers={("test", "device1")}) - device2 = dr.DeviceEntry(id="device2", identifiers={("test", "device2")}) + device1 = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="device1", + identifiers={("test", "device1")}, + ) + device2 = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, + id="device2", + identifiers={("test", "device2")}, + ) area_device = dr.DeviceEntry( - id="area_device", identifiers={("test", "device3")}, area_id=kitchen_area.id + config_entry_id=config_entry.entry_id, + id="area_device", + identifiers={("test", "device3")}, + area_id=kitchen_area.id, ) label2_device = dr.DeviceEntry( - id="label_device", identifiers={("test", "device4")}, labels={label2.label_id} + config_entry_id=config_entry.entry_id, + id="label_device", + identifiers={("test", "device4")}, + labels={label2.label_id}, ) diag_only_device = dr.DeviceEntry( + config_entry_id=config_entry.entry_id, id="diag_only_device", identifiers={("test", "device5")}, area_id=garage_area.id, diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 4632544e8d27..5615faf0fdf5 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -10,6 +10,7 @@ from homeassistant.components.websocket_api.messages import ( ) from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Context, Event, HomeAssistant, State, callback +from homeassistant.util.json import json_loads from tests.common import async_capture_events @@ -52,6 +53,19 @@ async def test_cached_event_message(hass: HomeAssistant) -> None: assert cache_info.currsize == 2 +async def test_cached_event_message_is_valid_json(hass: HomeAssistant) -> None: + """Test the cached event message is valid JSON with the id appended.""" + events = async_capture_events(hass, EVENT_STATE_CHANGED) + hass.states.async_set("light.window", "on") + await hass.async_block_till_done() + + parsed = json_loads(cached_event_message(b"2", events[0])) + + assert parsed["id"] == 2 + assert parsed["type"] == "event" + assert parsed["event"]["event_type"] == EVENT_STATE_CHANGED + + async def test_cached_event_message_with_different_idens(hass: HomeAssistant) -> None: """Test that we cache event messages when the subscrition idens differ.""" diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index e2bd4a641db9..a713247d4419 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -182,6 +182,7 @@ def mock_oven_single_cavity_api(): mock_oven.get_oven_cavity_exists.side_effect = lambda cavity: ( cavity == oven.Cavity.Upper ) + mock_oven.get_light.return_value = True mock_oven.get_temp.return_value = 180 mock_oven.get_target_temp.return_value = 200 return mock_oven @@ -205,6 +206,7 @@ def mock_oven_dual_cavity_api(): oven.Cavity.Lower, ) ) + mock_oven.get_light.side_effect = lambda cavity: cavity == oven.Cavity.Upper mock_oven.get_temp.return_value = 180 mock_oven.get_target_temp.return_value = 200 return mock_oven diff --git a/tests/components/whirlpool/snapshots/test_light.ambr b/tests/components/whirlpool/snapshots/test_light.ambr new file mode 100644 index 000000000000..a7699bc54db5 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_light.ambr @@ -0,0 +1,178 @@ +# serializer version: 1 +# name: test_all_entities[light.dual_cavity_oven_lower_oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dual_cavity_oven_lower_oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower oven light', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_light_lower', + 'unique_id': 'said_oven_dual-light_lower', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.dual_cavity_oven_lower_oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'Dual cavity oven Lower oven light', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.dual_cavity_oven_lower_oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[light.dual_cavity_oven_upper_oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dual_cavity_oven_upper_oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper oven light', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_light_upper', + 'unique_id': 'said_oven_dual-light_upper', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.dual_cavity_oven_upper_oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : , + : 'Dual cavity oven Upper oven light', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.dual_cavity_oven_upper_oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[light.single_cavity_oven_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.single_cavity_oven_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Light', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_light', + 'unique_id': 'said_oven_single-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[light.single_cavity_oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : , + : 'Single cavity oven Light', + : list([ + , + ]), + : , + }), + 'context': , + 'entity_id': 'light.single_cavity_oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_number.ambr b/tests/components/whirlpool/snapshots/test_number.ambr new file mode 100644 index 000000000000..841aa241e2b1 --- /dev/null +++ b/tests/components/whirlpool/snapshots/test_number.ambr @@ -0,0 +1,184 @@ +# serializer version: 1 +# name: test_all_entities[number.dual_cavity_oven_lower_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.dual_cavity_oven_lower_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lower oven target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature_lower', + 'unique_id': 'said_oven_dual-target_temperature_lower', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_lower_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Dual cavity oven Lower oven target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.dual_cavity_oven_lower_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_upper_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.dual_cavity_oven_upper_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upper oven target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature_upper', + 'unique_id': 'said_oven_dual-target_temperature_upper', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.dual_cavity_oven_upper_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Dual cavity oven Upper oven target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.dual_cavity_oven_upper_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_all_entities[number.single_cavity_oven_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 290, + : 30, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.single_cavity_oven_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Target temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_target_temperature', + 'unique_id': 'said_oven_single-target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.single_cavity_oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Single cavity oven Target temperature', + : 290, + : 30, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.single_cavity_oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_select.ambr b/tests/components/whirlpool/snapshots/test_select.ambr index 3371180eadcb..191c9a371d30 100644 --- a/tests/components/whirlpool/snapshots/test_select.ambr +++ b/tests/components/whirlpool/snapshots/test_select.ambr @@ -65,3 +65,216 @@ 'state': '0', }) # --- +# name: test_all_entities[select.dual_cavity_oven_lower_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dual_cavity_oven_lower_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lower oven cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower oven cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode_lower', + 'unique_id': 'said_oven_dual-cook_mode_lower', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_lower_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Dual cavity oven Lower oven cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.dual_cavity_oven_lower_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_upper_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dual_cavity_oven_upper_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Upper oven cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper oven cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode_upper', + 'unique_id': 'said_oven_dual-cook_mode_upper', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.dual_cavity_oven_upper_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Dual cavity oven Upper oven cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.dual_cavity_oven_upper_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[select.single_cavity_oven_cook_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.single_cavity_oven_cook_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cook mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cook mode', + 'platform': 'whirlpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oven_cook_mode', + 'unique_id': 'said_oven_single-cook_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.single_cavity_oven_cook_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Single cavity oven Cook mode', + : list([ + 'standby', + 'bake', + 'convection_bake', + 'broil', + 'convection_broil', + 'convection_roast', + 'keep_warm', + 'air_fry', + ]), + }), + 'context': , + 'entity_id': 'select.single_cavity_oven_cook_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index f4ec93c81fe0..ad2625d64019 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -149,78 +149,6 @@ 'state': 'running_maincycle', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Lower oven cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lower oven cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode_lower', - 'unique_id': 'said_oven_dual-oven_cook_mode_lower', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Dual cavity oven Lower oven cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.dual_cavity_oven_lower_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -341,136 +269,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Lower oven target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Lower oven target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature_lower', - 'unique_id': 'said_oven_dual-oven_target_temperature_lower', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_lower_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Dual cavity oven Lower oven target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_lower_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Upper oven cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Upper oven cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode_upper', - 'unique_id': 'said_oven_dual-oven_cook_mode_upper', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Dual cavity oven Upper oven cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.dual_cavity_oven_upper_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -591,136 +389,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Upper oven target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Upper oven target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature_upper', - 'unique_id': 'said_oven_dual-oven_target_temperature_upper', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.dual_cavity_oven_upper_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Dual cavity oven Upper oven target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.dual_cavity_oven_upper_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_cook_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.single_cavity_oven_cook_mode', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Cook mode', - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cook mode', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_cook_mode', - 'unique_id': 'said_oven_single-oven_cook_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_cook_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'enum', - : 'Single cavity oven Cook mode', - : list([ - 'standby', - 'bake', - 'convection_bake', - 'broil', - 'convection_broil', - 'convection_roast', - 'keep_warm', - 'air_fry', - ]), - }), - 'context': , - 'entity_id': 'sensor.single_cavity_oven_cook_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'bake', - }) -# --- # name: test_all_entities[sensor.single_cavity_oven_current_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -841,64 +509,6 @@ 'state': 'standby', }) # --- -# name: test_all_entities[sensor.single_cavity_oven_target_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': dict({ - : , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.single_cavity_oven_target_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Target temperature', - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Target temperature', - 'platform': 'whirlpool', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'oven_target_temperature', - 'unique_id': 'said_oven_single-oven_target_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.single_cavity_oven_target_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - : 'temperature', - : 'Single cavity oven Target temperature', - : , - : , - }), - 'context': , - 'entity_id': 'sensor.single_cavity_oven_target_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '200', - }) -# --- # name: test_all_entities[sensor.washer_detergent_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/whirlpool/test_light.py b/tests/components/whirlpool/test_light.py new file mode 100644 index 000000000000..78951963cc6c --- /dev/null +++ b/tests/components/whirlpool/test_light.py @@ -0,0 +1,99 @@ +"""Test the Whirlpool light platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import whirlpool + +from homeassistant.components.light import ( + DOMAIN as LIGHT_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 init_integration, snapshot_whirlpool_entities + + +@pytest.fixture( + params=[ + ( + "light.single_cavity_oven_light", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "light.dual_cavity_oven_upper_oven_light", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "light.dual_cavity_oven_lower_oven_light", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_light_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven light entities.""" + return request.param + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.LIGHT) + + +@pytest.mark.parametrize( + ("service", "expected_state"), + [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)], +) +async def test_turn_on_off( + hass: HomeAssistant, + oven_light_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, + service: str, + expected_state: bool, +) -> None: + """Test turning the oven light on and off.""" + entity_id, mock_fixture, cavity = oven_light_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock.set_light.assert_called_once_with(expected_state, cavity) + + +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +async def test_turn_on_off_failure( + hass: HomeAssistant, + oven_light_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, + service: str, +) -> None: + """Test a failed light request raises HomeAssistantError.""" + entity_id, mock_fixture, _ = oven_light_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_light.return_value = False + await init_integration(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/whirlpool/test_number.py b/tests/components/whirlpool/test_number.py new file mode 100644 index 000000000000..b2db959dfc34 --- /dev/null +++ b/tests/components/whirlpool/test_number.py @@ -0,0 +1,177 @@ +"""Test the Whirlpool number platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import whirlpool + +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, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback + + +@pytest.fixture( + params=[ + ( + "number.single_cavity_oven_target_temperature", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "number.dual_cavity_oven_upper_oven_target_temperature", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "number.dual_cavity_oven_lower_oven_target_temperature", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_number_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven target-temperature number entities.""" + return request.param + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test all entities.""" + await init_integration(hass) + snapshot_whirlpool_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +async def test_target_temperature_value( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test reading and updating the target temperature.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + assert hass.states.get(entity_id).state == "200" + + mock.get_target_temp.return_value = 220 + await trigger_attr_callback(hass, mock) + assert hass.states.get(entity_id).state == "220" + + +async def test_set_target_temperature( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test setting the target temperature issues a cook command.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_set_fractional_target_temperature( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a fractional target temperature is passed through without truncation.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220.5}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220.5, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_set_target_temperature_failure( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a failed request raises HomeAssistantError.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.return_value = False + await init_integration(hass) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + + +async def test_set_target_temperature_value_error( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a ValueError while setting the temperature raises ServiceValidationError.""" + entity_id, mock_fixture, _ = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.side_effect = ValueError + await init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + + +@pytest.mark.parametrize("current_mode", [whirlpool.oven.CookMode.Standby, None]) +async def test_set_target_temperature_from_idle( + hass: HomeAssistant, + oven_number_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, + current_mode: whirlpool.oven.CookMode | None, +) -> None: + """Test that setting the temperature with no active cook defaults to Bake.""" + entity_id, mock_fixture, cavity = oven_number_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_cook_mode.return_value = current_mode + await init_integration(hass) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 220}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=220, mode=whirlpool.oven.CookMode.Bake, cavity=cavity + ) diff --git a/tests/components/whirlpool/test_select.py b/tests/components/whirlpool/test_select.py index 665b0cb44bf0..fc56c59792c8 100644 --- a/tests/components/whirlpool/test_select.py +++ b/tests/components/whirlpool/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest from syrupy.assertion import SnapshotAssertion +import whirlpool from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform @@ -94,3 +95,128 @@ async def test_select_option_value_error( }, blocking=True, ) + + +@pytest.fixture( + params=[ + ( + "select.single_cavity_oven_cook_mode", + "mock_oven_single_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "select.dual_cavity_oven_upper_oven_cook_mode", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Upper, + ), + ( + "select.dual_cavity_oven_lower_oven_cook_mode", + "mock_oven_dual_cavity_api", + whirlpool.oven.Cavity.Lower, + ), + ] +) +def oven_cook_mode_entity( + request: pytest.FixtureRequest, +) -> tuple[str, str, whirlpool.oven.Cavity]: + """Parametrize the oven cook-mode select entities.""" + return request.param + + +async def test_oven_cook_mode_current( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test reading the current cook mode.""" + entity_id, mock_fixture, _ = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + assert hass.states.get(entity_id).state == "bake" + mock.get_cook_mode.return_value = whirlpool.oven.CookMode.Broil + await trigger_attr_callback(hass, mock) + assert hass.states.get(entity_id).state == "broil" + + +async def test_oven_cook_mode_select( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting a cook mode issues a cook command.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=200, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_oven_cook_mode_select_from_idle( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting a mode with no target set uses the default temperature.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + mock.get_target_temp.return_value = None + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) + mock.set_cook.assert_called_once_with( + target_temp=175, mode=whirlpool.oven.CookMode.Broil, cavity=cavity + ) + + +async def test_oven_cook_mode_select_standby( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test selecting standby stops the cook instead of starting one.""" + entity_id, mock_fixture, cavity = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + await init_integration(hass) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "standby"}, + blocking=True, + ) + mock.stop_cook.assert_called_once_with(cavity) + mock.set_cook.assert_not_called() + + +async def test_oven_cook_mode_select_value_error( + hass: HomeAssistant, + oven_cook_mode_entity: tuple[str, str, whirlpool.oven.Cavity], + request: pytest.FixtureRequest, +) -> None: + """Test a ValueError while setting the cook mode raises ServiceValidationError.""" + entity_id, mock_fixture, _ = oven_cook_mode_entity + mock = request.getfixturevalue(mock_fixture) + mock.set_cook.side_effect = ValueError + await init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "broil"}, + blocking=True, + ) diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 578232f4641b..38cbb4eea01d 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -6,13 +6,16 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from whirlpool.dryer import MachineState as DryerMachineState -from whirlpool.oven import CavityState as OvenCavityState, CookMode +from whirlpool.oven import CavityState as OvenCavityState from whirlpool.washer import MachineState as WasherMachineState +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.whirlpool.const import DOMAIN from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback @@ -324,22 +327,6 @@ async def test_washer_running_states( (None, STATE_UNKNOWN), ], ), - ( - "sensor.dual_cavity_oven_upper_oven_cook_mode", - "mock_oven_dual_cavity_api", - "get_cook_mode", - [ - (CookMode.Standby, "standby"), - (CookMode.Bake, "bake"), - (CookMode.ConvectBake, "convection_bake"), - (CookMode.Broil, "broil"), - (CookMode.ConvectBroil, "convection_broil"), - (CookMode.ConvectRoast, "convection_roast"), - (CookMode.KeepWarm, "keep_warm"), - (CookMode.AirFry, "air_fry"), - (None, STATE_UNKNOWN), - ], - ), ( "sensor.single_cavity_oven_state", "mock_oven_single_cavity_api", @@ -351,22 +338,6 @@ async def test_washer_running_states( (None, STATE_UNKNOWN), ], ), - ( - "sensor.single_cavity_oven_cook_mode", - "mock_oven_single_cavity_api", - "get_cook_mode", - [ - (CookMode.Standby, "standby"), - (CookMode.Bake, "bake"), - (CookMode.ConvectBake, "convection_bake"), - (CookMode.Broil, "broil"), - (CookMode.ConvectBroil, "convection_broil"), - (CookMode.ConvectRoast, "convection_roast"), - (CookMode.KeepWarm, "keep_warm"), - (CookMode.AirFry, "air_fry"), - (None, STATE_UNKNOWN), - ], - ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -390,3 +361,227 @@ async def test_simple_enum_sensors( state = hass.states.get(entity_id) assert state is not None assert state.state == expected_state + + +# The oven cook mode sensor has been replaced by a select entity and is deprecated. +DEPRECATED_COOK_MODE_UNIQUE_ID = "said_oven_single-oven_cook_mode" +DEPRECATED_COOK_MODE_ISSUE_ID = "deprecated_oven_cook_mode_said_oven_single" + + +async def test_oven_cook_mode_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated cook mode sensor is not created on a fresh install.""" + await init_integration(hass) + + assert hass.states.get("sensor.single_cavity_oven_cook_mode") is None + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_cook_mode_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an existing cook mode sensor is kept and raises a repair issue.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + ) + + await init_integration(hass) + + state = hass.states.get("sensor.single_cavity_oven_cook_mode") + assert state is not None + assert state.state == "bake" + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) in issue_registry.issues + + +async def test_oven_cook_mode_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled deprecated cook mode sensor is removed and the issue cleared.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await init_integration(hass) + + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_cook_mode_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled cook mode sensor used by an automation is kept and flagged.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_COOK_MODE_UNIQUE_ID, + suggested_object_id="single_cavity_oven_cook_mode", + disabled_by=er.RegistryEntryDisabler.USER, + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "trigger": { + "platform": "state", + "entity_id": "sensor.single_cavity_oven_cook_mode", + }, + "action": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await init_integration(hass) + + # The sensor is still referenced by an automation, so it is kept and the + # repair issue switches to the variant that lists the usage. + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_COOK_MODE_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_COOK_MODE_ISSUE_ID) + assert issue is not None + assert issue.translation_key == "deprecated_oven_cook_mode_scripts" + + +# The oven target temperature sensor has been replaced by a number entity. +DEPRECATED_TARGET_TEMP_UNIQUE_ID = "said_oven_single-oven_target_temperature" +DEPRECATED_TARGET_TEMP_ISSUE_ID = "deprecated_oven_target_temperature_said_oven_single" + + +async def test_oven_target_temperature_sensor_not_created_for_new_installs( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the deprecated target temperature sensor is not created on a fresh install.""" + await init_integration(hass) + + assert hass.states.get("sensor.single_cavity_oven_target_temperature") is None + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_target_temperature_sensor_deprecated( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an existing target temperature sensor is kept and raises a repair issue.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + ) + + await init_integration(hass) + + state = hass.states.get("sensor.single_cavity_oven_target_temperature") + assert state is not None + assert state.state == "200" + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) in issue_registry.issues + + +async def test_oven_target_temperature_sensor_removed_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled deprecated target temperature sensor is removed.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + disabled_by=er.RegistryEntryDisabler.USER, + ) + + await init_integration(hass) + + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is None + ) + assert (DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) not in issue_registry.issues + + +async def test_oven_target_temperature_sensor_kept_when_used_by_automation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a disabled target temperature sensor used by an automation is kept.""" + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + DEPRECATED_TARGET_TEMP_UNIQUE_ID, + suggested_object_id="single_cavity_oven_target_temperature", + disabled_by=er.RegistryEntryDisabler.USER, + ) + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test_automation", + "trigger": { + "platform": "state", + "entity_id": "sensor.single_cavity_oven_target_temperature", + }, + "action": {"action": "notify.notify", "data": {}}, + } + }, + ) + + await init_integration(hass) + + # The sensor is still referenced by an automation, so it is kept and the + # repair issue switches to the variant that lists the usage. + assert ( + entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, DEPRECATED_TARGET_TEMP_UNIQUE_ID + ) + is not None + ) + issue = issue_registry.async_get_issue(DOMAIN, DEPRECATED_TARGET_TEMP_ISSUE_ID) + assert issue is not None + assert issue.translation_key == "deprecated_oven_target_temperature_scripts" diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 885a32234dd1..1d24b827924e 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -52,8 +52,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_admin].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -74,7 +74,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -134,8 +133,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_created].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -156,7 +155,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -220,8 +218,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_days_until_expiration].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -242,7 +240,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -302,8 +299,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_expires].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -324,7 +321,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -384,8 +380,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_last_updated].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -406,7 +402,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -465,8 +460,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_owner].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -487,7 +482,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -546,8 +540,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_registrant].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -568,7 +562,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -627,8 +620,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_registrar].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -649,7 +642,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -708,8 +700,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_reseller].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -730,7 +722,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -841,8 +832,8 @@ # name: test_whois_sensors[sensor.home_assistant_io_status].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -863,7 +854,6 @@ 'model_id': None, 'name': 'home-assistant.io', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/wiim/test_media_player.py b/tests/components/wiim/test_media_player.py index 057bdc3960a9..ee3ea74aa84b 100644 --- a/tests/components/wiim/test_media_player.py +++ b/tests/components/wiim/test_media_player.py @@ -18,6 +18,7 @@ from wiim.models import ( from wiim.wiim_device import WiimDevice from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_CONTENT_ID, @@ -31,6 +32,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_BROWSE_MEDIA, + SERVICE_JOIN, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, @@ -38,6 +40,7 @@ from homeassistant.components.media_player import ( SERVICE_REPEAT_SET, SERVICE_SELECT_SOURCE, SERVICE_SHUFFLE_SET, + SERVICE_UNJOIN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, BrowseMedia, @@ -47,7 +50,9 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) -from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.components.wiim as wiim_component +from homeassistant.components.wiim.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST from homeassistant.core import HomeAssistant from . import fire_general_update, fire_transport_update, setup_integration @@ -57,6 +62,52 @@ from tests.common import MockConfigEntry MEDIA_PLAYER_ENTITY_ID = "media_player.test_wiim_device" +def _build_mock_wiim_device( + *, + udn: str, + name: str, + ip_address: str, + base_device: MagicMock, +) -> AsyncMock: + """Build a mocked WiiM device for a second integration entry.""" + device = AsyncMock(spec=WiimDevice) + device.udn = udn + device.name = name + device.model_name = "WiiM Pro" + device.manufacturer = "Linkplay Tech" + device.firmware_version = "4.8.523456" + device.ip_address = ip_address + device.http_api_url = f"http://{ip_address}:8080" + device.presentation_url = f"http://{ip_address}:8080/web_interface" + device.available = True + device.volume = 40 + device.is_muted = False + device.supports_http_api = False + device.playing_status = PlayingStatus.STOPPED + device.play_mode = "Network" + device.loop_state = WiimLoopState( + repeat=WiimRepeatMode.OFF, + shuffle=False, + ) + device.output_mode = "speaker" + device.current_media = None + device.supported_input_modes = base_device.supported_input_modes + device.supported_output_modes = base_device.supported_output_modes + device.async_get_transport_capabilities = AsyncMock( + return_value=WiimTransportCapabilities( + can_next=False, + can_previous=False, + can_repeat=False, + can_shuffle=False, + ) + ) + device.general_event_callback = None + device.av_transport_event_callback = None + device.rendering_control_event_callback = None + device.play_queue_event_callback = None + return device + + async def test_state_machine_updates_from_device_callbacks( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -79,6 +130,7 @@ async def test_state_machine_updates_from_device_callbacks( | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.GROUPING ) mock_wiim_device.volume = 60 @@ -131,6 +183,7 @@ async def test_state_machine_updates_from_device_callbacks( | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.GROUPING ) @@ -429,6 +482,87 @@ async def test_follower_routes_commands_and_reads_leader_metadata( assert state.attributes[ATTR_MEDIA_POSITION] == 90 +async def test_group_refresh_dispatcher_sends_to_followers_and_refreshes_member( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + mock_wiim_controller: MagicMock, +) -> None: + """Test leader group refresh signal makes a real follower entity refresh.""" + follower_device = _build_mock_wiim_device( + udn="uuid:follower-1234", + name="Follower WiiM Device", + ip_address="192.168.1.101", + base_device=mock_wiim_device, + ) + + wiim_component.async_create_wiim_device.side_effect = [ + mock_wiim_device, + follower_device, + ] + follower_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.101"}, + title="Follower WiiM Device", + unique_id=follower_device.udn, + ) + + await setup_integration(hass, mock_config_entry) + await setup_integration(hass, follower_config_entry) + + def group_snapshot_for(udn: str) -> WiimGroupSnapshot: + if udn == mock_wiim_device.udn: + return WiimGroupSnapshot( + role=WiimGroupRole.LEADER, + leader_udn=mock_wiim_device.udn, + member_udns=(mock_wiim_device.udn, follower_device.udn), + ) + return WiimGroupSnapshot( + role=WiimGroupRole.FOLLOWER, + leader_udn=mock_wiim_device.udn, + member_udns=(mock_wiim_device.udn, follower_device.udn), + ) + + mock_wiim_controller.get_group_snapshot.side_effect = group_snapshot_for + mock_wiim_controller.get_device.side_effect = lambda udn: ( + mock_wiim_device if udn == mock_wiim_device.udn else follower_device + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: ["media_player.follower_wiim_device"], + }, + blocking=True, + ) + + mock_wiim_controller.async_join_group.assert_awaited_once_with( + mock_wiim_device.udn, + [follower_device.udn], + ) + + mock_wiim_device.playing_status = PlayingStatus.PLAYING + mock_wiim_device.play_mode = "Spotify" + mock_wiim_device.current_media = WiimMediaMetadata( + title="Leader Signal Song", + album="Leader Signal Album", + duration=240, + position=33, + ) + + await fire_general_update(hass, mock_wiim_device) + + follower_state = hass.states.get("media_player.follower_wiim_device") + assert follower_state is not None + assert follower_state.state == MediaPlayerState.PLAYING + assert follower_state.attributes[ATTR_MEDIA_TITLE] == "Leader Signal Song" + assert follower_state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Leader Signal Album" + assert follower_state.attributes[ATTR_INPUT_SOURCE] == "Spotify" + assert follower_state.attributes[ATTR_MEDIA_POSITION] == 33 + + async def test_follower_routes_repeat_shuffle_and_source_commands_to_leader( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -717,3 +851,130 @@ async def test_browse_media_service_includes_media_sources_when_supported( "Queue", "song.mp3", ] + + +async def test_join_and_unjoin_services_use_resolved_member_udns( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + mock_wiim_controller: MagicMock, +) -> None: + """Test grouping services call the controller with resolved UDNs.""" + follower_device = _build_mock_wiim_device( + udn="uuid:follower-1234", + name="Follower WiiM Device", + ip_address="192.168.1.101", + base_device=mock_wiim_device, + ) + second_follower_device = _build_mock_wiim_device( + udn="uuid:follower-5678", + name="Second Follower WiiM Device", + ip_address="192.168.1.102", + base_device=mock_wiim_device, + ) + wiim_component.async_create_wiim_device.side_effect = [ + mock_wiim_device, + follower_device, + second_follower_device, + ] + follower_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.101"}, + title=follower_device.name, + unique_id=follower_device.udn, + ) + second_follower_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.102"}, + title=second_follower_device.name, + unique_id=second_follower_device.udn, + ) + + await setup_integration(hass, mock_config_entry) + await setup_integration(hass, follower_config_entry) + await setup_integration(hass, second_follower_config_entry) + + follower_entity_id = "media_player.follower_wiim_device" + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: [ + MEDIA_PLAYER_ENTITY_ID, + follower_entity_id, + "media_player.unknown_wiim_device", + ], + }, + blocking=True, + ) + + mock_wiim_controller.async_join_group.assert_awaited_once_with( + mock_wiim_device.udn, [follower_device.udn] + ) + mock_wiim_controller.async_join_group.reset_mock() + + leader_device = AsyncMock(spec=WiimDevice) + leader_device.udn = "uuid:leader-1234" + leader_device.name = "Leader WiiM Device" + leader_device.playing_status = PlayingStatus.STOPPED + leader_device.play_mode = "Network" + leader_device.loop_state = WiimLoopState( + repeat=WiimRepeatMode.OFF, + shuffle=False, + ) + leader_device.current_media = None + second_follower_entity_id = "media_player.second_follower_wiim_device" + mock_wiim_controller.get_group_snapshot.return_value = WiimGroupSnapshot( + role=WiimGroupRole.FOLLOWER, + leader_udn=leader_device.udn, + member_udns=(leader_device.udn, mock_wiim_device.udn), + ) + mock_wiim_controller.get_device.side_effect = lambda udn: ( + leader_device if udn == leader_device.udn else mock_wiim_device + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: [ + MEDIA_PLAYER_ENTITY_ID, + second_follower_entity_id, + ], + }, + blocking=True, + ) + + mock_wiim_controller.async_join_group.assert_awaited_once_with( + leader_device.udn, [second_follower_device.udn] + ) + mock_wiim_controller.async_join_group.reset_mock() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: [ + MEDIA_PLAYER_ENTITY_ID, + "media_player.unknown_wiim_device", + ], + }, + blocking=True, + ) + + mock_wiim_controller.async_join_group.assert_not_awaited() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + {ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_wiim_controller.async_ungroup_device.assert_awaited_once_with( + mock_wiim_device.udn + ) diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr index 31c239876803..21d04fd18ba4 100644 --- a/tests/components/withings/snapshots/test_init.ambr +++ b/tests/components/withings/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[12345] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'henk', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Body+', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': , diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index c07f001c8e3a..0a44756f8f4a 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -449,3 +449,58 @@ async def test_device_two_config_entries( await hass.async_block_till_done() assert "Platform withings does not generate unique IDs" not in caplog.text + + +async def test_old_device_removal_only_removes_own_device( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + second_polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, +) -> None: + """Removing an old device only removes the processing entry's own device. + + Two config entries can each own a device registry entry for the same shared sub-device. + When the sub-device disappears from one entry, it must remove its own device, not + another entry's device sharing the identifier. + """ + identifiers = {(DOMAIN, "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d")} + + def _device_for_entry(entry: MockConfigEntry) -> dr.DeviceEntry | None: + return next( + ( + device + for device in device_registry.devices.get_entries( + identifiers=identifiers + ) + if device.config_entry_id == entry.entry_id + ), + None, + ) + + # The first entry creates the sub-device and owns its device registry entry. + await setup_integration(hass, polling_config_entry, False) + assert _device_for_entry(polling_config_entry) is not None + + # Unload it, then set up a second entry: with the first entry unloaded it no longer + # provides the sub-device, so the second entry creates and owns its own device. + await hass.config_entries.async_unload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + second_polling_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(second_polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert _device_for_entry(polling_config_entry) is not None + assert _device_for_entry(second_polling_config_entry) is not None + + # The sub-device disappears from the (still loaded) second entry's data. + withings.get_devices.return_value = [] + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Only the second entry's own device was removed; the first entry's remains. + assert _device_for_entry(second_polling_config_entry) is None + assert _device_for_entry(polling_config_entry) is not None diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index a833087b8082..043044fa868e 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -2,8 +2,8 @@ # name: test_device_snapshot DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 51225d300839..a9b44bc1cf41 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -61,8 +61,8 @@ # name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -87,7 +87,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, @@ -155,8 +154,8 @@ # name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed].2 DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -181,7 +180,6 @@ 'model_id': None, 'name': 'WLED RGB Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '0.14.4', 'via_device_id': None, diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py index 8e076755ff10..84136524ca7c 100644 --- a/tests/components/wmspro/conftest.py +++ b/tests/components/wmspro/conftest.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable, Generator from unittest.mock import AsyncMock, patch import pytest +from wmspro.action import Action, ActionList from homeassistant.components.wmspro.const import DOMAIN from homeassistant.const import CONF_HOST @@ -82,10 +83,12 @@ async def mock_hub_configuration( request: pytest.FixtureRequest, hass: HomeAssistant ) -> AsyncGenerator[AsyncMock]: """Override WebControlPro._getConfiguration with a param fixture file.""" + hub_config = await async_load_json_object_fixture(hass, request.param, DOMAIN) with patch( "wmspro.webcontrol.WebControlPro._getConfiguration", - return_value=await async_load_json_object_fixture(hass, request.param, DOMAIN), + return_value=hub_config, ) as mock_hub_configuration: + mock_hub_configuration.configure_mock(**hub_config) yield mock_hub_configuration @@ -94,10 +97,12 @@ async def mock_hub_status( request: pytest.FixtureRequest, hass: HomeAssistant ) -> AsyncGenerator[AsyncMock]: """Override WebControlPro._getStatus with a param fixture file.""" + hub_status = await async_load_json_object_fixture(hass, request.param, DOMAIN) with patch( "wmspro.webcontrol.WebControlPro._getStatus", - return_value=await async_load_json_object_fixture(hass, request.param, DOMAIN), + return_value=hub_status, ) as mock_hub_status: + mock_hub_status.configure_mock(**hub_status) yield mock_hub_status @@ -118,9 +123,11 @@ def mock_action_call() -> Generator[Callable]: async def fake_call(self, **kwargs): self._update_params(kwargs) - with patch( - "wmspro.action.Action.__call__", - fake_call, + with patch.object( + Action, + "__call__", + side_effect=fake_call, + autospec=True, ) as mock_action_call: yield mock_action_call @@ -135,9 +142,11 @@ def mock_action_list_call() -> Generator[Callable]: dest = self._control.dests[args["destinationId"]] await dest.actions[args["actionId"]](**args["parameters"]) - with patch( - "wmspro.action.ActionList.__call__", - fake_list_call, + with patch.object( + ActionList, + "__call__", + side_effect=fake_list_call, + autospec=True, ) as mock_action_list_call: yield mock_action_list_call diff --git a/tests/components/wmspro/fixtures/config_prod_light_switch.json b/tests/components/wmspro/fixtures/config_prod_light_switch.json new file mode 100644 index 000000000000..c95e136f6573 --- /dev/null +++ b/tests/components/wmspro/fixtures/config_prod_light_switch.json @@ -0,0 +1,32 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 97358, + "animationType": 6, + "names": ["Licht", "", "", ""], + "actions": [ + { + "id": 20, + "actionType": 4, + "actionDescription": 6 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 19239, + "name": "Terrasse", + "destinations": [97358], + "scenes": [] + } + ], + "scenes": [] +} diff --git a/tests/components/wmspro/fixtures/status_prod_light_switch.json b/tests/components/wmspro/fixtures/status_prod_light_switch.json new file mode 100644 index 000000000000..766b73f766e5 --- /dev/null +++ b/tests/components/wmspro/fixtures/status_prod_light_switch.json @@ -0,0 +1,22 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 97358, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 20, + "value": { + "onOffState": false + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr index 26bf8feed48d..9f47e6213ec3 100644 --- a/tests/components/wmspro/snapshots/test_cover.ambr +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -2,8 +2,8 @@ # name: test_cover_device[config_prod_awning_dimmer.json-status_prod_awning.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_init.ambr b/tests/components/wmspro/snapshots/test_init.ambr index 34b55c0ac3e1..b62cd611dc5a 100644 --- a/tests/components/wmspro/snapshots/test_init.ambr +++ b/tests/components/wmspro/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-19239] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '19239', 'sw_version': None, 'via_device_id': , @@ -33,8 +32,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-58717] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , @@ -64,8 +62,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_awning.json][device-97358] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , @@ -95,8 +92,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-19239] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '19239', 'sw_version': None, 'via_device_id': , @@ -126,8 +122,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-58717] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Markise', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '58717', 'sw_version': None, 'via_device_id': , @@ -157,8 +152,8 @@ # name: test_device_setup[config_prod_awning_dimmer.json-status_prod_dimmer.json][device-97358] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , @@ -188,8 +182,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-116682] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -210,7 +204,6 @@ 'model_id': None, 'name': 'Wohnzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '116682', 'sw_version': None, 'via_device_id': , @@ -219,8 +212,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-172555] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -241,7 +234,6 @@ 'model_id': None, 'name': 'Badezimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '172555', 'sw_version': None, 'via_device_id': , @@ -250,8 +242,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-18894] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -272,7 +264,6 @@ 'model_id': None, 'name': 'Wohnebene alle', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '18894', 'sw_version': None, 'via_device_id': , @@ -281,8 +272,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-230952] DeviceRegistryEntrySnapshot({ 'area_id': 'wohnbereich', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -303,7 +294,6 @@ 'model_id': None, 'name': 'Sportzimmer', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '230952', 'sw_version': None, 'via_device_id': , @@ -312,8 +302,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-284942] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -334,7 +324,6 @@ 'model_id': None, 'name': 'Terrasse', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '284942', 'sw_version': None, 'via_device_id': , @@ -343,8 +332,8 @@ # name: test_device_setup[config_prod_roller_shutter.json-status_prod_roller_shutter.json][device-328518] DeviceRegistryEntrySnapshot({ 'area_id': 'alle', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -365,7 +354,6 @@ 'model_id': None, 'name': 'alle Rollläden', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '328518', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_light.ambr b/tests/components/wmspro/snapshots/test_light.ambr index 3fb4ac620efe..aa556fd9c42a 100644 --- a/tests/components/wmspro/snapshots/test_light.ambr +++ b/tests/components/wmspro/snapshots/test_light.ambr @@ -2,8 +2,8 @@ # name: test_light_device[config_prod_awning_dimmer.json-status_prod_dimmer.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Licht', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '97358', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_number.ambr b/tests/components/wmspro/snapshots/test_number.ambr index ffc1e1db4b27..bfa66e925083 100644 --- a/tests/components/wmspro/snapshots/test_number.ambr +++ b/tests/components/wmspro/snapshots/test_number.ambr @@ -3,7 +3,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by WMS WebControl pro API', - : 'Keuken alle Maximum Rotation', + : 'Keuken alle Maximum rotation', : 127, : 0, : , @@ -21,7 +21,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by WMS WebControl pro API', - : 'Keuken alle Minimum Rotation', + : 'Keuken alle Minimum rotation', : 0, : -127, : , @@ -39,7 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : 'Data provided by WMS WebControl pro API', - : 'Keuken alle Raw Rotation', + : 'Keuken alle Raw rotation', : 127, : -127, : , diff --git a/tests/components/wmspro/snapshots/test_scene.ambr b/tests/components/wmspro/snapshots/test_scene.ambr index 95dfd47a905d..87b1e4fe59e1 100644 --- a/tests/components/wmspro/snapshots/test_scene.ambr +++ b/tests/components/wmspro/snapshots/test_scene.ambr @@ -16,8 +16,8 @@ # name: test_scene_room_device[config_test.json] DeviceRegistryEntrySnapshot({ 'area_id': 'raum_0', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -38,7 +38,6 @@ 'model_id': None, 'name': 'Raum 0', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '42581', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/snapshots/test_switch.ambr b/tests/components/wmspro/snapshots/test_switch.ambr index b5fcc25c5ea3..144ec10ea724 100644 --- a/tests/components/wmspro/snapshots/test_switch.ambr +++ b/tests/components/wmspro/snapshots/test_switch.ambr @@ -2,8 +2,8 @@ # name: test_switch_device[config_prod_load_switch.json-status_prod_load_switch.json] DeviceRegistryEntrySnapshot({ 'area_id': 'terasse', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'HEIZUNG LINKS', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': '499120', 'sw_version': None, 'via_device_id': , diff --git a/tests/components/wmspro/test_button.py b/tests/components/wmspro/test_button.py index dc89d834e9d1..7a052c89d3dc 100644 --- a/tests/components/wmspro/test_button.py +++ b/tests/components/wmspro/test_button.py @@ -33,14 +33,13 @@ async def test_button_update( mock_hub_ping: AsyncMock, mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, - mock_action_call: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test that a button entity is created and updated correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("button.terrasse_markise_identify") assert entity is not None @@ -63,12 +62,16 @@ async def test_button_press( """Test that a button entity is pressed correctly.""" assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) entity = hass.states.get("button.terrasse_markise_identify") assert entity is not None before_state = entity.state @@ -83,7 +86,8 @@ async def test_button_press( entity = hass.states.get("button.terrasse_markise_identify") assert entity is not None assert entity.state != before_state - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -114,7 +118,6 @@ async def test_button_rotation_reset_press( mock_hub_ping: AsyncMock, mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, - mock_action_call: AsyncMock, freezer: FrozenDateTimeFactory, button_entity_id: str, range_entity_id: str, @@ -125,7 +128,7 @@ async def test_button_rotation_reset_press( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(range_entity_id) assert entity is not None diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py index 58f443f0898a..c47c6eef7c32 100644 --- a/tests/components/wmspro/test_cover.py +++ b/tests/components/wmspro/test_cover.py @@ -46,6 +46,7 @@ async def test_cover_device( mock_hub_ping: AsyncMock, mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, + freezer: FrozenDateTimeFactory, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -53,7 +54,7 @@ async def test_cover_device( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "58717")}) assert device_entry is not None @@ -78,47 +79,65 @@ async def test_cover_update( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("cover.terrasse_markise") assert entity is not None assert entity == snapshot + before_status = len(mock_hub_status.mock_calls) + # Move time to next update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_hub_status.mock_calls) >= 3 + assert len(mock_hub_status.mock_calls) == before_status + 1 @pytest.mark.parametrize( - ("mock_hub_configuration", "mock_hub_status", "entity_id"), + ( + "mock_hub_configuration", + "mock_hub_status", + "entity_id", + "num_action", + "num_action_list", + ), [ ( "config_prod_awning_dimmer.json", "status_prod_awning.json", "cover.terrasse_markise", + 1, + 0, ), ( "config_prod_awning_valance.json", "status_prod_valance.json", "cover.raum_0_markise_2", + 1, + 0, ), ( "config_prod_roller_shutter.json", "status_prod_roller_shutter.json", "cover.wohnbereich_wohnebene_alle", + 1, + 0, ), ( "config_prod_slat_drive.json", "status_prod_slat_drive.json", "cover.terrasse_lamellen", + 1, + 0, ), ( "config_prod_slat_rotate.json", "status_prod_slat_rotate.json", "cover.zonwering_begane_grond_keuken_alle", + 2, + 1, ), ], indirect=["mock_hub_configuration", "mock_hub_status"], @@ -132,12 +151,14 @@ async def test_cover_open_and_close( mock_action_call: AsyncMock, mock_action_list_call: AsyncMock, entity_id: str, + num_action: int, + num_action_list: int, ) -> None: """Test that a cover entity is opened and closed correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -148,7 +169,9 @@ async def test_cover_open_and_close( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -161,13 +184,20 @@ async def test_cover_open_and_close( assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 100 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action + assert ( + len(mock_action_list_call.mock_calls) + == before_action_list + num_action_list + ) with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -180,7 +210,12 @@ async def test_cover_open_and_close( assert entity is not None assert entity.state == STATE_CLOSED assert entity.attributes[ATTR_CURRENT_POSITION] == 0 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action + assert ( + len(mock_action_list_call.mock_calls) + == before_action_list + num_action_list + ) @pytest.mark.parametrize( @@ -227,7 +262,7 @@ async def test_cover_open_to_pos( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -238,7 +273,8 @@ async def test_cover_open_to_pos( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -251,7 +287,8 @@ async def test_cover_open_to_pos( assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 50 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -298,7 +335,7 @@ async def test_cover_open_and_stop( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -309,7 +346,8 @@ async def test_cover_open_and_stop( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -322,13 +360,15 @@ async def test_cover_open_and_stop( assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 80 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -341,7 +381,8 @@ async def test_cover_open_and_stop( assert entity is not None assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 80 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -362,14 +403,13 @@ async def test_cover_tilt_open_and_close( mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, mock_action_call: AsyncMock, - mock_action_list_call: AsyncMock, entity_id: str, ) -> None: """Test that a cover entity is tilted open and closed correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) await hass.async_block_till_done(wait_background_tasks=True) @@ -381,7 +421,8 @@ async def test_cover_tilt_open_and_close( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -393,13 +434,15 @@ async def test_cover_tilt_open_and_close( entity = hass.states.get(entity_id) assert entity is not None assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -411,7 +454,8 @@ async def test_cover_tilt_open_and_close( entity = hass.states.get(entity_id) assert entity is not None assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 50 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -432,14 +476,13 @@ async def test_cover_tilt_to_pos( mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, mock_action_call: AsyncMock, - mock_action_list_call: AsyncMock, entity_id: str, ) -> None: """Test that a cover entity is tilted to correct position.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) await hass.async_block_till_done(wait_background_tasks=True) @@ -451,7 +494,8 @@ async def test_cover_tilt_to_pos( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -463,16 +507,25 @@ async def test_cover_tilt_to_pos( entity = hass.states.get(entity_id) assert entity is not None assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 100 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( - ("mock_hub_configuration", "mock_hub_status", "entity_id"), + ( + "mock_hub_configuration", + "mock_hub_status", + "entity_id", + "num_action", + "num_action_list", + ), [ ( "config_prod_slat_rotate.json", "status_prod_slat_rotate.json", "cover.zonwering_begane_grond_keuken_alle", + 2, + 1, ), ], indirect=["mock_hub_configuration", "mock_hub_status"], @@ -486,12 +539,14 @@ async def test_cover_tilt_with_open_and_close_pos( mock_action_call: AsyncMock, mock_action_list_call: AsyncMock, entity_id: str, + num_action: int, + num_action_list: int, ) -> None: """Test that a cover entity is tilted to correct position.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) await hass.async_block_till_done(wait_background_tasks=True) @@ -505,7 +560,9 @@ async def test_cover_tilt_with_open_and_close_pos( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -519,13 +576,20 @@ async def test_cover_tilt_with_open_and_close_pos( assert entity.state == STATE_OPEN assert entity.attributes[ATTR_CURRENT_POSITION] == 100 assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 100 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action + assert ( + len(mock_action_list_call.mock_calls) + == before_action_list + num_action_list + ) with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) await hass.services.async_call( COVER_DOMAIN, @@ -539,4 +603,9 @@ async def test_cover_tilt_with_open_and_close_pos( assert entity.state == STATE_CLOSED assert entity.attributes[ATTR_CURRENT_POSITION] == 0 assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action + assert ( + len(mock_action_list_call.mock_calls) + == before_action_list + num_action_list + ) diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py index 9ead9e23b3d2..948dfaf3a1e9 100644 --- a/tests/components/wmspro/test_init.py +++ b/tests/components/wmspro/test_init.py @@ -56,6 +56,8 @@ async def test_config_entry_persistent_storage( ) assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_refresh.mock_calls) == 1 assert config_dir.is_dir() # created during setup assert await unload_config_entry(hass, mock_config_entry) @@ -87,12 +89,12 @@ async def test_device_setup( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) > 0 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) device_entries = device_registry.devices.get_devices_for_config_entry_id( mock_config_entry.entry_id ) - assert len(device_entries) > 1 + assert len(device_entries) > len(mock_hub_configuration.destinations) device_entries = list( filter( @@ -100,6 +102,6 @@ async def test_device_setup( device_entries, ) ) - assert len(device_entries) > 0 + assert len(device_entries) >= len(mock_hub_configuration.destinations) for device_entry in device_entries: assert device_entry == snapshot(name=f"device-{device_entry.serial_number}") diff --git a/tests/components/wmspro/test_light.py b/tests/components/wmspro/test_light.py index 0cfb9976e3db..cc91385e9664 100644 --- a/tests/components/wmspro/test_light.py +++ b/tests/components/wmspro/test_light.py @@ -42,7 +42,7 @@ async def test_light_device( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "97358")}) assert device_entry is not None @@ -67,24 +67,29 @@ async def test_light_update( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("light.terrasse_licht") assert entity is not None assert entity == snapshot + before_status = len(mock_hub_status.mock_calls) + # Move time to next update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_hub_status.mock_calls) >= 3 + assert len(mock_hub_status.mock_calls) == before_status + 2 @pytest.mark.parametrize( - ("mock_hub_configuration", "mock_hub_status"), - [("config_prod_awning_dimmer.json", "status_prod_dimmer.json")], - indirect=True, + ("mock_hub_configuration", "mock_hub_status", "target_brightness"), + [ + ("config_prod_awning_dimmer.json", "status_prod_dimmer.json", 1), + ("config_prod_light_switch.json", "status_prod_light_switch.json", None), + ], + indirect=["mock_hub_configuration", "mock_hub_status"], ) async def test_light_turn_on_and_off( hass: HomeAssistant, @@ -93,23 +98,25 @@ async def test_light_turn_on_and_off( mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, mock_action_call: AsyncMock, + target_brightness: int | None, ) -> None: """Test that a light entity is turned on and off correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("light.terrasse_licht") assert entity is not None assert entity.state == STATE_OFF - assert entity.attributes[ATTR_BRIGHTNESS] is None + assert entity.attributes.get(ATTR_BRIGHTNESS) is None with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -121,14 +128,16 @@ async def test_light_turn_on_and_off( entity = hass.states.get("light.terrasse_licht") assert entity is not None assert entity.state == STATE_ON - assert entity.attributes[ATTR_BRIGHTNESS] >= 1 - assert len(mock_hub_status.mock_calls) == before + assert entity.attributes.get(ATTR_BRIGHTNESS) == target_brightness + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -140,8 +149,9 @@ async def test_light_turn_on_and_off( entity = hass.states.get("light.terrasse_licht") assert entity is not None assert entity.state == STATE_OFF - assert entity.attributes[ATTR_BRIGHTNESS] is None - assert len(mock_hub_status.mock_calls) == before + assert entity.attributes.get(ATTR_BRIGHTNESS) is None + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 @pytest.mark.parametrize( @@ -161,7 +171,7 @@ async def test_light_dimm_on_and_off( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("light.terrasse_licht") assert entity is not None @@ -172,7 +182,8 @@ async def test_light_dimm_on_and_off( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -185,13 +196,15 @@ async def test_light_dimm_on_and_off( assert entity is not None assert entity.state == STATE_ON assert entity.attributes[ATTR_BRIGHTNESS] >= 1 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -204,13 +217,15 @@ async def test_light_dimm_on_and_off( assert entity is not None assert entity.state == STATE_ON assert entity.attributes[ATTR_BRIGHTNESS] == 128 - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( LIGHT_DOMAIN, @@ -223,4 +238,5 @@ async def test_light_dimm_on_and_off( assert entity is not None assert entity.state == STATE_OFF assert entity.attributes[ATTR_BRIGHTNESS] is None - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 diff --git a/tests/components/wmspro/test_number.py b/tests/components/wmspro/test_number.py index b0a052804529..dbd283a1e781 100644 --- a/tests/components/wmspro/test_number.py +++ b/tests/components/wmspro/test_number.py @@ -47,26 +47,28 @@ async def test_number_update( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 7 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None assert entity == snapshot + before_status = len(mock_hub_status.mock_calls) + # Move time to next update freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_hub_status.mock_calls) >= 10 + assert len(mock_hub_status.mock_calls) == before_status + 28 @pytest.mark.parametrize( - ("entity_id", "initial_value", "target_value"), + ("entity_id", "initial_value", "target_value", "num_action"), [ - ("number.zonwering_begane_grond_keuken_alle_raw_rotation", "0", "80"), - ("number.zonwering_begane_grond_keuken_alle_minimum_rotation", "-75", "-50"), - ("number.zonwering_begane_grond_keuken_alle_maximum_rotation", "75", "100"), + ("number.zonwering_begane_grond_keuken_alle_raw_rotation", "0", "80", 1), + ("number.zonwering_begane_grond_keuken_alle_minimum_rotation", "-75", "-50", 0), + ("number.zonwering_begane_grond_keuken_alle_maximum_rotation", "75", "100", 0), ], ) @pytest.mark.parametrize( @@ -85,12 +87,13 @@ async def test_number_set_value( entity_id: str, initial_value: str, target_value: str, + num_action: int, ) -> None: """Test that a number entity is created and value set correctly.""" assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 7 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -100,7 +103,8 @@ async def test_number_set_value( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( NUMBER_DOMAIN, @@ -117,7 +121,8 @@ async def test_number_set_value( entity = hass.states.get(entity_id) assert entity is not None assert float(entity.state) == float(target_value) - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + num_action @pytest.mark.parametrize( @@ -138,8 +143,6 @@ async def test_number_set_and_restore_value( mock_hub_ping: AsyncMock, mock_hub_configuration: AsyncMock, mock_hub_status: AsyncMock, - mock_action_call: AsyncMock, - freezer: FrozenDateTimeFactory, entity_id: str, initial_value: str, target_value: str, @@ -148,7 +151,7 @@ async def test_number_set_and_restore_value( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) == 7 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get(entity_id) assert entity is not None @@ -190,7 +193,7 @@ async def test_number_update_handles_zero_value( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get( "number.zonwering_begane_grond_keuken_alle_minimum_rotation" diff --git a/tests/components/wmspro/test_scene.py b/tests/components/wmspro/test_scene.py index 8d3ebffbdf3b..7a65e39f1946 100644 --- a/tests/components/wmspro/test_scene.py +++ b/tests/components/wmspro/test_scene.py @@ -34,6 +34,7 @@ async def test_scene_room_device( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_dest_refresh.mock_calls) == 2 device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "42581")}) assert device_entry is not None @@ -58,6 +59,7 @@ async def test_scene_activate( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_dest_refresh.mock_calls) == 2 entity = hass.states.get("scene.raum_0_raum_0_gute_nacht") assert entity is not None diff --git a/tests/components/wmspro/test_services.py b/tests/components/wmspro/test_services.py new file mode 100644 index 000000000000..0976a5f6d6f0 --- /dev/null +++ b/tests/components/wmspro/test_services.py @@ -0,0 +1,147 @@ +"""Test wmspro integration services.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, +) +from homeassistant.components.wmspro.const import ( + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OPEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_config_entry + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status"), + [("config_prod_slat_rotate.json", "status_prod_slat_rotate.json")], + indirect=True, +) +async def test_set_cover_position_and_tilt_service_is_registered( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, +) -> None: + """Test that set_cover_position_and_tilt service is registered.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) + + assert hass.services.has_service(DOMAIN, SERVICE_SET_COVER_POSITION_AND_TILT) + + +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_id"), + [ + ( + "config_prod_slat_rotate.json", + "status_prod_slat_rotate.json", + "cover.zonwering_begane_grond_keuken_alle", + ), + ], + indirect=["mock_hub_configuration", "mock_hub_status"], +) +async def test_set_cover_position_and_tilt_service_executes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, + mock_action_call: AsyncMock, + mock_action_list_call: AsyncMock, + entity_id: str, +) -> None: + """Test set_cover_position_and_tilt updates position and tilt in one action call.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.attributes[ATTR_CURRENT_POSITION] == 0 + assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) + before_action_list = len(mock_action_list_call.mock_calls) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, + { + ATTR_ENTITY_ID: entity.entity_id, + ATTR_POSITION: 30, + ATTR_TILT_POSITION: 80, + }, + blocking=True, + ) + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity.state == STATE_OPEN + assert entity.attributes[ATTR_CURRENT_POSITION] == 30 + assert entity.attributes[ATTR_CURRENT_TILT_POSITION] == 80 + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 2 + assert len(mock_action_list_call.mock_calls) == before_action_list + 1 + + +@pytest.mark.parametrize( + ("mock_hub_configuration", "mock_hub_status", "entity_id"), + [ + ( + "config_prod_awning_dimmer.json", + "status_prod_awning.json", + "cover.terrasse_markise", + ), + ], + indirect=["mock_hub_configuration", "mock_hub_status"], +) +async def test_set_cover_position_and_tilt_unsupported_entity_raises( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration: AsyncMock, + mock_hub_status: AsyncMock, + entity_id: str, +) -> None: + """Test set_cover_position_and_tilt raises for entities without tilt support.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration.mock_calls) == 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) + + before = len(mock_hub_status.mock_calls) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, + { + ATTR_ENTITY_ID: entity_id, + ATTR_POSITION: 30, + ATTR_TILT_POSITION: 80, + }, + blocking=True, + ) + + assert len(mock_hub_status.mock_calls) == before diff --git a/tests/components/wmspro/test_switch.py b/tests/components/wmspro/test_switch.py index 2fe0a04c1c96..818c74818910 100644 --- a/tests/components/wmspro/test_switch.py +++ b/tests/components/wmspro/test_switch.py @@ -42,7 +42,7 @@ async def test_switch_device( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "499120")}) assert device_entry is not None @@ -67,7 +67,7 @@ async def test_switch_update( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 2 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("switch.terasse_heizung_links") assert entity is not None @@ -80,7 +80,7 @@ async def test_switch_update( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_hub_status.mock_calls) > before + assert len(mock_hub_status.mock_calls) == before + 12 @pytest.mark.parametrize( @@ -100,7 +100,7 @@ async def test_switch_turn_on_and_off( assert await setup_config_entry(hass, mock_config_entry) assert len(mock_hub_ping.mock_calls) == 1 assert len(mock_hub_configuration.mock_calls) == 1 - assert len(mock_hub_status.mock_calls) >= 1 + assert len(mock_hub_status.mock_calls) == len(mock_hub_configuration.destinations) entity = hass.states.get("switch.terasse_heizung_links") assert entity is not None @@ -110,7 +110,8 @@ async def test_switch_turn_on_and_off( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( SWITCH_DOMAIN, @@ -122,13 +123,15 @@ async def test_switch_turn_on_and_off( entity = hass.states.get("switch.terasse_heizung_links") assert entity is not None assert entity.state == STATE_ON - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 with patch( "wmspro.destination.Destination.refresh", return_value=True, ): - before = len(mock_hub_status.mock_calls) + before_status = len(mock_hub_status.mock_calls) + before_action = len(mock_action_call.mock_calls) await hass.services.async_call( SWITCH_DOMAIN, @@ -140,4 +143,5 @@ async def test_switch_turn_on_and_off( entity = hass.states.get("switch.terasse_heizung_links") assert entity is not None assert entity.state == STATE_OFF - assert len(mock_hub_status.mock_calls) == before + assert len(mock_hub_status.mock_calls) == before_status + assert len(mock_action_call.mock_calls) == before_action + 1 diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index 88ce626f4aa2..994672355869 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -2,8 +2,8 @@ # name: test_device_entry DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://www.wolf-smartset.com/', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'test-device', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py index 445411eb6fc7..7b10a4e8989b 100644 --- a/tests/components/wolflink/test_init.py +++ b/tests/components/wolflink/test_init.py @@ -11,7 +11,7 @@ from wolf_comm.token_auth import InvalidAuth from wolf_comm.wolf_client import FetchFailed, ParameterReadError from homeassistant.components.wolflink.const import DOMAIN, MANUFACTURER -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -233,8 +233,9 @@ async def test_migration_merges_duplicate_v1_entries( wolf_mock.return_value.fetch_system_list.side_effect = RequestError( "Unable to connect" ) + # Setting up the first entry loads the integration, which sets up and migrates + # every wolflink entry: the first becomes the hub and the second merges into it. await hass.config_entries.async_setup(first_entry.entry_id) - await second_entry.async_migrate(hass) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) @@ -251,6 +252,58 @@ async def test_migration_merges_duplicate_v1_entries( assert device.config_entries == {surviving.entry_id} +async def test_migration_merge_into_disabled_hub( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an enabled device merged onto a disabled hub entry gets disabled.""" + hub_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + version=2, + minor_version=2, + disabled_by=ConfigEntryDisabler.USER, + ) + hub_entry.add_to_hass(hass) + legacy_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="5678", + data={**LEGACY_CONFIG, "device_id": 5678}, + version=1, + minor_version=2, + ) + legacy_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=legacy_entry.entry_id, + identifiers={(DOMAIN, "5678")}, + manufacturer=MANUFACTURER, + name="test-device", + ) + + with patch( + "homeassistant.components.wolflink.WolfClient", + autospec=True, + ) as wolf_mock: + wolf_mock.return_value.fetch_system_list.side_effect = RequestError( + "Unable to connect" + ) + await hass.config_entries.async_setup(legacy_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].entry_id == hub_entry.entry_id + + # The device was reattached to the disabled hub entry, and its disabled + # state now reflects the new owning entry's disabled state. + migrated_device = device_registry.async_get(device.id) + assert migrated_device is not None + assert migrated_device.config_entries == {hub_entry.entry_id} + assert migrated_device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + + async def test_migration_v1_list_device_id(hass: HomeAssistant) -> None: """Test v1 migration tolerates device_id stored as a list from partial migrations.""" config_entry = MockConfigEntry( diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index d621471916b9..3bb84a3ce383 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -455,6 +455,97 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: await hass.async_block_till_done() +async def test_satellite_pipeline_error_no_restart(hass: HomeAssistant) -> None: + """Test that a pipeline error does not auto-restart an "always on" satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, + end_stage=PipelineStage.TTS, + restart_on_end=True, + ).event(), + ] + + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_event_callback + pipeline_event_callback = event_callback + run_pipeline_called.set() + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), + ): + entry = await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # Reset so we can detect an (unwanted) restart below + run_pipeline_called.clear() + + # Pipeline fails, then ends + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.ERROR, + {"code": "test-code", "message": "test-message"}, + ) + ) + pipeline_event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + + # A ping/pong round trip guarantees the loop has processed the run end + mock_client.inject_event(Ping("test-ping").event()) + async with asyncio.timeout(1): + await mock_client.pong_event.wait() + + # Pipeline must not have automatically restarted after the error + assert not run_pipeline_called.is_set() + + # A new run request from the satellite clears the error and runs again + mock_client.inject_event( + RunPipeline( + start_stage=PipelineStage.WAKE, + end_stage=PipelineStage.TTS, + restart_on_end=True, + ).event() + ) + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_satellite_muted(hass: HomeAssistant) -> None: """Test callback for a satellite that has been muted.""" on_muted_event = asyncio.Event() diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 43820f8e933d..50cfb5f8b2fb 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -9,7 +9,6 @@ from miio import DeviceException import pytest from homeassistant.components.vacuum import ( - ATTR_BATTERY_ICON, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, DOMAIN as VACUUM_DOMAIN, @@ -263,9 +262,8 @@ async def test_xiaomi_vacuum_services( state = hass.states.get(entity_id) assert state.state == VacuumActivity.ERROR - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14140 assert state.attributes.get(ATTR_ERROR) == "Error message" - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-80" assert state.attributes.get(ATTR_TIMERS) == [ { "enabled": True, @@ -449,9 +447,8 @@ async def test_xiaomi_specific_services( # Check state attributes state = hass.states.get(entity_id) assert state.state == VacuumActivity.CLEANING - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14140 assert state.attributes.get(ATTR_ERROR) is None - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-30" assert state.attributes.get(ATTR_TIMERS) == [ { "enabled": True, diff --git a/tests/components/xthings_cloud/snapshots/test_init.ambr b/tests/components/xthings_cloud/snapshots/test_init.ambr index d0c552b66cd4..c1e7dda19e1f 100644 --- a/tests/components/xthings_cloud/snapshots/test_init.ambr +++ b/tests/components/xthings_cloud/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_devices[XT-LT050] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'Porch Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -33,8 +32,8 @@ # name: test_devices[XT-LT100] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': None, 'name': 'Hallway Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -64,8 +62,8 @@ # name: test_devices[XT-LT200] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -86,7 +84,6 @@ 'model_id': None, 'name': 'Bedroom Light', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '2.0.1', 'via_device_id': None, @@ -95,8 +92,8 @@ # name: test_devices[XT-PL50] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -117,7 +114,6 @@ 'model_id': None, 'name': 'Smart Plug 50', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -126,8 +122,8 @@ # name: test_devices[XT-PL100] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -148,7 +144,6 @@ 'model_id': None, 'name': 'Smart Plug 100', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, @@ -157,8 +152,8 @@ # name: test_devices[XT-LK50] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -179,7 +174,6 @@ 'model_id': None, 'name': 'Front Door Lock', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '1.0.0', 'via_device_id': None, diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr index 226d0bdbba91..5d013ac7f213 100644 --- a/tests/components/yale/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -2,8 +2,8 @@ # name: test_doorbell_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': None, 'name': 'tmt100 Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': '3.1.0-HYDRC75+201909251139', 'via_device_id': None, diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr index 3f89fe085253..f0c282c5eb1e 100644 --- a/tests/components/yale/snapshots/test_lock.ambr +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -2,8 +2,8 @@ # name: test_lock_device_registry DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'online_with_doorsense Name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'undefined-4.3.0-1.8.14', 'via_device_id': None, diff --git a/tests/components/yardian/snapshots/test_button.ambr b/tests/components/yardian/snapshots/test_button.ambr new file mode 100644 index 000000000000..b26b7cd70b6c --- /dev/null +++ b/tests/components/yardian/snapshots/test_button.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[button.yardian_smart_sprinkler_stop_irrigation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.yardian_smart_sprinkler_stop_irrigation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Stop irrigation', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stop irrigation', + 'platform': 'yardian', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop_irrigation', + 'unique_id': 'yid123_stop_all', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.yardian_smart_sprinkler_stop_irrigation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Yardian Smart Sprinkler Stop irrigation', + }), + 'context': , + 'entity_id': 'button.yardian_smart_sprinkler_stop_irrigation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/yardian/test_button.py b/tests/components/yardian/test_button.py new file mode 100644 index 000000000000..6134624de6d0 --- /dev/null +++ b/tests/components/yardian/test_button.py @@ -0,0 +1,46 @@ +"""Validate Yardian button behavior.""" + +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 setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@patch("homeassistant.components.yardian.PLATFORMS", [Platform.BUTTON]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all button entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@patch("homeassistant.components.yardian.button.BUTTON_REFRESH_DELAY", 0) +async def test_stop_all_button( + hass: HomeAssistant, + mock_yardian_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing the stop irrigation button.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.yardian_smart_sprinkler_stop_irrigation"}, + blocking=True, + ) + mock_yardian_client.stop_irrigation.assert_called_once() diff --git a/tests/components/yardian/test_switch.py b/tests/components/yardian/test_switch.py index 07e826f6c10c..f71d1b0d246a 100644 --- a/tests/components/yardian/test_switch.py +++ b/tests/components/yardian/test_switch.py @@ -68,4 +68,4 @@ async def test_turn_off_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - mock_yardian_client.stop_irrigation.assert_called_once() + mock_yardian_client.stop_zone.assert_called_once() diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 0839bbfdcb9a..6afe6d063931 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -1,10 +1,11 @@ """ZHA device automation trigger tests.""" from collections.abc import Callable, Coroutine -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest -from zha.application.const import ATTR_ENDPOINT_ID +from zha.application.const import ATTR_ENDPOINT_ID, RadioType +from zha.quirks import DEVICE_REGISTRY from zigpy.application import ControllerApplication from zigpy.device import Device as ZigpyDevice import zigpy.profiles.zha @@ -558,3 +559,28 @@ async def test_validate_trigger_config_unloaded_bad_info( ) assert "Unable to find trigger" in caplog.text + + +async def test_device_trigger_cache_built_with_quirk_resolver( + zigpy_app_controller: ControllerApplication, + setup_zha: Callable[..., Coroutine[None]], +) -> None: + """Test the early device trigger cache is built with quirk resolution. + + Regression test: without quirk resolution, quirk-defined triggers (e.g. + remote button presses) are missing whenever the cache is used as a + fallback (i.e. before ZHA has finished loading). + """ + with patch.object( + RadioType.ezsp.controller, + "new", + AsyncMock(return_value=zigpy_app_controller), + ) as mock_new: + await setup_zha() + + # Both the trigger cache app and the gateway app must quirk-resolve devices + assert len(mock_new.await_args_list) == 2 + assert all( + call.kwargs.get("device_resolver") == DEVICE_REGISTRY.resolve + for call in mock_new.await_args_list + ) diff --git a/tests/components/zinvolt/snapshots/test_init.ambr b/tests/components/zinvolt/snapshots/test_init.ambr index 657cb27c219b..5e1c9b893105 100644 --- a/tests/components/zinvolt/snapshots/test_init.ambr +++ b/tests/components/zinvolt/snapshots/test_init.ambr @@ -2,8 +2,8 @@ # name: test_device[BAT002] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -24,7 +24,6 @@ 'model_id': 'ZVS4000', 'name': 'Battery - 2', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'BAT002', 'sw_version': 'V1.20', 'via_device_id': , @@ -33,8 +32,8 @@ # name: test_device[ZVG011025120088] DeviceRegistryEntrySnapshot({ 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': None, 'connections': set({ }), @@ -55,7 +54,6 @@ 'model_id': 'ZVS4000', 'name': 'Zinvolt Batterij', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': 'ZVG011025120088', 'sw_version': 'V1.20', 'via_device_id': None, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 85b0e2c54192..8088fb13ec36 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1187,6 +1187,23 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert "keep_old_devices" in entry.data +@pytest.mark.usefixtures("supervisor", "addon_info") +async def test_esphome_discovery_title_placeholders(hass: HomeAssistant) -> None: + """Test ESPHome discovery sets the name placeholder for the flow_title.""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ESPHOME}, + data=ESPHOME_DISCOVERY_INFO, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert ( + flows[0]["context"]["title_placeholders"]["name"] + == "Network 0x000004d2 via mock-name (ESPHome)" + ) + + @pytest.mark.parametrize( "service_info", [ESPHOME_DISCOVERY_INFO, ESPHOME_DISCOVERY_INFO_CLEAN] ) @@ -4087,9 +4104,10 @@ async def test_zeroconf(hass: HomeAssistant) -> None: flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 flow = flows[0] - assert flow["context"]["title_placeholders"]["host"] == "127.0.0.1" - assert flow["context"]["title_placeholders"]["port"] == "3000" - assert flow["context"]["title_placeholders"]["home_id"] == "0x000004d2" # 1234 + assert ( + flow["context"]["title_placeholders"]["name"] + == "Network 0x000004d2 at 127.0.0.1:3000" + ) with ( patch( diff --git a/tests/components/zwave_js/test_credential_services.py b/tests/components/zwave_js/test_credential_services.py index 2b9903d19e92..61742b66403e 100644 --- a/tests/components/zwave_js/test_credential_services.py +++ b/tests/components/zwave_js/test_credential_services.py @@ -13,7 +13,12 @@ from zwave_js_server.const.command_class.access_control import ( UserCredentialUserType, ) from zwave_js_server.exceptions import FailedZWaveCommand -from zwave_js_server.model.access_control import AccessControlAPI, SetUserOptions +from zwave_js_server.model.access_control import ( + AccessControlAPI, + AddUserCredential, + AddUserResult, + SetUserOptions, +) from zwave_js_server.model.node import Node from homeassistant.components.zwave_js.const import DOMAIN @@ -26,7 +31,9 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry -def _mock_access_control(node: Node) -> MagicMock: +def _mock_access_control( + node: Node, *, supports_users_without_credentials: bool = True +) -> MagicMock: """Inject a mock AccessControlAPI into the node's endpoint 0.""" api = create_autospec(AccessControlAPI, instance=True) api.is_supported.return_value = True @@ -36,6 +43,7 @@ def _mock_access_control(node: Node) -> MagicMock: user_caps.supported_user_types = [UserCredentialUserType.GENERAL] user_caps.max_user_name_length = 20 user_caps.supported_credential_rules = [] + user_caps.supports_users_without_credentials = supports_users_without_credentials api.get_user_capabilities_cached.return_value = user_caps pin_cap = MagicMock() @@ -62,6 +70,9 @@ def _mock_access_control(node: Node) -> MagicMock: api.get_users_cached.return_value = [] api.get_user_cached.return_value = None api.set_user.return_value = SetUserResult.OK + api.add_user.return_value = AddUserResult( + user=SetUserResult.OK, credential=SetCredentialResult.OK + ) api.delete_user.return_value = SetUserResult.OK api.delete_all_users.return_value = SetUserResult.OK @@ -101,7 +112,7 @@ def _lock_entity_id( raise AssertionError(f"No lock entity found for device {device_id}") -async def test_set_user_auto_find( +async def test_set_user_new_user_auto_find( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, @@ -109,8 +120,9 @@ async def test_set_user_auto_find( lock_schlage_be469: Node, integration: MockConfigEntry, ) -> None: - """Test set_user with auto-find user slot returns allocated user_id.""" + """Without a user_id, set_user creates a new user via addUser.""" api = _mock_access_control(lock_schlage_be469) + api.add_user.return_value = AddUserResult(user=SetUserResult.OK) entity_id = _lock_entity_id( entity_registry, device_registry, client, lock_schlage_be469 ) @@ -128,7 +140,9 @@ async def test_set_user_auto_find( return_response=True, ) - api.set_user.assert_called_once_with( + # No user_id => addUser, auto-allocating the first free user slot. + api.set_user.assert_not_called() + api.add_user.assert_called_once_with( 1, SetUserOptions( active=True, @@ -136,11 +150,12 @@ async def test_set_user_auto_find( user_name="Alice", credential_rule=None, ), + None, ) - assert result == {entity_id: {"user_id": 1}} + assert result == {entity_id: {"user_id": 1, "credential_slot": None}} -async def test_set_user_explicit_index( +async def test_set_user_existing_user( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, @@ -148,7 +163,7 @@ async def test_set_user_explicit_index( lock_schlage_be469: Node, integration: MockConfigEntry, ) -> None: - """Test set_user with explicit user index echoes it back.""" + """With a user_id, set_user updates the existing user via setUser.""" api = _mock_access_control(lock_schlage_be469) entity_id = _lock_entity_id( entity_registry, device_registry, client, lock_schlage_be469 @@ -166,6 +181,8 @@ async def test_set_user_explicit_index( return_response=True, ) + # A user_id => setUser, not the addUser create path. + api.add_user.assert_not_called() api.set_user.assert_called_once_with( 5, SetUserOptions( @@ -175,7 +192,7 @@ async def test_set_user_explicit_index( credential_rule=None, ), ) - assert result == {entity_id: {"user_id": 5}} + assert result == {entity_id: {"user_id": 5, "credential_slot": None}} async def test_set_user_no_slots( @@ -212,11 +229,377 @@ async def test_set_user_no_slots( ) assert exc.value.translation_key == "no_available_user_slots" - # Here, we fail trying to find a free user slot, meaning - # before we even call set_user. + # We fail while searching for a free user slot, before issuing addUser. + api.add_user.assert_not_called() api.set_user.assert_not_called() +async def test_set_user_new_user_with_credential( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """A new user and its credential are written in one addUser call.""" + api = _mock_access_control(lock_schlage_be469) + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + result = await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: entity_id, + "user_name": "Alice", + "user_type": "general", + "active": True, + "credential_type": "pin_code", + "credential_data": "1234", + }, + blocking=True, + return_response=True, + ) + + # The combined addUser API is used, not the two-call setUser/setCredential path + api.set_user.assert_not_called() + api.set_credential.assert_not_called() + api.add_user.assert_called_once_with( + 1, + SetUserOptions( + active=True, + user_type=UserCredentialUserType.GENERAL, + user_name="Alice", + credential_rule=None, + ), + AddUserCredential( + credential_type=UserCredentialType.PIN_CODE, + credential_slot=1, + data="1234", + ), + ) + assert result == {entity_id: {"user_id": 1, "credential_slot": 1}} + + +async def test_set_user_rolls_back_on_credential_failure( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """If the credential write fails, the just-created user is rolled back.""" + api = _mock_access_control(lock_schlage_be469) + # User created OK, but the credential was rejected as a duplicate. + api.add_user.return_value = AddUserResult( + user=SetUserResult.OK, + credential=SetCredentialResult.ERROR_DUPLICATE_CREDENTIAL, + ) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ), + "user_name": "Alice", + "credential_type": "pin_code", + "credential_data": "1234", + }, + blocking=True, + return_response=True, + ) + + # The credential error is surfaced, and the auto-allocated user (slot 1) is + # deleted so the lock is not left with a credential-less user. + assert exc.value.translation_key == "credential_rejected_duplicate" + api.delete_user.assert_called_once_with(1) + + +async def test_set_user_rollback_failure_still_raises_credential_error( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """A failed rollback is swallowed; the credential error is still surfaced.""" + api = _mock_access_control(lock_schlage_be469) + api.add_user.return_value = AddUserResult( + user=SetUserResult.OK, + credential=SetCredentialResult.ERROR_DUPLICATE_CREDENTIAL, + ) + api.delete_user.side_effect = FailedZWaveCommand("boom", 1, "boom") + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ), + "user_name": "Alice", + "credential_type": "pin_code", + "credential_data": "1234", + }, + blocking=True, + return_response=True, + ) + + assert exc.value.translation_key == "credential_rejected_duplicate" + api.delete_user.assert_called_once_with(1) + + +@pytest.mark.parametrize( + ("supports_users_without_credentials", "expected_credential_slot"), + [ + # User Credential CC: the credential gets its own slot, auto-allocated + # to the first free one (1) independently of the user's slot (3). + pytest.param(True, 1, id="user_credential_cc_independent_slot"), + # User Code CC: the credential shares the user's slot, so it must match + # the auto-allocated user ID (3). + pytest.param(False, 3, id="user_code_cc_shared_slot"), + ], +) +async def test_set_user_new_user_credential_slot_allocation( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + supports_users_without_credentials: bool, + expected_credential_slot: int, +) -> None: + """A new user's credential slot resolution differs by command class.""" + api = _mock_access_control( + lock_schlage_be469, + supports_users_without_credentials=supports_users_without_credentials, + ) + # Occupy user slots 1 and 2 so the new user gets slot 3, while all credential + # slots stay free (first free is 1). This makes the user's slot and the first + # free credential slot diverge so the two command classes pick different ones. + user1 = MagicMock() + user1.user_id = 1 + user2 = MagicMock() + user2.user_id = 2 + api.get_users_cached.return_value = [user1, user2] + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + result = await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: entity_id, + "user_name": "Bob", + "credential_type": "pin_code", + "credential_data": "5678", + }, + blocking=True, + return_response=True, + ) + + api.set_user.assert_not_called() + api.set_credential.assert_not_called() + api.add_user.assert_called_once_with( + 3, + SetUserOptions( + active=None, + user_type=None, + user_name="Bob", + credential_rule=None, + ), + AddUserCredential( + credential_type=UserCredentialType.PIN_CODE, + credential_slot=expected_credential_slot, + data="5678", + ), + ) + assert result == { + entity_id: {"user_id": 3, "credential_slot": expected_credential_slot} + } + + +@pytest.mark.parametrize( + ("supports_users_without_credentials", "expected_credential_slot"), + [ + # User Credential CC: the credential is auto-allocated its own free slot. + pytest.param(True, 1, id="user_credential_cc_independent_slot"), + # User Code CC: the credential shares the existing user's slot (5). + pytest.param(False, 5, id="user_code_cc_shared_slot"), + ], +) +async def test_set_user_existing_user_with_credential( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + supports_users_without_credentials: bool, + expected_credential_slot: int, +) -> None: + """Updating an existing user with a credential uses setUser + setCredential.""" + api = _mock_access_control( + lock_schlage_be469, + supports_users_without_credentials=supports_users_without_credentials, + ) + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + result = await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: entity_id, + "user_id": 5, + "user_name": "Bob", + "credential_type": "pin_code", + "credential_data": "5678", + }, + blocking=True, + return_response=True, + ) + + # A user_id => setUser, then the credential is written separately. + api.add_user.assert_not_called() + api.set_user.assert_called_once_with( + 5, + SetUserOptions( + active=None, + user_type=None, + user_name="Bob", + credential_rule=None, + ), + ) + api.set_credential.assert_called_once_with( + 5, + UserCredentialType.PIN_CODE, + expected_credential_slot, + "5678", + ) + assert result == { + entity_id: {"user_id": 5, "credential_slot": expected_credential_slot} + } + + +async def test_set_user_existing_user_explicit_credential_slot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """An explicit credential_slot is honored on the existing-user path.""" + api = _mock_access_control(lock_schlage_be469) + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + result = await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: entity_id, + "user_id": 5, + "credential_type": "pin_code", + "credential_data": "5678", + "credential_slot": 7, + }, + blocking=True, + return_response=True, + ) + + api.add_user.assert_not_called() + api.set_user.assert_called_once_with( + 5, + SetUserOptions( + active=None, + user_type=None, + user_name=None, + credential_rule=None, + ), + ) + api.set_credential.assert_called_once_with( + 5, UserCredentialType.PIN_CODE, 7, "5678" + ) + assert result == {entity_id: {"user_id": 5, "credential_slot": 7}} + + +async def test_set_user_invalid_pin( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """set_user validates the credential before creating the user.""" + api = _mock_access_control(lock_schlage_be469) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ), + "credential_type": "pin_code", + "credential_data": "abcd", + }, + blocking=True, + return_response=True, + ) + + assert exc.value.translation_key == "credential_data_pin_not_digits" + api.add_user.assert_not_called() + + +async def test_set_user_requires_credential_on_user_code_cc( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, +) -> None: + """User Code CC locks reject creating a user without a credential.""" + api = _mock_access_control( + lock_schlage_be469, supports_users_without_credentials=False + ) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + "set_user", + { + ATTR_ENTITY_ID: _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ), + "user_name": "Alice", + }, + blocking=True, + return_response=True, + ) + + # Users and codes share a slot here, so a user cannot be created without a + # credential; the helper rejects it before reaching the device. + assert exc.value.translation_key == "credential_required" + api.add_user.assert_not_called() + + async def test_delete_user( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -1354,3 +1737,60 @@ async def test_delete_all_credentials_single_failure_unwrapped( ) assert exc.value.translation_key == "credential_rejected_unknown" + + +@pytest.mark.parametrize( + ("service", "service_data", "returns_response"), + [ + pytest.param("set_user", {}, True, id="set_user"), + pytest.param("delete_user", {"user_id": 1}, False, id="delete_user"), + pytest.param("delete_all_users", {}, False, id="delete_all_users"), + pytest.param("get_users", {}, True, id="get_users"), + pytest.param( + "set_credential", + {"user_id": 1, "credential_type": "pin_code", "credential_data": "1234"}, + True, + id="set_credential", + ), + pytest.param( + "delete_credential", + {"user_id": 1, "credential_type": "pin_code", "credential_slot": 1}, + False, + id="delete_credential", + ), + pytest.param( + "delete_all_credentials", {"user_id": 1}, False, id="delete_all_credentials" + ), + ], +) +async def test_service_access_control_not_supported( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + client: MagicMock, + lock_schlage_be469: Node, + integration: MockConfigEntry, + service: str, + service_data: dict[str, int | str], + returns_response: bool, +) -> None: + """Every user/credential service fails fast when access control is unsupported.""" + api = _mock_access_control(lock_schlage_be469) + api.is_supported.return_value = False + entity_id = _lock_entity_id( + entity_registry, device_registry, client, lock_schlage_be469 + ) + + with pytest.raises(HomeAssistantError) as exc: + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + return_response=returns_response, + ) + + assert exc.value.translation_key == "access_control_not_supported" + # The guard runs before anything else, so no capability query is issued. + api.is_supported.assert_called_once_with() + api.get_user_capabilities_cached.assert_not_called() diff --git a/tests/conftest.py b/tests/conftest.py index eaea4d3c8d17..92a94c4f04de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ import pytest_socket import requests_mock import respx from syrupy.assertion import SnapshotAssertion -from syrupy.session import SnapshotSession # Setup patching of JSON functions before any other Home Assistant imports from . import patch_json # isort:skip @@ -108,7 +107,7 @@ from homeassistant.util.async_ import create_eager_task, get_scheduled_timer_han from homeassistant.util.json import json_loads from .ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS -from .syrupy import HomeAssistantSnapshotExtension, override_syrupy_finish +from .syrupy import HomeAssistantSnapshotExtension from .typing import ( ClientSessionGenerator, MockHAClientWebSocket, @@ -173,11 +172,6 @@ def pytest_configure(config: pytest.Config) -> None: if config.getoption("verbose") > 0: logging.getLogger().setLevel(logging.DEBUG) - # Override default finish to detect unused snapshots despite xdist - # Temporary workaround until it is finalised inside syrupy - # See https://github.com/syrupy-project/syrupy/pull/901 - SnapshotSession.finish = override_syrupy_finish - class HASocketBlockedError(pytest_socket.SocketBlockedError): """SocketBlockedError variant which counts instances.""" @@ -2069,7 +2063,7 @@ async def hassio_stubs( ) -> None: """Create mock hassio http client.""" with patch( - "homeassistant.components.hassio.issues.SupervisorIssues.setup", + "homeassistant.components.hassio.coordinator.SupervisorIssuesCoordinator.async_refresh", ): await async_setup_component(hass, "hassio", {}) @@ -2260,8 +2254,11 @@ DhcpServiceInfo.__init__ = _dhcp_service_info_init def disable_http_server() -> Generator[None]: """Disable automatic start of HTTP server during tests. - This prevents the HTTP server from starting in tests that setup - integrations which depend on the HTTP component. + This prevents the HTTP server from binding sockets and starting in tests + that setup integrations which depend on the HTTP component. """ - with patch("homeassistant.components.http.start_http_server_and_save_config"): + with ( + patch("homeassistant.components.http.HomeAssistantHTTP.async_bind"), + patch("homeassistant.components.http.HomeAssistantHTTP.start"), + ): yield diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts new file mode 100644 index 000000000000..d6431b3d1ae6 --- /dev/null +++ b/tests/e2e/onboarding.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from "@playwright/test"; + +test("fresh instance redirects to onboarding and renders the UI", async ({ + page, +}) => { + await page.goto("/"); + + await expect(page).toHaveURL(/\/onboarding\.html/); + await expect(page.locator("ha-onboarding")).toBeVisible(); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 000000000000..bb69b43f05bd --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "home-assistant-e2e-tests", + "version": "1.0.0", + "description": "End-to-end browser tests for Home Assistant Core", + "private": true, + "packageManager": "pnpm@11.13.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.61.1" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 000000000000..f130b643c4c0 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = process.env.BASE_URL ?? "http://localhost:8123"; + +export default defineConfig({ + testDir: ".", + timeout: 30_000, + // Reruns a failed test once in CI to absorb transient startup flakiness. + retries: process.env.CI ? 1 : 0, + reporter: [["list"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml new file mode 100644 index 000000000000..51cd78654eff --- /dev/null +++ b/tests/e2e/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 + +packages: + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/tests/helpers/snapshots/test_entity_platform.ambr b/tests/helpers/snapshots/test_entity_platform.ambr index 2da81a956021..d35a0affa7f0 100644 --- a/tests/helpers/snapshots/test_entity_platform.ambr +++ b/tests/helpers/snapshots/test_entity_platform.ambr @@ -2,8 +2,8 @@ # name: test_device_info_called DeviceRegistryEntrySnapshot({ 'area_id': 'heliport', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.100/config', 'connections': set({ tuple( @@ -28,7 +28,6 @@ 'model_id': None, 'name': 'test-name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'test-sw', 'via_device_id': , @@ -37,8 +36,8 @@ # name: test_device_info_called.1 DeviceRegistryEntrySnapshot({ 'area_id': 'heliport', - 'config_entries': , - 'config_entries_subentries': , + 'config_entry_id': , + 'config_subentry_id': , 'configuration_url': 'http://192.168.0.100/config', 'connections': set({ tuple( @@ -63,7 +62,6 @@ 'model_id': None, 'name': 'test-name', 'name_by_user': None, - 'primary_config_entry': , 'serial_number': None, 'sw_version': 'test-sw', 'via_device_id': , diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index 896c1ea0c1f5..6d1c1a31363f 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -906,6 +906,13 @@ async def test_parse_result(hass: HomeAssistant) -> None: ("-1.0", -1.0), ("+1", 1), ("5.", 5.0), + ("-0", 0), + ("-0.0", -0.0), + ("+", "+"), + ("-", "-"), + (".", "."), + # Exceeds the int digit limit for both int() and literal_eval + ("9" * 5000, "9" * 5000), ("123_123_123", "123_123_123"), # ("+48100200300", "+48100200300"), # phone number ("010", "010"), diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 8dadec50063c..449864e0bf60 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2134,10 +2134,43 @@ async def test_extract_entities(hass: HomeAssistant) -> None: "entity_id": ["sensor.temperature_9", "sensor.temperature_10"], "below": 110, }, + { + "condition": "zone", + "options": { + "entity_id": [ + "device_tracker.paulus", + "device_tracker.anne_therese", + ], + "zone": ["zone.home"], + }, + }, + { + "condition": "zone.in_zone", + "target": {"entity_id": "person.paulus"}, + "options": {"zone": "zone.work", "behavior": "any"}, + }, + { + "condition": "zone.occupancy_is_detected", + "options": {"zone": "zone.school"}, + }, + { + "condition": "time", + "after": "input_datetime.start", + "before": "sensor.end", + }, + { + "condition": "time", + "after": "08:00:00", + }, Template("{{ is_state('light.example', 'on') }}", hass), ], } ) == { + "device_tracker.anne_therese", + "device_tracker.paulus", + "input_datetime.start", + "person.paulus", + "sensor.end", "sensor.temperature", "sensor.temperature_2", "sensor.temperature_3", @@ -2148,6 +2181,29 @@ async def test_extract_entities(hass: HomeAssistant) -> None: "sensor.temperature_8", "sensor.temperature_9", "sensor.temperature_10", + "zone.home", + "zone.school", + "zone.work", + } + + +async def test_extract_entities_zone_condition_validated(hass: HomeAssistant) -> None: + """Test extracting entities from a validated legacy zone condition. + + Validation moves the top level entity_id and zone fields into options. + """ + assert await async_setup_component(hass, "zone", {}) + config = await condition.async_validate_condition_config( + hass, + { + "condition": "zone", + "entity_id": "device_tracker.paulus", + "zone": "zone.home", + }, + ) + assert condition.async_extract_entities(config) == { + "device_tracker.paulus", + "zone.home", } diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 262e700c29ed..5459020276c9 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -1,5 +1,7 @@ """Tests for the Device Utils.""" +from unittest.mock import patch + import pytest import voluptuous as vol @@ -103,7 +105,13 @@ async def test_device_info_to_link( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test for returning device info with device link information.""" + """The link helpers are deprecated and always return None. + + A device_info carrying another device's identifiers implicitly added the caller's + config entry to that device, which a single-config-entry device can't represent - it + would silently fork a duplicate instead. Entities still attach to another config + entry's device by setting entity.device_entry. + """ config_entry = MockConfigEntry(domain="my") config_entry.add_to_hass(hass) @@ -112,7 +120,6 @@ async def test_device_info_to_link( connections={("mac", "30:31:32:33:34:00")}, config_entry_id=config_entry.entry_id, ) - assert device is not None # Source entity registry source_entity = entity_registry.async_get_or_create( @@ -125,33 +132,30 @@ async def test_device_info_to_link( await hass.async_block_till_done() assert entity_registry.async_get("sensor.test_source") is not None - result = async_device_info_to_link_from_entity( - hass, entity_id_or_uuid=source_entity.entity_id - ) - assert result == { - "identifiers": {("test", "my_device")}, - "connections": {("mac", "30:31:32:33:34:00")}, - } - - result = async_device_info_to_link_from_device_id(hass, device_id=device.id) - assert result == { - "identifiers": {("test", "my_device")}, - "connections": {("mac", "30:31:32:33:34:00")}, - } + # No link device_info is returned, even for an existing entity and device + with patch("homeassistant.helpers.device.report_usage") as report_usage: + assert ( + async_device_info_to_link_from_entity( + hass, entity_id_or_uuid=source_entity.entity_id + ) + is None + ) + assert ( + async_device_info_to_link_from_device_id(hass, device_id=device.id) is None + ) + assert report_usage.call_count == 2 # With a non-existent entity id - result = async_device_info_to_link_from_entity( - hass, entity_id_or_uuid="sensor.invalid" + assert ( + async_device_info_to_link_from_entity(hass, entity_id_or_uuid="sensor.invalid") + is None ) - assert result is None # With a non-existent device id - result = async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") - assert result is None + assert async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") is None # With a None device id - result = async_device_info_to_link_from_device_id(hass, device_id=None) - assert result is None + assert async_device_info_to_link_from_device_id(hass, device_id=None) is None async def test_remove_stale_device_links_keep_entity_device( diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 33da980715fd..cbe16456a8ac 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,9 +1,11 @@ """Tests for the Device Registry.""" -from collections.abc import Iterable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager, nullcontext from datetime import datetime from functools import partial +import json +import pathlib import time from typing import Any from unittest.mock import ANY, patch @@ -28,6 +30,20 @@ from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_capture_events, flush_store +def _get_device_for_config_entry( + device_registry: dr.DeviceRegistry, + config_entry_id: str, + *, + identifiers: set[tuple[str, str]] | None = None, + connections: set[tuple[str, str]] | None = None, +) -> dr.DeviceEntry | None: + """Return the device for a config entry matching identifiers or connections.""" + for device in device_registry.devices.get_entries(identifiers, connections): + if device.config_entry_id == config_entry_id: + return device + return None + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a mock config entry and add it to hass.""" @@ -160,10 +176,27 @@ async def test_requirement_for_identifier_or_connection( ) +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None: + """Test async_get raises when the registry has not been set up.""" + with pytest.raises(RuntimeError, match="Device registry not set up"): + dr.async_get(hass) + + dr.async_setup(hass) + assert isinstance(dr.async_get(hass), dr.DeviceRegistry) + + +async def test_async_load_twice_raises(hass: HomeAssistant) -> None: + """Test loading the device registry twice raises.""" + registry = dr.async_get(hass) + with pytest.raises(RuntimeError, match="Device registry is already loaded"): + await registry.async_load() + + async def test_multiple_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" + """Test registering a device for multiple config entries with same identifiers.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -191,133 +224,70 @@ async def test_multiple_config_entries( model="model", ) - assert len(device_registry.devices) == 1 - assert entry.id == entry2.id + # Identifiers and connections are unique per config entry: the two config entries + # get separate devices, while re-registering for the first entry reuses its device + assert len(device_registry.devices) == 2 + assert entry.id != entry2.id assert entry.id == entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.primary_config_entry == config_entry_1.entry_id - assert entry3.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry3.primary_config_entry == config_entry_1.entry_id + assert entry.config_entry_id == config_entry_1.entry_id + assert entry2.config_entry_id == config_entry_2.entry_id async def test_multiple_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - config_entry_1 = MockConfigEntry( + """Test re-registering a device under different subentries of one config entry.""" + config_entry = MockConfigEntry( subentries_data=( config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), ) ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} - entry_id = entry.id - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=None, + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", + entry3 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - } - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"} - } - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry.id == entry_id - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - -@pytest.mark.parametrize("load_registries", [False]) -async def test_async_get_before_setup_raises(hass: HomeAssistant) -> None: - """Test async_get raises when the registry has not been set up.""" - with pytest.raises(RuntimeError, match="Device registry not set up"): - dr.async_get(hass) - - dr.async_setup(hass) - assert isinstance(dr.async_get(hass), dr.DeviceRegistry) - - -async def test_async_load_twice_raises(hass: HomeAssistant) -> None: - """Test loading the device registry twice raises.""" - registry = dr.async_get(hass) - with pytest.raises(RuntimeError, match="Device registry is already loaded"): - await registry.async_load() + # A device belongs to a single subentry; re-registering the same identifiers under + # another subentry of the same config entry moves the device rather than duplicating + assert len(device_registry.devices) == 1 + assert entry.id == entry2.id == entry3.id + assert entry2.config_subentry_id == "mock-subentry-id-2" + assert entry3.config_subentry_id == "mock-subentry-id-1" @pytest.mark.parametrize("load_registries", [False]) @@ -339,6 +309,12 @@ async def test_loading_from_storage( "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": created_at, @@ -365,6 +341,9 @@ async def test_loading_from_storage( "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "has_composite_identifiers": False, "connections": [["Zigbee", "23.45.67.89.01"]], "created_at": created_at, "disabled_by": dr.DeviceEntryDisabler.USER, @@ -375,6 +354,7 @@ async def test_loading_from_storage( "modified_at": modified_at, "name_by_user": "Test Friendly Name", "orphaned_timestamp": None, + "domain": None, } ], }, @@ -388,8 +368,8 @@ async def test_loading_from_storage( assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, @@ -410,8 +390,8 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, created_at=datetime.fromisoformat(created_at), @@ -427,7 +407,6 @@ async def test_loading_from_storage( modified_at=datetime.fromisoformat(modified_at), name_by_user="Test Friendly Name", name="name", - primary_config_entry=mock_config_entry.entry_id, serial_number="serial_no", sw_version="version", ) @@ -445,8 +424,8 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), disabled_by=dr.DeviceEntryDisabler.USER, @@ -457,7 +436,6 @@ async def test_loading_from_storage( model="model", modified_at=utcnow(), name_by_user="Test Friendly Name", - primary_config_entry=mock_config_entry.entry_id, ) assert entry.id == "bcdefghijklmn" assert isinstance(entry.config_entries, set) @@ -552,8 +530,12 @@ async def test_migration_from_1_1( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -576,8 +558,12 @@ async def test_migration_from_1_1( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -593,7 +579,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -602,8 +588,8 @@ async def test_migration_from_1_1( "deleted_devices": [ { "area_id": None, - "config_entries": ["123456"], - "config_entries_subentries": {"123456": [None]}, + "config_entry_id": "123456", + "config_subentry_id": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -614,6 +600,7 @@ async def test_migration_from_1_1( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "orphaned_timestamp": None, + "domain": None, } ], }, @@ -705,8 +692,12 @@ async def test_migration_from_1_2( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -729,8 +720,12 @@ async def test_migration_from_1_2( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -746,7 +741,7 @@ async def test_migration_from_1_2( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -842,8 +837,12 @@ async def test_migration_fom_1_3( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -866,8 +865,12 @@ async def test_migration_fom_1_3( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -883,7 +886,7 @@ async def test_migration_fom_1_3( "modified_at": "1970-01-01T00:00:00+00:00", "name": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -923,7 +926,7 @@ async def test_migration_from_1_4( "name": "name", "name_by_user": None, "serial_number": None, - "sw_version": "new_version", + "sw_version": "version", "via_device_id": None, }, { @@ -981,8 +984,12 @@ async def test_migration_from_1_4( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1005,8 +1012,12 @@ async def test_migration_from_1_4( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1022,7 +1033,7 @@ async def test_migration_from_1_4( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1063,7 +1074,7 @@ async def test_migration_from_1_5( "name": "name", "name_by_user": None, "serial_number": None, - "sw_version": "new_version", + "sw_version": "version", "via_device_id": None, }, { @@ -1122,8 +1133,12 @@ async def test_migration_from_1_5( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1146,8 +1161,12 @@ async def test_migration_from_1_5( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1163,7 +1182,7 @@ async def test_migration_from_1_5( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1222,7 +1241,7 @@ async def test_migration_from_1_6( "manufacturer": None, "model": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "name": None, "serial_number": None, "sw_version": None, @@ -1265,8 +1284,12 @@ async def test_migration_from_1_6( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1289,8 +1312,12 @@ async def test_migration_from_1_6( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1306,7 +1333,7 @@ async def test_migration_from_1_6( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1367,7 +1394,7 @@ async def test_migration_from_1_7( "model": None, "model_id": None, "name_by_user": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "name": None, "serial_number": None, "sw_version": None, @@ -1410,8 +1437,12 @@ async def test_migration_from_1_7( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1434,8 +1465,12 @@ async def test_migration_from_1_7( }, { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1451,7 +1486,7 @@ async def test_migration_from_1_7( "modified_at": "1970-01-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": None, + "primary_config_entry": "234567", "serial_number": None, "sw_version": None, "via_device_id": None, @@ -1556,8 +1591,12 @@ async def test_migration_from_1_10( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1582,8 +1621,9 @@ async def test_migration_from_1_10( "deleted_devices": [ { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "domain": None, "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -1693,8 +1733,12 @@ async def test_migration_from_1_11( "devices": [ { "area_id": None, - "config_entries": [mock_config_entry.entry_id], - "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [["mac", "12:34:56:ab:cd:ef"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1719,8 +1763,9 @@ async def test_migration_from_1_11( "deleted_devices": [ { "area_id": None, - "config_entries": ["234567"], - "config_entries_subentries": {"234567": [None]}, + "config_entry_id": "234567", + "config_subentry_id": None, + "domain": None, "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, @@ -1737,11 +1782,1472 @@ async def test_migration_from_1_11( } +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_from_1_12( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from version 1.12. + + Version 3.1 restricts a device to a single config entry and subentry: a device + belonging to several config entries is split into one device per config entry (each + keeping a copy of the identifiers/connections and a legacy reference to the composite + id), while a device in several subentries of one config entry is collapsed onto a + single subentry (preferring a real subentry over the main entry). A device already + tied to a single config entry and subentry keeps its id. + """ + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + config_entry_3.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Composite device belonging to two config entries -> split in two + { + "area_id": "area_1", + "config_entries": [ + mock_config_entry.entry_id, + config_entry_2.entry_id, + ], + "config_entries_subentries": { + mock_config_entry.entry_id: [None], + config_entry_2.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": None, + "hw_version": None, + "id": "composite0000000000000000000000", + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + }, + # Composite device spanning several subentries of one config entry -> + # split into one device per subentry (including the no-subentry one) + { + "area_id": None, + "config_entries": [config_entry_3.entry_id], + "config_entries_subentries": { + config_entry_3.entry_id: [ + None, + "mock-subentry-id-1", + "mock-subentry-id-2", + ] + }, + "configuration_url": None, + "connections": [["mac", "34:56:78:cd:ef:12"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "subentries00000000000000000000", + "identifiers": [["domain_c", "1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": config_entry_3.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + # Single (config entry, subentry) device -> keeps its id, no legacy ref + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "singleentry00000000000000000000", + "identifiers": [["domain_a", "2"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "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": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The single (config entry, subentry) device keeps its id and has no legacy reference + single = registry.async_get("singleentry00000000000000000000") + assert single is not None + assert single.config_entry_id == mock_config_entry.entry_id + assert single.config_subentry_id is None + assert single.composite_device_id is None + assert single.has_composite_identifiers is False + + # The composite spanning two config entries is split into one device per config entry + assert "composite0000000000000000000000" not in registry.devices + entry_splits = registry.async_get_devices_for_composite_device_id( + "composite0000000000000000000000" + ) + assert len(entry_splits) == 2 + assert {(d.config_entry_id, d.config_subentry_id) for d in entry_splits} == { + (mock_config_entry.entry_id, None), + (config_entry_2.entry_id, None), + } + for device in entry_splits: + assert device.id != "composite0000000000000000000000" + # Each split copies the identity and customizations of the composite ... + assert device.identifiers == {("domain_a", "1"), ("domain_b", "1")} + assert device.connections == {("mac", "12:34:56:ab:cd:ef")} + assert device.area_id == "area_1" + assert device.name_by_user == "custom name" + assert device.labels == {"lab"} + assert device.serial_number == "SERIAL" + # ... and records its composite_device_id, keeping the copied identifiers + assert device.composite_device_id == "composite0000000000000000000000" + assert device.composite_primary_config_entry == mock_config_entry.entry_id + assert device.split_at is not None + assert device.has_composite_identifiers is True + + # A device spanning several subentries of ONE config entry is an invalid state (only + # a buggy 2025.7 subentry migration produced it); it is collapsed to a single device + # on one subentry - preferring a real subentry over the main entry (None) - rather + # than split into duplicate devices sharing the same identifiers/connections. It + # keeps its id and gains no composite bookkeeping. + assert "subentries00000000000000000000" in registry.devices + assert ( + registry.async_get_devices_for_composite_device_id( + "subentries00000000000000000000" + ) + == [] + ) + collapsed = _get_device_for_config_entry( + registry, config_entry_3.entry_id, identifiers={("domain_c", "1")} + ) + assert collapsed is not None + assert collapsed.id == "subentries00000000000000000000" + assert collapsed.config_entry_id == config_entry_3.entry_id + assert collapsed.config_subentry_id == "mock-subentry-id-1" + assert collapsed.identifiers == {("domain_c", "1")} + assert collapsed.connections == {("mac", "34:56:78:cd:ef:12")} + assert collapsed.composite_device_id is None + assert collapsed.has_composite_identifiers is False + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_backs_up_store_file( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_tmp_config_dir: str, +) -> None: + """The store file is copied to a timestamped backup before the version 3 migration.""" + hass.config.config_dir = hass_tmp_config_dir + storage_dir = pathlib.Path(hass_tmp_config_dir) / ".storage" + storage_dir.mkdir(parents=True, exist_ok=True) + old_store = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": {"devices": [], "deleted_devices": []}, + } + (storage_dir / dr.STORAGE_KEY).write_text(json.dumps(old_store)) + hass_storage[dr.STORAGE_KEY] = old_store + + dr.async_setup(hass) + await dr.async_load(hass) + + # Exactly one timestamped copy of the pre-migration file was made + backups = list(storage_dir.glob(f"{dr.STORAGE_KEY}.*.migration_backup")) + assert len(backups) == 1 + assert json.loads(backups[0].read_text()) == old_store + # The middle segment is a YYYYMMDD_HHMMSS timestamp (strptime raises if malformed) + datetime.strptime(backups[0].name.split(".")[-2], "%Y%m%d_%H%M%S") + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_detaches_via_device_of_dropped_parent( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A child of an ownerless parent dropped by the migration has its link detached. + + The migration drops an active device with no config entry; normally + async_remove_device would clear via_device_id links to it, so the migration must too. + """ + entry = MockConfigEntry() + entry.add_to_hass(hass) + + def _device(**overrides: Any) -> dict[str, Any]: + device = { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "device0000000000000000000000000", + "identifiers": [["test", "1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + return device | overrides + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Ownerless parent (no config entries) -> dropped by the migration + _device( + id="orphan0000000000000000000000000", + config_entries=[], + config_entries_subentries={}, + identifiers=[["test", "orphan"]], + primary_config_entry=None, + ), + # Child linked to the orphan via via_device_id + _device( + id="child00000000000000000000000000", + identifiers=[["test", "child"]], + via_device_id="orphan0000000000000000000000000", + ), + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The ownerless parent was dropped; the child survives with its link detached + assert registry.async_get("orphan0000000000000000000000000") is None + child = registry.async_get("child00000000000000000000000000") + assert child is not None + assert child.via_device_id is None + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_collapses_multi_subentry_device( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A device wrongly assigned to several subentries of one config entry collapses. + + Only a buggy 2025.7 subentry migration produced this state. The migration must + collapse it to a single device (preferring a real subentry over the main entry, + None), NOT split it into duplicate devices sharing the same identifiers/connections. + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="Sub 1", + unique_id="s1", + ), + ] + ) + entry.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None, "sub-1"]}, + "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": None, + "hw_version": None, + "id": "buggydevice00000000000000000", + "identifiers": [["test", "device-1"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Collapsed to a single device (no duplicate), on the real subentry, keeping its id + assert len(registry.devices) == 1 + device = registry.async_get("buggydevice00000000000000000") + assert device is not None + assert device.config_entry_id == entry.entry_id + assert device.config_subentry_id == "sub-1" + assert device.config_entries_subentries == {entry.entry_id: {"sub-1"}} + # It is not split and stays findable by identifier and connection (not shadowed) + assert ( + registry.async_get_devices_for_composite_device_id( + "buggydevice00000000000000000" + ) + == [] + ) + assert device.composite_device_id is None + assert device.has_composite_identifiers is False + assert ( + _get_device_for_config_entry( + registry, entry.entry_id, identifiers={("test", "device-1")} + ) + is device + ) + assert ( + _get_device_for_config_entry( + registry, entry.entry_id, connections={("mac", "12:34:56:ab:cd:ef")} + ) + is device + ) + + +async def test_async_get_or_create_moves_device_between_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Re-registering under a different subentry moves the device, not duplicates it. + + Identifiers and connections are unique per config entry (not per subentry), so a + second async_get_or_create with the same identifier/connection but a different + subentry of the same config entry moves the existing device - it neither creates a + duplicate nor raises. + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="1", + unique_id="s1", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-2", + subentry_type="test", + title="2", + unique_id="s2", + ), + ] + ) + entry.add_to_hass(hass) + + # Same identifier, different subentry -> the existing device is moved + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-1", + identifiers={("test", "1")}, + ) + assert device.config_subentry_id == "sub-1" + moved = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-2", + identifiers={("test", "1")}, + ) + assert moved.id == device.id + assert moved.config_subentry_id == "sub-2" + assert len(device_registry.devices) == 1 + + # Same connection, different subentry -> also moved, not duplicated + device_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-1", + connections={("mac", "12:34:56:ab:cd:ef")}, + ) + moved_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="sub-2", + connections={("mac", "12:34:56:ab:cd:ef")}, + ) + assert moved_2.id == device_2.id + assert moved_2.config_subentry_id == "sub-2" + assert len(device_registry.devices) == 2 + + +async def test_async_get_device_returns_first_match_for_ambiguous_lookup( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Independent devices sharing an identifier resolve to the first match. + + They are not splits of one pre-migration composite (no shared composite_device_id), + so there is nothing to merge and the lookup returns one of the real devices rather + than a composite. + """ + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + assert device_1.id != device_2.id + + match = device_registry.async_get_device(identifiers={("test", "shared")}) + # A real registry device (the first match), not a synthesized composite + assert match is device_1 + assert match.id in device_registry.devices + assert match.config_entries == {entry_1.entry_id} + + +async def test_async_get_device_prefers_calling_integration( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An ambiguous lookup prefers a device owned by the calling integration.""" + entry_a = MockConfigEntry(domain="itg_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="itg_b") + entry_b.add_to_hass(hass) + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + # itg_a's device is indexed first (created first) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, connections={mac} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, connections={mac} + ) + assert device_a.id != device_b.id + + # Each integration resolves to its own device, regardless of index order + with patch.object(dr, "_current_integration_domain", return_value="itg_b"): + assert device_registry.async_get_device(connections={mac}) is device_b + with patch.object(dr, "_current_integration_domain", return_value="itg_a"): + assert device_registry.async_get_device(connections={mac}) is device_a + + # A caller owning neither, or no integration frame, falls back to the first match + with patch.object(dr, "_current_integration_domain", return_value="other"): + assert device_registry.async_get_device(connections={mac}) is device_a + with patch.object(dr, "_current_integration_domain", return_value=None): + assert device_registry.async_get_device(connections={mac}) is device_a + + +async def test_async_get_device_prefers_matching_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A lookup prefers the device whose config entry domain matches the identifier. + + Right after the migration split, and until identifiers are pruned, every split still + carries the composite's full identifier set, so a lookup matches all splits; the + domain match resolves it to the correct single device without a composite. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + # entry_b's device also carries domain_a's identifier (unpruned split state) + device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, + identifiers={("domain_a", "1"), ("domain_b", "2")}, + ) + assert device_registry.async_get_device(identifiers={("domain_a", "1")}) is device_a + + +async def test_async_remove_device_fans_out_to_migration_composite( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """async_remove_device on a pre-migration composite id removes its splits.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + device_registry.async_remove_device(old_id) + + assert device_1.id not in device_registry.devices + assert device_2.id not in device_registry.devices + + +async def test_async_update_device_fans_out_to_migration_composite( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """async_update_device on a pre-migration composite id fans out to its splits.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + device_registry.async_update_device(old_id, name_by_user="merged") + + assert device_registry.async_get(device_1.id).name_by_user == "merged" + assert device_registry.async_get(device_2.id).name_by_user == "merged" + + +async def test_get_entry_by_connection_without_config_entry_scope( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The container resolves by connection when no config entry scope is given.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + connection = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, connections={connection} + ) + assert device_registry.devices.get_entry(connections={connection}) is device + + +async def test_update_unknown_device_id_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Updating an id that is neither a real device nor a composite raises.""" + with pytest.raises(KeyError): + device_registry.async_update_device("unknown0000000000000000000000ab", name="x") + + +async def test_cleanup_removes_device_referencing_missing_config_entry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Cleanup drops a device still referencing a config entry that no longer exists.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "1")} + ) + # An entity keeps the device out of the plain-orphan sweep so the defensive + # missing-config-entry path is reached + entity_registry.async_get_or_create("sensor", "test", "unique", device_id=device.id) + + # The device's config entry is no longer known to hass + with patch.object(hass.config_entries, "async_entry_ids", return_value=[]): + dr.async_cleanup(hass, device_registry, entity_registry) + + assert device.id not in device_registry.devices + + +async def test_clear_config_entry_removes_device_with_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config entry removes its device, ignoring a pending move. + + add_config_entry_id records a transient pending move; tearing down the owning config + entry must remove the device rather than complete that move to the other entry. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + device_registry.async_clear_config_entry(entry_1.entry_id) + + assert device.id not in device_registry.devices + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + + +async def test_clear_config_entry_clears_pending_move_targeting_it( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config entry drops a pending move that targets it. + + A device owned by another entry can hold a transient pending move to the entry being + removed; clearing it stops a later completion from moving the device onto the removed + entry instead of deleting it. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + # Start a deferred move to entry_2 (add_config_entry_id without the paired remove yet) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + # entry_2 is torn down before the move completes + device_registry.async_clear_config_entry(entry_2.entry_id) + + # Completing the move by removing the owner must delete the device, not move it onto + # the removed entry_2 + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert result is None + assert device.id not in device_registry.devices + + +async def test_move_to_config_entry_clears_target_entry_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device into a config entry clears a matching deleted device it holds. + + A retained-identity move adds no new identifiers/connections, so the deleted device the + target entry kept for the same identity must still be removed - otherwise the active + device and the deleted device share the target entry's per-identity slot. + """ + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry() + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "shared")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + assert device_a.id != device_b.id + + # Leave a deleted device owned by entry_b with the shared identity + device_registry.async_remove_device(device_b.id) + assert device_b.id in device_registry.deleted_devices + + # Move device_a into entry_b, retaining its identity + device_registry.async_update_device( + device_a.id, new_config_entry_id=entry_b.entry_id + ) + + assert device_registry.async_get(device_a.id).config_entry_id == entry_b.entry_id + # The deleted device entry_b held for the same identity is cleared, not left immortal + assert device_b.id not in device_registry.deleted_devices + + +async def test_get_or_create_via_device_and_via_device_id_raises_cleanly( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Passing both via_device and via_device_id raises without inserting a device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with pytest.raises(HomeAssistantError, match="not allowed"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("test", "1")}, + via_device=("test", "via"), + via_device_id="via-device-id", + ) + + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + assert len(device_registry.devices) == 0 + + +async def test_get_or_create_invalid_subentry_raises_cleanly( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An unknown config_subentry_id raises without inserting a device.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with pytest.raises(HomeAssistantError, match="has no subentry"): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="does-not-exist", + identifiers={("test", "1")}, + ) + + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + assert len(device_registry.devices) == 0 + + +async def test_add_current_config_entry_is_noop( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Adding the device's current owner records no pending move. + + So a later removal of that sole owner deletes the device instead of moving it to + itself. + """ + entry = MockConfigEntry() + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("test", "1")} + ) + + device_registry.async_update_device(device.id, add_config_entry_id=entry.entry_id) + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + assert result is None + assert device.id not in device_registry.devices + + +@pytest.mark.parametrize( + "clear_domain", + ["light", None], + ids=["explicit-domain", "auto-resolved-domain"], +) +async def test_reregister_restores_orphan( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + clear_domain: str | None, +) -> None: + """Re-adding an integration restores its orphan. + + async_clear_config_entry records the config entry's domain - passed in by the core + removal flow, or resolved from the still-present entry when omitted - and a later + async_get_or_create under the same domain restores that orphan (id, labels, name) + rather than create a fresh device. + """ + entry = MockConfigEntry(domain="light") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("light", "1")}, name="Original" + ) + device_registry.async_update_device( + device.id, name_by_user="Custom", labels={"label1"} + ) + + # Removing the config entry orphans the deleted device (config_entry_id=None) + device_registry.async_clear_config_entry(entry.entry_id, clear_domain) + orphan = device_registry.deleted_devices[device.id] + assert orphan.config_entry_id is None + assert orphan.domain == "light" + + # Re-add the integration under a new config entry and re-register the device + new_entry = MockConfigEntry(domain="light") + new_entry.add_to_hass(hass) + restored = device_registry.async_get_or_create( + config_entry_id=new_entry.entry_id, identifiers={("light", "1")} + ) + + assert restored.id == device.id + assert restored.config_entry_id == new_entry.entry_id + assert restored.name_by_user == "Custom" + assert restored.labels == {"label1"} + + +async def test_orphan_not_restored_for_other_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan recorded for one integration is not restored by another. + + Identifiers and connections are no longer unique across integrations, so a chance + collision must not restore another integration's orphaned device onto this one. + """ + entry = MockConfigEntry(domain="light") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("light", "1")} + ) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) + assert device_registry.deleted_devices[device.id].domain == "light" + + # A different integration registering a device with the same identifiers gets a fresh + # device, and the orphan is left intact for its own integration to restore later + other_entry = MockConfigEntry(domain="switch") + other_entry.add_to_hass(hass) + fresh = device_registry.async_get_or_create( + config_entry_id=other_entry.entry_id, identifiers={("light", "1")} + ) + assert fresh.id != device.id + assert device.id in device_registry.deleted_devices + + +async def test_orphaning_replaces_colliding_same_domain_orphan( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Orphaning a device drops a stale same-domain orphan it collides with. + + Two devices from the same integration sharing a connection both orphan under + config_entry_id=None and would collide in the lookup index; the newest orphan replaces + the stale one so a re-add restores it deterministically. + """ + connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")} + entry_1 = MockConfigEntry(domain="hue") + entry_1.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + connections=connections, + identifiers={("hue", "1")}, + ) + entry_2 = MockConfigEntry(domain="hue") + entry_2.add_to_hass(hass) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + connections=connections, + identifiers={("hue", "2")}, + ) + + device_registry.async_clear_config_entry(entry_1.entry_id, entry_1.domain) + assert device_1.id in device_registry.deleted_devices + + device_registry.async_clear_config_entry(entry_2.entry_id, entry_2.domain) + # The newer orphan replaces the stale one it collides with on the shared connection + assert device_1.id not in device_registry.deleted_devices + assert device_2.id in device_registry.deleted_devices + + # Re-adding under the same domain restores the surviving orphan + entry_3 = MockConfigEntry(domain="hue") + entry_3.add_to_hass(hass) + restored = device_registry.async_get_or_create( + config_entry_id=entry_3.entry_id, + connections=connections, + identifiers={("hue", "2")}, + ) + assert restored.id == device_2.id + + +async def test_orphaned_domain_survives_store_round_trip( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan's recorded domain is written to and read back from storage.""" + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("hue", "1")} + ) + device_registry.async_clear_config_entry(entry.entry_id, entry.domain) + + registry2 = dr.DeviceRegistry(hass) + await flush_store(device_registry._store) + await registry2.async_load() + + assert registry2.deleted_devices[device.id].domain == "hue" + + +async def test_orphan_keeps_domain_when_config_entry_removed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An orphan keeps its domain when its config entry is removed via the normal flow. + + config_entries deletes the entry from the registry before calling + async_clear_config_entry, so async_remove_device can no longer look up the domain and + records None; the domain passed to async_clear_config_entry is what preserves it on + the orphan. Without it the orphan would have domain=None and, with the domain-less + restore fallback gone, could never be restored. + """ + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("hue", "1")} + ) + + await hass.config_entries.async_remove(entry.entry_id) + + orphan = device_registry.deleted_devices[device.id] + assert orphan.config_entry_id is None + assert orphan.domain == "hue" + + +async def test_cross_domain_orphans_do_not_shadow( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Orphans from different integrations sharing an identifier stay independently found. + + Both orphans would otherwise collide in the config_entry_id=None index; keying orphans + by their recorded domain keeps each restorable by its own integration. + """ + shared = {("test", "shared")} + entry_a = MockConfigEntry(domain="hue") + entry_a.add_to_hass(hass) + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers=shared + ) + entry_b = MockConfigEntry(domain="mqtt") + entry_b.add_to_hass(hass) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers=shared + ) + + device_registry.async_clear_config_entry(entry_a.entry_id, entry_a.domain) + device_registry.async_clear_config_entry(entry_b.entry_id, entry_b.domain) + + # Re-adding under each domain restores that domain's own orphan, not the other's + entry_c = MockConfigEntry(domain="mqtt") + entry_c.add_to_hass(hass) + restored_b = device_registry.async_get_or_create( + config_entry_id=entry_c.entry_id, identifiers=shared + ) + assert restored_b.id == device_b.id + + entry_d = MockConfigEntry(domain="hue") + entry_d.add_to_hass(hass) + restored_a = device_registry.async_get_or_create( + config_entry_id=entry_d.entry_id, identifiers=shared + ) + assert restored_a.id == device_a.id + + +async def test_domainless_orphan_not_restored( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A domain-less orphan is not restored; re-registering creates a fresh device. + + The migration carries orphans over without a domain, which can't be resolved once the + config entry is gone. Orphans are matched only on their recorded domain, so a + domain-less one is left for the periodic purge and re-registering makes a new device. + """ + entry_1 = MockConfigEntry(domain="hue") + entry_1.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + + # Simulate an orphan whose domain can no longer be resolved (the migration carries + # orphans over without one) + with patch.object(hass.config_entries, "async_get_entry", return_value=None): + device_registry.async_clear_config_entry(entry_1.entry_id) + assert device_registry.deleted_devices[device_1.id].domain is None + + # Re-registering the shared identifier does not restore the domain-less orphan + entry_2 = MockConfigEntry(domain="hue") + entry_2.add_to_hass(hass) + fresh = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + assert fresh.id != device_1.id + # The un-restored orphan lingers until the periodic purge + assert device_1.id in device_registry.deleted_devices + + +async def test_clear_config_subentry_removes_device_with_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config subentry removes its device, ignoring a pending move. + + add_config_entry_id records a transient pending move; tearing down the owning + subentry must remove the device rather than complete that move. + """ + entry_1 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("test", "1")}, + ) + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + + device_registry.async_clear_config_subentry(entry_1.entry_id, "mock-subentry-id-1") + + assert device.id not in device_registry.devices + assert device_registry.async_get_device(identifiers={("test", "1")}) is None + + +async def test_clear_config_subentry_clears_pending_move_targeting_it( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Clearing a config subentry drops a pending move that targets it. + + A device owned by another entry can hold a transient pending move to the subentry being + removed; clearing it stops a later completion from validating against the removed + subentry (moving the device onto it, or raising) instead of deleting it. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + # Start a deferred move into entry_2's subentry + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + + # The target subentry is torn down before the move completes + device_registry.async_clear_config_subentry(entry_2.entry_id, "mock-subentry-id-1") + + # Completing the move by removing the owner must delete the device, not move it onto + # the removed subentry + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert result is None + assert device.id not in device_registry.devices + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_device_composite_reuses_pre_migration_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A composite over migration splits reuses the pre-migration device id. + + Backwards compatibility for unmodified integrations: before the rewrite a shared + connection resolved to one device with a stable id that stored references + (automations, an entity device_id, a fired event device_id) use. The composite over + that device's splits reuses the same id, so those references keep resolving; a + transient id is minted only for a runtime ambiguity between independent devices. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [["mac", "aa:bb:cc:dd:ee:ff"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "composite00000000000000000000", + "identifiers": [["domain_a", "1"], ["domain_b", "2"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_a.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # A connections-only lookup matches both splits -> composite reuses the old id + composite = registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + ) + assert composite is not None + assert composite.id == "composite00000000000000000000" + assert composite.id not in registry.devices + # It is the same composite async_get resolves for the old id + assert registry.async_get("composite00000000000000000000").id == composite.id + # An identifier lookup still domain-resolves to the single owning split (real id) + resolved = registry.async_get_device(identifiers={("domain_a", "1")}) + assert resolved.id in registry.devices + assert resolved.config_entry_id == entry_a.entry_id + + +@pytest.mark.parametrize( + "update_kwargs", + [ + pytest.param({"new_identifiers": {("test", "new")}}, id="new_identifiers"), + pytest.param( + {"new_connections": {("mac", "12:34:56:ab:cd:ef")}}, id="new_connections" + ), + pytest.param( + {"merge_identifiers": {("test", "extra")}}, id="merge_identifiers" + ), + pytest.param( + {"merge_connections": {("mac", "12:34:56:ab:cd:ef")}}, + id="merge_connections", + ), + ], +) +async def test_async_update_device_composite_drops_identity_args( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + update_kwargs: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Identity-rewriting args are ambiguous on a composite: dropped with a warning.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # No raise; the arg is ignored with a report-issue warning, devices untouched + device_registry.async_update_device(old_id, **update_kwargs) + assert "async_entries_for_config_entry" in caplog.text + assert "report this issue" in caplog.text + assert device_registry.async_get(device_1.id).identifiers == {("test", "1")} + assert device_registry.async_get(device_1.id).connections == set() + assert device_registry.async_get(device_2.id).identifiers == {("test", "2")} + assert device_registry.async_get(device_2.id).connections == set() + + +async def test_async_update_device_composite_drops_only_disallowed_args( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A composite update applies the allowed args and drops the disallowed ones.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + device_registry.async_update_device( + old_id, + new_identifiers={("test", "renamed")}, # disallowed -> dropped + name_by_user="Custom name", # allowed -> applied to every underlying device + ) + assert "new_identifiers" in caplog.text + # Allowed arg applied to both underlying devices + assert device_registry.async_get(device_1.id).name_by_user == "Custom name" + assert device_registry.async_get(device_2.id).name_by_user == "Custom name" + # Disallowed arg dropped: identities untouched + assert device_registry.async_get(device_1.id).identifiers == {("test", "1")} + assert device_registry.async_get(device_2.id).identifiers == {("test", "2")} + + +async def test_async_update_device_composite_drops_move_args( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """new_config_entry_id / new_config_subentry_id are dropped on the composite path. + + A forwarded move can't be caught by the identifier/connection checks - the splits have + distinct identities and would move without colliding - so assert each split keeps its + original (config entry, subentry). + """ + entry_1 = MockConfigEntry( + domain="test", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, subentry_type="test", title="Sub", unique_id=None + ) + ], + ) + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + subentry_id = next(iter(entry_1.subentries)) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "2")} + ) + old_id = "composite00000000000000000000ab" + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # Targets are valid, so a forwarded move would land silently - only the owner + # assertions below catch it. + device_registry.async_update_device(old_id, new_config_entry_id=entry_2.entry_id) + device_registry.async_update_device(old_id, new_config_subentry_id=subentry_id) + + assert device_registry.async_get(device_1.id).config_entry_id == entry_1.entry_id + assert device_registry.async_get(device_1.id).config_subentry_id is None + assert device_registry.async_get(device_2.id).config_entry_id == entry_2.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_drops_device_without_config_entries( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """A device with no config entry / subentry pairs is dropped during migration.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + # Orphan device with no config entries -> dropped + { + "area_id": None, + "config_entries": [], + "config_entries_subentries": {}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "orphan00000000000000000000000", + "identifiers": [["domain_a", "orphan"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": None, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + # Normal single-config-entry device -> kept + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "keptdevice0000000000000000000", + "identifiers": [["domain_a", "kept"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "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": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The orphan device was dropped, the normal device kept + assert registry.async_get("orphan00000000000000000000000") is None + assert "orphan00000000000000000000000" not in registry.devices + kept = registry.async_get("keptdevice0000000000000000000") + assert kept is not None + assert kept.config_entry_id == mock_config_entry.entry_id + assert len(registry.devices) == 1 + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_splits_deleted_device_with_multiple_config_entries( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A deleted device belonging to several config entries is split, one per entry. + + Each split keeps the identity and customizations so every config entry can still + restore its share when a matching device is re-registered. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [], + "deleted_devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": False, + "id": "deletedcomposite0000000000000", + "identifiers": [["domain_a", "1"]], + "labels": ["lab"], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "orphaned_timestamp": None, + } + ], + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Split into one deleted device per config entry, each keeping identity/customizations + assert len(registry.deleted_devices) == 2 + assert "deletedcomposite0000000000000" not in registry.deleted_devices + by_entry = {d.config_entry_id: d for d in registry.deleted_devices.values()} + assert set(by_entry) == {entry_a.entry_id, entry_b.entry_id} + for deleted in by_entry.values(): + assert deleted.identifiers == {("domain_a", "1")} + assert deleted.connections == {("mac", "12:34:56:ab:cd:ef")} + assert deleted.name_by_user == "custom name" + assert deleted.area_id == "area_1" + + # Each config entry can restore its share, with the customizations preserved + restored_a = registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert restored_a.config_entry_id == entry_a.entry_id + assert restored_a.name_by_user == "custom name" + + restored_b = registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert restored_b.config_entry_id == entry_b.entry_id + assert restored_b.name_by_user == "custom name" + assert restored_a.id != restored_b.id + + async def test_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + """Test clearing a config entry removes the devices that belong to it.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -1751,86 +3257,36 @@ async def test_removing_config_entries( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry3 = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 2 - assert entry.id == entry2.id + # Same identifiers on different config entries are separate devices + assert len(device_registry.devices) == 3 + assert entry.id != entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.config_entries_subentries == { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - } device_registry.async_clear_config_entry(config_entry_1.entry_id) - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - entry3_removed = device_registry.async_get_device( - identifiers={("bridgeid", "4567")} - ) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} - assert entry3_removed is None - - await hass.async_block_till_done() - - assert len(update_events) == 5 - 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": "create", - "device_id": entry3.id, - } - assert update_events[3].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}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } + # Clearing config_entry_1 removes its two devices, leaving config_entry_2's + assert len(device_registry.devices) == 1 + assert device_registry.async_get(entry.id) is None + assert device_registry.async_get(entry3.id) is None + assert device_registry.async_get(entry2.id) is not None async def test_deleted_device_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + """Test clearing a config entry orphans its deleted devices.""" config_entry_1 = MockConfigEntry() config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry() @@ -1840,536 +3296,137 @@ async def test_deleted_device_removing_config_entries( config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 2 - assert len(device_registry.deleted_devices) == 0 - assert entry.id == entry2.id - assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry2.config_entries_subentries == { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - } - device_registry.async_remove_device(entry.id) - device_registry.async_remove_device(entry3.id) - + device_registry.async_remove_device(entry2.id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 - await hass.async_block_till_done() - assert len(update_events) == 5 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].data == { - "action": "remove", - "device_id": entry.id, - "device": entry2.dict_repr, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } - device_registry.async_clear_config_entry(config_entry_1.entry_id) - assert len(device_registry.devices) == 0 + + # Deleted devices are kept but orphaned (config entry cleared) so they can be purged assert len(device_registry.deleted_devices) == 2 - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} + assert device_registry.deleted_devices[entry.id].config_entry_id is None + assert ( + device_registry.deleted_devices[entry2.id].config_entry_id + == config_entry_2.entry_id + ) device_registry.async_clear_config_entry(config_entry_2.entry_id) - assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - - # No event when a deleted device is purged - await hass.async_block_till_done() - assert len(update_events) == 5 - - # Re-add, expect to keep the device id - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - - assert entry.id == entry2.id - - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 - - with patch("time.time", return_value=future_time): - device_registry.async_purge_expired_orphaned_devices() - - # Re-add, expect to get a new device id after the purge - entry4 = 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")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert entry3.id != entry4.id + assert device_registry.deleted_devices[entry2.id].config_entry_id is None async def test_removing_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test clearing a config subentry removes the devices that belong to it.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) + assert len(device_registry.devices) == 2 + assert entry.config_subentry_id == "mock-subentry-id-1" + assert entry2.config_subentry_id == "mock-subentry-id-2" + + device_registry.async_clear_config_subentry( + config_entry.entry_id, "mock-subentry-id-1" + ) + + # Only the device on the cleared subentry is removed assert len(device_registry.devices) == 1 - assert entry.id == entry2.id - assert entry.id == entry3.id - assert entry.id == entry4.id - assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - device_registry.async_update_device( - entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, - ) - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") - entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"} - } - - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - assert device_registry.async_get_device(identifiers={("bridgeid", "0123")}) is None - assert device_registry.async_get_device(identifiers={("bridgeid", "4567")}) is None - - await hass.async_block_till_done() - - assert len(update_events) == 8 - 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_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - "identifiers": {("bridgeid", "0123")}, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - }, - } - assert update_events[6].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: { - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: { - "mock-subentry-id-2-1", - }, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[7].data == { - "action": "remove", - "device_id": entry.id, - "device": entry.dict_repr, - } + assert device_registry.async_get(entry.id) is None + assert device_registry.async_get(entry2.id) is not None async def test_deleted_device_removing_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test clearing a config subentry orphans its deleted devices.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-2", + subentry_id="mock-subentry-id-2", subentry_type="test", title="Mock title", unique_id="test", ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", ) - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - assert entry.id == entry2.id - assert entry.id == entry3.id - assert entry.id == entry4.id - assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - device_registry.async_remove_device(entry.id) + device_registry.async_remove_device(entry2.id) + assert len(device_registry.deleted_devices) == 2 - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - await hass.async_block_till_done() - - assert len(update_events) == 5 - 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_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} - }, - }, - } - assert update_events[3].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - None, - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - "identifiers": {("bridgeid", "0123")}, - }, - } - assert update_events[4].data == { - "action": "remove", - "device_id": entry.id, - "device": entry4.dict_repr, - } - - device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - assert entry.orphaned_timestamp is None - - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - assert entry.orphaned_timestamp is None - - # Remove the same subentry again device_registry.async_clear_config_subentry( - config_entry_1.entry_id, "mock-subentry-id-1-1" + config_entry.entry_id, "mock-subentry-id-1" ) + + # Only the deleted device on the cleared subentry is orphaned + assert len(device_registry.deleted_devices) == 2 + assert device_registry.deleted_devices[entry.id].config_entry_id is None assert ( - device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) is entry + device_registry.deleted_devices[entry2.id].config_entry_id + == config_entry.entry_id ) - hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == {config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"} - } - assert entry.orphaned_timestamp is None - - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - assert entry.orphaned_timestamp is not None - - # No event when a deleted device is purged - await hass.async_block_till_done() - assert len(update_events) == 5 - - # Re-add, expect to keep the device id - hass.config_entries.async_add_subentry( - config_entry_2, - config_entries.ConfigSubentry( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - restored_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - config_subentry_id="mock-subentry-id-2-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert restored_entry.id == entry.id - - # Remove again, and trigger purge - device_registry.async_remove_device(entry.id) - hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") - entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) - assert entry.config_entries == set() - assert entry.config_entries_subentries == {} - assert entry.orphaned_timestamp is not None - - future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 - - with patch("time.time", return_value=future_time): - device_registry.async_purge_expired_orphaned_devices() - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 0 - - # Re-add, expect to get a new device id after the purge - new_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")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - assert new_entry.id != entry.id - async def test_removing_area_id( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry @@ -2554,6 +3611,167 @@ async def test_specifying_via_device_update( assert light.name == "New light" +async def test_get_or_create_via_device_and_via_device_id_not_allowed( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Passing both via_device and via_device_id is not allowed.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + via = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("hue", "via")} + ) + + with pytest.raises( + HomeAssistantError, + match="Passing both `via_device` and `via_device_id` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + via_device_id=via.id, + ) + + # Passing only via_device_id is allowed + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device_id=via.id, + ) + assert device.via_device_id == via.id + + # Passing only the deprecated via_device is still allowed (resolved to via_device_id) + device_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device_2")}, + via_device=("hue", "via"), + ) + assert device_2.via_device_id == via.id + + +async def test_get_or_create_via_device_none( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """`via_device=None` means "no via device"; combining it with via_device_id raises.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + via = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("hue", "via")} + ) + + # `via_device=None` alongside a via_device_id is contradictory and rejected + with pytest.raises( + HomeAssistantError, + match="Passing both `via_device` and `via_device_id` is not allowed", + ): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=None, + via_device_id=via.id, + ) + + # `via_device=None` on its own means no via device + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "device")}, + via_device=None, + ) + assert device.via_device_id is None + + # ... and it clears an existing via device on re-registration + linked = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "linked")}, + via_device_id=via.id, + ) + assert linked.via_device_id == via.id + relinked = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "linked")}, + via_device=None, + ) + assert relinked.id == linked.id + assert relinked.via_device_id is None + + +async def test_via_device_prefers_same_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device resolves to the via device in the same config entry.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + # Two via devices share an identifier, one per config entry + via_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("hue", "via")} + ) + via_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("hue", "via")} + ) + assert via_1.id != via_2.id + + device = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_2.id + + +async def test_via_device_falls_back_to_other_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device falls back to a via device in another config entry.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + # The via device only exists in entry_1 + via_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("hue", "via")} + ) + + device = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_1.id + + +async def test_via_device_prefers_same_domain( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The deprecated via_device prefers a via device from the same integration. + + When no via device exists in the registering config entry, one from another config + entry of the same domain is preferred over an arbitrary other-domain match. + """ + entry = MockConfigEntry(domain="hue") + entry.add_to_hass(hass) + other_domain_entry = MockConfigEntry(domain="deconz") + other_domain_entry.add_to_hass(hass) + same_domain_entry = MockConfigEntry(domain="hue") + same_domain_entry.add_to_hass(hass) + + # No via device in `entry`; the other-domain candidate is indexed first + device_registry.async_get_or_create( + config_entry_id=other_domain_entry.entry_id, identifiers={("hue", "via")} + ) + via_same_domain = device_registry.async_get_or_create( + config_entry_id=same_domain_entry.entry_id, identifiers={("hue", "via")} + ) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={("hue", "device")}, + via_device=("hue", "via"), + ) + assert device.via_device_id == via_same_domain.id + + async def test_loading_saving_data( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -2641,7 +3859,10 @@ async def test_loading_saving_data( suggested_area="Kitchen", ) - assert len(device_registry.devices) == 4 + # config_entry_4's device shares a connection with orig_light3 but belongs to a + # different config entry, so it is a separate device (identifiers/connections are + # unique per config entry) + assert len(device_registry.devices) == 5 assert len(device_registry.deleted_devices) == 1 orig_via = device_registry.async_update_device( @@ -2793,8 +4014,8 @@ async def test_update( assert updated_entry != entry assert updated_entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, - config_entries_subentries={mock_config_entry.entry_id: {None}}, + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=None, configuration_url="https://example.com/config", connections={("mac", "65:43:21:fe:dc:ba")}, created_at=created_at, @@ -2975,411 +4196,62 @@ async def test_update_connection( async def test_update_remove_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry() - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) + """Test removing a device's config entry deletes the device.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, + config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", - ) - entry3 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, - identifiers={("bridgeid", "4567")}, - manufacturer="manufacturer", - model="model", - ) - entry4 = device_registry.async_update_device( - entry2.id, add_config_entry_id=config_entry_3.entry_id - ) - # Try to add an unknown config entry - with pytest.raises(HomeAssistantError): - device_registry.async_update_device(entry2.id, add_config_entry_id="blabla") + assert entry.config_entry_id == config_entry.entry_id - assert len(device_registry.devices) == 2 - assert entry.id == entry2.id == entry4.id - assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry4.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - - device_registry.async_update_device( - entry2.id, remove_config_entry_id=config_entry_1.entry_id - ) - updated_entry = device_registry.async_update_device( - entry2.id, remove_config_entry_id=config_entry_3.entry_id - ) - removed_entry = device_registry.async_update_device( - entry3.id, remove_config_entry_id=config_entry_1.entry_id + # Removing the owning config entry with no pending move deletes the device + updated = device_registry.async_update_device( + entry.id, remove_config_entry_id=config_entry.entry_id ) - assert updated_entry.config_entries == {config_entry_2.entry_id} - assert removed_entry is None - - removed_entry = device_registry.async_get_device(identifiers={("bridgeid", "4567")}) - - assert removed_entry is None - - await hass.async_block_till_done() - - assert len(update_events) == 7 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": {config_entry_1.entry_id: {None}}, - }, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - assert update_events[3].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}, - }, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - }, - "config_entries_subentries": { - config_entry_1.entry_id: {None}, - config_entry_2.entry_id: {None}, - config_entry_3.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry2.id, - "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, - "config_entries_subentries": { - config_entry_2.entry_id: {None}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[6].data == { - "action": "remove", - "device_id": entry3.id, - "device": entry3.dict_repr, - } + assert updated is None + assert device_registry.async_get(entry.id) is None + assert len(device_registry.devices) == 0 async def test_update_remove_config_subentries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: - """Make sure we do not get duplicate entries.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( + """Test removing a device's config subentry deletes the device.""" + config_entry = MockConfigEntry( + subentries_data=[ config_entries.ConfigSubentryData( data={}, - subentry_id="mock-subentry-id-1-1", + subentry_id="mock-subentry-id-1", subentry_type="test", title="Mock title", unique_id="test", ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-2", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) + ] ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-2-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ) - ) - config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry() - config_entry_3.add_to_hass(hass) + config_entry.add_to_hass(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", + config_entry_id=config_entry.entry_id, + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer", - model="model", ) - entry_id = entry.id - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - } + assert entry.config_subentry_id == "mock-subentry-id-1" - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_1.entry_id, - add_config_subentry_id="mock-subentry-id-1-2", - ) - assert entry.config_entries == {config_entry_1.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"} - } - - # Try adding the same subentry again - assert ( - device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_1.entry_id, - add_config_subentry_id="mock-subentry-id-1-2", - ) - is entry + # Removing the owning config entry/subentry with no pending move deletes the device + updated = device_registry.async_update_device( + entry.id, + remove_config_entry_id=config_entry.entry_id, + remove_config_subentry_id="mock-subentry-id-1", ) - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_2.entry_id, - add_config_subentry_id="mock-subentry-id-2-1", - ) - assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - } - - entry = device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_3.entry_id, - add_config_subentry_id=None, - ) - assert entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - # Try to add a subentry without specifying entry - with pytest.raises( - HomeAssistantError, - match="Can't add config subentry without specifying config entry", - ): - device_registry.async_update_device(entry_id, add_config_subentry_id="blabla") - - # Try to add an unknown subentry - with pytest.raises( - HomeAssistantError, - match=f"Config entry {config_entry_3.entry_id} has no subentry blabla", - ): - device_registry.async_update_device( - entry_id, - add_config_entry_id=config_entry_3.entry_id, - add_config_subentry_id="blabla", - ) - - # Try to remove a subentry without specifying entry - with pytest.raises( - HomeAssistantError, - match="Can't remove config subentry without specifying config entry", - ): - device_registry.async_update_device( - entry_id, remove_config_subentry_id="blabla" - ) - - assert len(device_registry.devices) == 1 - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-1", - ) - assert entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - } - assert entry.config_entries_subentries == { - config_entry_1.entry_id: {"mock-subentry-id-1-2"}, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - # Try removing the same subentry again - assert ( - device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-1", - ) - is entry - ) - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1-2", - ) - assert entry.config_entries == {config_entry_2.entry_id, config_entry_3.entry_id} - assert entry.config_entries_subentries == { - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - } - - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_2.entry_id, - remove_config_subentry_id="mock-subentry-id-2-1", - ) - assert entry.config_entries == {config_entry_3.entry_id} - assert entry.config_entries_subentries == { - config_entry_3.entry_id: {None}, - } - - entry_before_remove = entry - entry = device_registry.async_update_device( - entry_id, - remove_config_entry_id=config_entry_3.entry_id, - remove_config_subentry_id=None, - ) - assert entry is None - - await hass.async_block_till_done() - - assert len(update_events) == 8 - 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_subentries": { - config_entry_1.entry_id: {"mock-subentry-id-1-1"} - }, - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_1.entry_id}, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - } - }, - }, - } - assert update_events[3].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: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - }, - }, - } - assert update_events[4].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-1", - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[5].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": { - config_entry_1.entry_id, - config_entry_2.entry_id, - config_entry_3.entry_id, - }, - "config_entries_subentries": { - config_entry_1.entry_id: { - "mock-subentry-id-1-2", - }, - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - "primary_config_entry": config_entry_1.entry_id, - }, - } - assert update_events[6].data == { - "action": "update", - "device_id": entry_id, - "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, - "config_entries_subentries": { - config_entry_2.entry_id: {"mock-subentry-id-2-1"}, - config_entry_3.entry_id: {None}, - }, - }, - } - assert update_events[7].data == { - "action": "remove", - "device_id": entry_id, - "device": entry_before_remove.dict_repr, - } + assert updated is None + assert device_registry.async_get(entry.id) is None + assert len(device_registry.devices) == 0 @pytest.mark.parametrize( @@ -3452,202 +4324,85 @@ async def test_update_suggested_area( @pytest.mark.parametrize( - ( - "new_config_entry_disabled_by", - "device_disabled_by_initial", - "device_disabled_by_updated", - "extra_changes", - ), + "device_disabled_by", [ - ( - 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, - {}, - ), + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.INTEGRATION, + 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], + device_disabled_by: dr.DeviceEntryDisabler | None, ) -> None: - """Check how the disabled_by flag is treated when adding a config entry.""" + """Check how the disabled_by flag is treated when adding a config entry. + + A device is now owned by a single config entry: add_config_entry_id only records a + transient pending move (completed by a subsequent remove of the current owner), so on + its own it leaves the device - including its disabled_by flag - unchanged. + """ 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 = MockConfigEntry(title=None) 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, + disabled_by=device_disabled_by, ) - assert entry.disabled_by == device_disabled_by_initial + assert entry.disabled_by == device_disabled_by 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, - ) + # The device is unchanged: still owned by config_entry_1, same disabled_by + assert entry2.config_entry_id == config_entry_1.entry_id + assert entry2.config_subentry_id is None + assert entry2.disabled_by == device_disabled_by await hass.async_block_till_done() - assert len(update_events) == 2 + # The pending move is never stored, so no update event is fired + assert len(update_events) == 1 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", - ), + ("device_disabled_by", "expected_disabled_by"), [ - # 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, - {}, - ), + # An enabled device moved onto a disabled entry is disabled by CONFIG_ENTRY + (None, dr.DeviceEntryDisabler.CONFIG_ENTRY), + # An existing CONFIG_ENTRY / INTEGRATION / USER disable is preserved + (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), + (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), + (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], + device_disabled_by: dr.DeviceEntryDisabler | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, ) -> 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 - ) + """Check how the disabled_by flag is treated when removing a config entry. + + add_config_entry_id followed by remove_config_entry_id of the current owner moves the + device to the added config entry. The move re-evaluates disabled_by against the new + owning entry (like restoring a deleted device): an enabled device moved onto a + disabled entry becomes CONFIG_ENTRY-disabled, while a USER/INTEGRATION disable - or an + existing CONFIG_ENTRY disable - is kept. + """ + config_entry_1 = MockConfigEntry(title=None) config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry( title=None, disabled_by=config_entries.ConfigEntryDisabler.USER @@ -3658,57 +4413,525 @@ async def test_update_remove_config_entry_disabled_by( 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, + disabled_by=device_disabled_by, ) - assert entry.disabled_by == device_disabled_by_initial + assert entry.disabled_by == device_disabled_by - entry2 = device_registry.async_update_device( + # add records a pending move, remove of the current owner performs it + 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, - ) + # The device moved to config_entry_2, disabled_by reflecting the new entry + assert entry3 is not None + assert entry3.config_entry_id == config_entry_2.entry_id + assert entry3.config_subentry_id is None + assert entry3.disabled_by == expected_disabled_by await hass.async_block_till_done() - assert len(update_events) == 3 + # create + the move update (the add on its own does not fire an event) + assert len(update_events) == 2 assert update_events[0].data == { "action": "create", "device_id": entry.id, } + expected_changes: dict[str, Any] = {"config_entry_id": config_entry_1.entry_id} + if expected_disabled_by != device_disabled_by: + expected_changes["disabled_by"] = device_disabled_by 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}}, + "changes": expected_changes, + } + + +async def test_move_to_enabled_config_entry_clears_config_entry_disable( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device to an enabled config entry clears a CONFIG_ENTRY disable. + + The reverse of moving onto a disabled entry; a USER disable is preserved. + """ + disabled_entry = MockConfigEntry( + disabled_by=config_entries.ConfigEntryDisabler.USER + ) + disabled_entry.add_to_hass(hass) + enabled_entry = MockConfigEntry() + enabled_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=disabled_entry.entry_id, + identifiers={("test", "1")}, + disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY, + ) + device_registry.async_update_device( + device.id, add_config_entry_id=enabled_entry.entry_id + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=disabled_entry.entry_id + ) + assert moved is not None + assert moved.config_entry_id == enabled_entry.entry_id + assert moved.disabled_by is None + + user_device = device_registry.async_get_or_create( + config_entry_id=disabled_entry.entry_id, + identifiers={("test", "2")}, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + device_registry.async_update_device( + user_device.id, add_config_entry_id=enabled_entry.entry_id + ) + moved_user = device_registry.async_update_device( + user_device.id, remove_config_entry_id=disabled_entry.entry_id + ) + assert moved_user is not None + assert moved_user.disabled_by is dr.DeviceEntryDisabler.USER + + +async def test_move_to_config_entry_with_colliding_identity_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Moving a device onto a config entry that already has its identity raises. + + Identifiers and connections are unique per config entry, so a move must validate the + device's retained identity against the target entry instead of silently overwriting + the existing device's index slot. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + device_a.id, new_config_entry_id=entry_2.entry_id + ) + + mac = (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef") + device_c = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, connections={mac} + ) + device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, connections={mac} + ) + with pytest.raises(dr.DeviceConnectionCollisionError): + device_registry.async_update_device( + device_c.id, new_config_entry_id=entry_2.entry_id + ) + + +async def test_add_identifier_keeps_other_config_entry_deleted_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Adding an identifier does not delete a matching deleted device of another entry. + + Deleted devices are per config entry now, so a device in entry A merging an + identifier must not wipe entry B's deleted-device metadata (its restore data). + """ + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry() + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "a")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + device_registry.async_update_device(device_b.id, name_by_user="Custom B") + device_b_id = device_b.id + device_registry.async_remove_device(device_b.id) + + # entry A's device merges the identifier entry B's deleted device also has + device_registry.async_update_device( + device_a.id, merge_identifiers={("test", "shared")} + ) + + # entry B's deleted device survives, so re-registering restores its id and metadata + restored_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "shared")} + ) + assert restored_b.id == device_b_id + assert restored_b.name_by_user == "Custom B" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_remaps_via_device_id_to_split( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A child's via_device_id is remapped to a live parent split. + + To the split in the child's own config entry when the parent spanned it, otherwise to + one of the parent's splits - never left dangling on the removed composite id. + """ + entry_a = MockConfigEntry(domain="dom_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="dom_b") + entry_b.add_to_hass(hass) + entry_c = MockConfigEntry(domain="dom_c") + entry_c.add_to_hass(hass) + + def _device(id_: str, entries: list[str], identifiers, via: str | None) -> dict: + return { + "area_id": None, + "config_entries": entries, + "config_entries_subentries": {entry: [None] for entry in entries}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": id_, + "identifiers": identifiers, + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entries[0], + "serial_number": None, + "sw_version": None, + "via_device_id": via, + } + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + _device( + "parent000000000000000000000000", + [entry_a.entry_id, entry_b.entry_id], + [["dom_a", "p"], ["dom_b", "p"]], + None, + ), + _device( + "child0000000000000000000000000", + [entry_a.entry_id], + [["dom_a", "c"]], + "parent000000000000000000000000", + ), + # child in a config entry the parent does not span + _device( + "childc000000000000000000000000", + [entry_c.entry_id], + [["dom_c", "c"]], + "parent000000000000000000000000", + ), + ], + "deleted_devices": [], }, } - 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, + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + # The parent splits (fresh split ids, not the old composite id) + parent_a = registry.async_get_device(identifiers={("dom_a", "p")}) + parent_b = registry.async_get_device(identifiers={("dom_b", "p")}) + assert parent_a is not None + assert parent_b is not None + assert parent_a.config_entry_id == entry_a.entry_id + assert parent_a.id != "parent000000000000000000000000" + + # The child in entry_a points at the parent's entry_a split + child = registry.async_get_device(identifiers={("dom_a", "c")}) + assert child is not None + assert child.via_device_id == parent_a.id + + # The child in entry_c, which the parent did not span, points at one of the parent's + # splits rather than the removed composite id + child_c = registry.async_get_device(identifiers={("dom_c", "c")}) + assert child_c is not None + assert child_c.via_device_id in {parent_a.id, parent_b.id} + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.parametrize( + ("composite_disabled_by", "expected_split_enabled", "expected_split_disabled"), + [ + pytest.param( + None, None, dr.DeviceEntryDisabler.CONFIG_ENTRY, id="enabled_composite" + ), + pytest.param( + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + dr.DeviceEntryDisabler.USER, + id="user_disabled", + ), + pytest.param( + dr.DeviceEntryDisabler.CONFIG_ENTRY, + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + id="config_entry_disabled", + ), + ], +) +async def test_migration_split_disabled_by_follows_config_entry( + hass: HomeAssistant, + hass_storage: dict[str, Any], + composite_disabled_by: dr.DeviceEntryDisabler | None, + expected_split_enabled: dr.DeviceEntryDisabler | None, + expected_split_disabled: dr.DeviceEntryDisabler, +) -> None: + """A split's disabled_by follows its single owning config entry's disabled state. + + A composite spanning an enabled and a disabled config entry copies its disabled_by to + both splits; each split is then reconciled against its own entry - the split owned by + the disabled entry becomes CONFIG_ENTRY disabled (a USER disable is preserved), while + the split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared. + """ + entry_enabled = MockConfigEntry(domain="dom_a") + entry_enabled.add_to_hass(hass) + entry_disabled = MockConfigEntry( + domain="dom_b", disabled_by=config_entries.ConfigEntryDisabler.USER + ) + entry_disabled.add_to_hass(hass) + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [ + entry_enabled.entry_id, + entry_disabled.entry_id, + ], + "config_entries_subentries": { + entry_enabled.entry_id: [None], + entry_disabled.entry_id: [None], + }, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": composite_disabled_by, + "entry_type": None, + "hw_version": None, + "id": "composite00000000000000000000", + "identifiers": [["dom_a", "x"], ["dom_b", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_enabled.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + split_enabled = registry.async_get_device(identifiers={("dom_a", "x")}) + split_disabled = registry.async_get_device(identifiers={("dom_b", "x")}) + assert split_enabled is not None + assert split_disabled is not None + assert split_enabled.config_entry_id == entry_enabled.entry_id + assert split_disabled.config_entry_id == entry_disabled.entry_id + # The split owned by the enabled entry has a stale CONFIG_ENTRY disable cleared + assert split_enabled.disabled_by is expected_split_enabled + # The split owned by the disabled entry follows that entry (USER preserved) + assert split_disabled.disabled_by is expected_split_disabled + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_disabled_by_not_reconciled_without_composite_split( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """disabled_by is reconciled only for split composites, not other migrated devices. + + A 1.12 -> 1.13 migration that splits no composite does not touch a device whose stored + disabled_by does not match its config entry. + """ + entry = MockConfigEntry( + domain="dom_a", disabled_by=config_entries.ConfigEntryDisabler.USER + ) + entry.add_to_hass(hass) + + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "device000000000000000000000000", + "identifiers": [["dom_a", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + device = registry.async_get_device(identifiers={("dom_a", "x")}) + assert device is not None + # The reconcile is gated on a composite split, so disabled_by is left as stored + assert device.disabled_by is None + + +@pytest.mark.parametrize("config_entry_disabled", [False, True]) +@pytest.mark.parametrize( + "initial_disabled_by", + [ + None, + dr.DeviceEntryDisabler.CONFIG_ENTRY, + dr.DeviceEntryDisabler.INTEGRATION, + dr.DeviceEntryDisabler.USER, + ], +) +async def test_migrate_device_disabled_by_matches_runtime( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + initial_disabled_by: dr.DeviceEntryDisabler | None, + config_entry_disabled: bool, +) -> None: + """The migration dict reconcile matches async_config_entry_disabled_by_changed. + + _migrate_device_disabled_by reimplements the runtime helper on stored data, so for + every combination of device disabled_by and config entry state both must agree. + """ + config_entry = MockConfigEntry( + disabled_by=config_entries.ConfigEntryDisabler.USER + if config_entry_disabled + else None + ) + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "1")} + ) + # Explicit disabled_by bypasses async_update_device's own reconciliation + device_registry.async_update_device(device.id, disabled_by=initial_disabled_by) + + # Runtime helper on the loaded registry + dr.async_config_entry_disabled_by_changed(device_registry, config_entry) + runtime_result = device_registry.async_get(device.id).disabled_by + + # Migration helper on the stored representation + stored = {"disabled_by": initial_disabled_by} + dr._migrate_device_disabled_by(stored, config_entry_disabled) + + assert stored["disabled_by"] == runtime_result + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_composite_lineage_not_restored_after_remove( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A migrated split loses its composite lineage once removed. + + The deleted device does not carry composite data, so re-registering the split makes a + plain device that no longer resolves from the pre-migration composite id. + """ + entry_a = MockConfigEntry(domain="dom_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="dom_b") + entry_b.add_to_hass(hass) + + old_id = "composite00000000000000000000" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 12, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.entry_id: [None], + }, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": old_id, + "identifiers": [["dom_a", "x"], ["dom_b", "x"]], + "labels": [], + "manufacturer": None, + "model": None, + "name": None, + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": entry_a.entry_id, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + registry = dr.async_get(hass) + + split_a = registry.async_get_device(identifiers={("dom_a", "x")}) + assert split_a is not None + assert split_a.composite_device_id == old_id + + # Remove the split; the deleted device does not carry the composite lineage + registry.async_remove_device(split_a.id) + + # Re-registering reuses the deleted device's id but drops the composite lineage + restored = registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("dom_a", "x")} + ) + assert restored.id == split_a.id + assert restored.composite_device_id is None + assert restored not in registry.async_get_devices_for_composite_device_id(old_id) async def test_cleanup_device_registry( @@ -3900,8 +5123,8 @@ async def test_restore_device( ) assert entry2 == dr.DeviceEntry( area_id=None, - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + config_entry_id=entry_id, + config_subentry_id=None, configuration_url=None, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:cd:ef:12")}, created_at=utcnow(), @@ -3917,7 +5140,6 @@ async def test_restore_device( modified_at=utcnow(), name_by_user=None, name=None, - primary_config_entry=entry_id, serial_number=None, sw_version=None, ) @@ -3942,8 +5164,8 @@ async def test_restore_device( ) assert entry3 == dr.DeviceEntry( area_id=initial_area, - config_entries={entry_id}, - config_entries_subentries={entry_id: {subentry_id}}, + config_entry_id=entry_id, + config_subentry_id=subentry_id, configuration_url="http://config_url_new.bla", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, created_at=utcnow(), @@ -3959,7 +5181,6 @@ async def test_restore_device( modified_at=utcnow(), name_by_user="Test Friendly Name", name="name_new", - primary_config_entry=entry_id, serial_number="serial_no_new", suggested_area="suggested_area_new", sw_version="version_new", @@ -4081,8 +5302,8 @@ async def test_restore_migrated_device_disabled_by( ) assert entry3 == dr.DeviceEntry( area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + 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")}, created_at=utcnow(), @@ -4098,7 +5319,6 @@ async def test_restore_migrated_device_disabled_by( 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", @@ -4249,8 +5469,8 @@ async def test_restore_disabled_by( ) assert entry3 == dr.DeviceEntry( area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, + 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")}, created_at=utcnow(), @@ -4266,7 +5486,6 @@ async def test_restore_disabled_by( 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", @@ -4298,353 +5517,6 @@ async def test_restore_disabled_by( } -@pytest.mark.usefixtures("freezer") -async def test_restore_shared_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Make sure device id is stable for shared devices.""" - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - config_entry_1 = MockConfigEntry( - subentries_data=( - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-1-1", - subentry_type="test", - title="Mock title", - unique_id="test", - ), - ), - ) - config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry() - config_entry_2.add_to_hass(hass) - - entry = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_orig_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_orig_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_orig_1", - model="model_orig_1", - model_id="model_id_orig_1", - name="name_orig_1", - serial_number="serial_no_orig_1", - suggested_area="suggested_area_orig_1", - sw_version="version_orig_1", - via_device="via_device_id_orig_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - # Add another config entry to the same device - device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - configuration_url="http://config_url_orig_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_orig_2", - identifiers={("entry_234", "2345")}, - manufacturer="manufacturer_orig_2", - model="model_orig_2", - model_id="model_id_orig_2", - name="name_orig_2", - serial_number="serial_no_orig_2", - suggested_area="suggested_area_orig_2", - sw_version="version_orig_2", - via_device="via_device_id_orig_2", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - # Apply user customizations - updated_device = device_registry.async_update_device( - entry.id, - area_id="12345A", - disabled_by=dr.DeviceEntryDisabler.USER, - labels={"label1", "label2"}, - name_by_user="Test Friendly Name", - ) - - # Check device entry before we remove it - assert updated_device == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {"mock-subentry-id-1-1"}, - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_orig_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_orig_2", - id=entry.id, - identifiers={("entry_123", "0123"), ("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_orig_2", - model="model_orig_2", - model_id="model_id_orig_2", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_orig_2", - primary_config_entry=config_entry_1.entry_id, - serial_number="serial_no_orig_2", - suggested_area="suggested_area_orig_2", - sw_version="version_orig_2", - ) - - device_registry.async_remove_device(entry.id) - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - # config_entry_1 restores the original device, only the supplied config entry, - # config subentry, connections, and identifiers will be restored, user - # customizations of area_id, disabled_by, labels and name_by_user will be restored. - entry2 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - name="name_new_1", - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - via_device="via_device_id_new_1", - ) - - assert entry2 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id}, - config_entries_subentries={config_entry_1.entry_id: {"mock-subentry-id-1-1"}}, - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - id=entry.id, - identifiers={("entry_123", "0123")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_1", - primary_config_entry=config_entry_1.entry_id, - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry2.config_entries, set) - assert isinstance(entry2.connections, set) - assert isinstance(entry2.identifiers, set) - - # Remove the device again - device_registry.async_remove_device(entry.id) - - # config_entry_2 restores the original device, only the supplied config entry, - # config subentry, connections, and identifiers will be restored, 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=config_entry_2.entry_id, - configuration_url="http://config_url_new_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=None, - hw_version="hw_version_new_2", - identifiers={("entry_234", "2345")}, - manufacturer="manufacturer_new_2", - model="model_new_2", - model_id="model_id_new_2", - name="name_new_2", - serial_number="serial_no_new_2", - suggested_area="suggested_area_new_2", - sw_version="version_new_2", - via_device="via_device_id_new_2", - ) - - assert entry3 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_new_2.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=None, - hw_version="hw_version_new_2", - id=entry.id, - identifiers={("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_2", - model="model_new_2", - model_id="model_id_new_2", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_2", - primary_config_entry=config_entry_2.entry_id, - serial_number="serial_no_new_2", - suggested_area="suggested_area_new_2", - sw_version="version_new_2", - ) - - 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) - - # Add config_entry_1 back to the restored device - entry4 = device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1-1", - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - identifiers={("entry_123", "0123")}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - name="name_new_1", - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - via_device="via_device_id_new_1", - ) - - assert entry4 == dr.DeviceEntry( - area_id="12345A", - config_entries={config_entry_1.entry_id, config_entry_2.entry_id}, - config_entries_subentries={ - config_entry_1.entry_id: {"mock-subentry-id-1-1"}, - config_entry_2.entry_id: {None}, - }, - configuration_url="http://config_url_new_1.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=dr.DeviceEntryDisabler.USER, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_new_1", - id=entry.id, - identifiers={("entry_123", "0123"), ("entry_234", "2345")}, - labels={"label1", "label2"}, - manufacturer="manufacturer_new_1", - model="model_new_1", - model_id="model_id_new_1", - modified_at=utcnow(), - name_by_user="Test Friendly Name", - name="name_new_1", - primary_config_entry=config_entry_2.entry_id, - serial_number="serial_no_new_1", - suggested_area="suggested_area_new_1", - sw_version="version_new_1", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry4.config_entries, set) - assert isinstance(entry4.connections, set) - assert isinstance(entry4.identifiers, set) - - await hass.async_block_till_done() - - assert len(update_events) == 8 - 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: {"mock-subentry-id-1-1"} - }, - "configuration_url": "http://config_url_orig_1.bla", - "entry_type": dr.DeviceEntryType.SERVICE, - "hw_version": "hw_version_orig_1", - "identifiers": {("entry_123", "0123")}, - "manufacturer": "manufacturer_orig_1", - "model": "model_orig_1", - "model_id": "model_id_orig_1", - "name": "name_orig_1", - "serial_number": "serial_no_orig_1", - "suggested_area": "suggested_area_orig_1", - "sw_version": "version_orig_1", - }, - } - assert update_events[2].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "area_id": "suggested_area_orig_1", - "disabled_by": None, - "labels": set(), - "name_by_user": None, - }, - } - assert update_events[3].data == { - "action": "remove", - "device_id": entry.id, - "device": updated_device.dict_repr, - } - assert update_events[4].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[5].data == { - "action": "remove", - "device_id": entry.id, - "device": entry2.dict_repr, - } - assert update_events[6].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[7].data == { - "action": "update", - "device_id": entry.id, - "changes": { - "config_entries": {config_entry_2.entry_id}, - "config_entries_subentries": {config_entry_2.entry_id: {None}}, - "configuration_url": "http://config_url_new_2.bla", - "entry_type": None, - "hw_version": "hw_version_new_2", - "identifiers": {("entry_234", "2345")}, - "manufacturer": "manufacturer_new_2", - "model": "model_new_2", - "model_id": "model_id_new_2", - "name": "name_new_2", - "serial_number": "serial_no_new_2", - "suggested_area": "suggested_area_new_2", - "sw_version": "version_new_2", - }, - } - - async def test_get_or_create_empty_then_set_default_values( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, @@ -4823,50 +5695,6 @@ async def test_disable_config_entry_disables_devices( assert entry2.disabled_by is dr.DeviceEntryDisabler.USER -async def test_only_disable_device_if_all_config_entries_are_disabled( - hass: HomeAssistant, device_registry: dr.DeviceRegistry -) -> None: - """Test that we only disable device if all related config entries are disabled.""" - config_entry1 = MockConfigEntry(domain="light") - config_entry1.add_to_hass(hass) - config_entry2 = MockConfigEntry(domain="light") - config_entry2.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=config_entry1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entry1 = device_registry.async_get_or_create( - config_entry_id=config_entry2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert len(entry1.config_entries) == 2 - assert not entry1.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry1.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled - - await hass.config_entries.async_set_disabled_by( - config_entry2.entry_id, config_entries.ConfigEntryDisabler.USER - ) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert entry1.disabled - assert entry1.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY - - await hass.config_entries.async_set_disabled_by(config_entry1.entry_id, None) - await hass.async_block_till_done() - - entry1 = device_registry.async_get(entry1.id) - assert not entry1.disabled - - @pytest.mark.parametrize( ("configuration_url", "expectation"), [ @@ -4999,8 +5827,14 @@ async def test_loading_invalid_configuration_url_from_storage( "devices": [ { "area_id": None, - "config_entries": ["1234"], - "config_entries_subentries": {"1234": [None]}, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "config_entry_id": mock_config_entry.entry_id, + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": "invalid", "connections": [], "created_at": "2024-01-01T00:00:00+00:00", @@ -5016,7 +5850,7 @@ async def test_loading_invalid_configuration_url_from_storage( "modified_at": "2024-02-01T00:00:00+00:00", "name_by_user": None, "name": None, - "primary_config_entry": "1234", + "primary_config_entry": mock_config_entry.entry_id, "serial_number": None, "sw_version": None, "via_device_id": None, @@ -5619,78 +6453,6 @@ async def test_device_registry_deleted_device_collision( assert len(device_registry.deleted_devices) == 0 -async def test_primary_config_entry( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, -) -> None: - """Test the primary integration field.""" - mock_config_entry_1 = MockConfigEntry(domain="mqtt", title=None) - mock_config_entry_1.add_to_hass(hass) - mock_config_entry_2 = MockConfigEntry(title=None) - mock_config_entry_2.add_to_hass(hass) - mock_config_entry_3 = MockConfigEntry(title=None) - mock_config_entry_3.add_to_hass(hass) - mock_config_entry_4 = MockConfigEntry(domain="matter", title=None) - mock_config_entry_4.add_to_hass(hass) - - # Create device without model name etc, config entry will not be marked primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers=set(), - ) - assert device.primary_config_entry is None - - # Set model, mqtt config entry will be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model", - ) - assert device.primary_config_entry == mock_config_entry_1.entry_id - - # New config entry with model will be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 2", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # New config entry with model will not be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_3.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 3", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # New matter config entry with model will not be promoted to primary - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_4.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - model="model 3", - ) - assert device.primary_config_entry == mock_config_entry_2.entry_id - - # Remove the primary config entry - device = device_registry.async_update_device( - device.id, - remove_config_entry_id=mock_config_entry_2.entry_id, - ) - assert device.primary_config_entry is None - - # Create new - device = device_registry.async_get_or_create( - config_entry_id=mock_config_entry_1.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers=set(), - manufacturer="manufacturer", - model="model", - ) - assert device.primary_config_entry == mock_config_entry_1.entry_id - - async def test_update_device_no_connections_or_identifiers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -5713,7 +6475,10 @@ async def test_update_device_no_connections_or_identifiers( async def test_connections_validator() -> None: """Test checking connections validator.""" with pytest.raises(ValueError, match="Invalid mac address format"): - dr.DeviceEntry(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}) + dr.DeviceEntry( + config_entry_id="mock-config-entry", + connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}, + ) async def test_suggested_area_deprecation( @@ -5755,3 +6520,946 @@ async def test_suggested_area_deprecation( "device. This will stop working in Home Assistant 2026.9.0, please report " "this issue" ) in caplog.text + + +COMPOSITE_ID = "composite0000000000000000000000" + + +def _composite_device_storage( + entry_a: MockConfigEntry, entry_b: MockConfigEntry +) -> dict[str, Any]: + """Return a v1.10 device registry store with one composite device.""" + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.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": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + +async def test_single_config_entry_and_compat_properties( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A device has a single config entry; the deprecated shims reflect it.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + assert device.config_entry_id == entry.entry_id + assert device.config_subentry_id is None + assert device.config_entries == {entry.entry_id} + assert device.config_entries_subentries == {entry.entry_id: {None}} + assert device.primary_config_entry == entry.entry_id + + +async def test_identifiers_unique_per_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """The same identifier under two config entries yields two devices.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + + device_a = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("shared", "1")} + ) + device_b = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("shared", "1")} + ) + + assert device_a.id != device_b.id + + # Scoped lookup returns the owning device + assert ( + _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("shared", "1")} + ).id + == device_a.id + ) + assert ( + _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("shared", "1")} + ).id + == device_b.id + ) + + +async def test_collision_only_within_same_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A collision is raised only for two devices of the same config entry.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + other = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "2")} + ) + + with pytest.raises(dr.DeviceIdentifierCollisionError): + device_registry.async_update_device( + other.id, merge_identifiers={("domain_a", "1")} + ) + assert device_registry.async_get(device.id) is not None + + +async def test_remove_shadowed_collision_keeps_index_consistent( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Removing a device that shadows a same-entry collision keeps the index consistent. + + allow_collisions lets a device absorb an identifier another device of the same config + entry holds, shadowing it in the index. When a second config entry also shares that + identifier, removing the shadowed device then the indexed one must not delete the wrong + slot or raise KeyError on the mapping the second entry keeps. + """ + entry_a = MockConfigEntry(domain="test") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "1")} + ) + shadowed = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("test", "2")} + ) + # The second config entry keeps its own slot for the shared identifier + other_entry_device = device_registry.async_get_or_create( + config_entry_id=entry_b.entry_id, identifiers={("test", "2")} + ) + # allow_collisions lets `device` absorb the shadowed device's identifier + device_registry._async_update_device( + device.id, merge_identifiers={("test", "2")}, allow_collisions=True + ) + assert device_registry.async_get(device.id).identifiers == { + ("test", "1"), + ("test", "2"), + } + assert shadowed.id in device_registry.devices + + # Remove the shadowed device, then the indexed one - neither must raise + device_registry.async_remove_device(shadowed.id) + device_registry.async_remove_device(device.id) + + # The second config entry's device is still reachable by the shared identifier + assert ( + device_registry.async_get_device(identifiers={("test", "2")}) + is other_entry_device + ) + + +@pytest.mark.parametrize( + ("identity", "merge_kwarg", "merge_extra", "error"), + [ + pytest.param( + {"identifiers": {("test", "shared")}}, + "merge_identifiers", + {("test", "extra")}, + dr.DeviceIdentifierCollisionError, + id="identifiers", + ), + pytest.param( + {"connections": {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}}, + "merge_connections", + {(dr.CONNECTION_NETWORK_MAC, "ab:cd:ef:12:34:56")}, + dr.DeviceConnectionCollisionError, + id="connections", + ), + ], +) +async def test_move_with_merge_validates_retained_identity( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + identity: dict[str, set[tuple[str, str]]], + merge_kwarg: str, + merge_extra: set[tuple[str, str]], + error: type[Exception], +) -> None: + """A move that also merges must validate the retained identity against the target. + + The merged additions are validated, but the retained old identity must be too, or the + move silently overwrites the target entry's index slot for a device already there. + """ + entry_a = MockConfigEntry(domain="test") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="test") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, **identity + ) + # entry_b already owns a device with the same identity + device_registry.async_get_or_create(config_entry_id=entry_b.entry_id, **identity) + + # Moving device to entry_b retains its identity, which collides with entry_b's + # existing device, so the move must raise rather than silently shadow it. + with pytest.raises(error): + device_registry.async_update_device( + device.id, + new_config_entry_id=entry_b.entry_id, + **{merge_kwarg: merge_extra}, + ) + + +async def test_move_two_calls_add_then_remove( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test add_config_entry_id records a pending move; the later remove performs it.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + # add alone does nothing yet + device_registry.async_update_device(device.id, add_config_entry_id=entry_b.entry_id) + assert device_registry.async_get(device.id).config_entry_id == entry_a.entry_id + + # remove of the current owner performs the pending move + device_registry.async_update_device( + device.id, remove_config_entry_id=entry_a.entry_id + ) + moved = device_registry.async_get(device.id) + assert moved is not None + assert moved.config_entry_id == entry_b.entry_id + + +async def test_move_new_config_entry_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test new_config_entry_id moves the device immediately.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + device_registry.async_update_device(device.id, new_config_entry_id=entry_b.entry_id) + assert device_registry.async_get(device.id).config_entry_id == entry_b.entry_id + + +async def test_move_new_and_add_raises( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test mixing new_config_entry_id with add/remove raises.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + + with pytest.raises(HomeAssistantError, match="Can't combine"): + device_registry.async_update_device( + device.id, + new_config_entry_id=entry_b.entry_id, + add_config_entry_id=entry_b.entry_id, + ) + + +async def test_async_get_or_create_unknown_config_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test async_get_or_create raises for an unknown config entry.""" + with pytest.raises( + HomeAssistantError, + match="Can't link device to unknown config entry unknown-config-entry", + ): + device_registry.async_get_or_create( + config_entry_id="unknown-config-entry", identifiers={("bridgeid", "0123")} + ) + + +@pytest.mark.parametrize( + ("make_update_kwargs", "error_match"), + [ + pytest.param( + lambda entry: {"add_config_entry_id": "unknown-config-entry"}, + "Can't link device to unknown config entry unknown-config-entry", + id="add-unknown-config-entry", + ), + pytest.param( + lambda entry: {"add_config_subentry_id": "mock-subentry-id-2"}, + "Can't add config subentry without specifying config entry", + id="add-subentry-without-config-entry", + ), + pytest.param( + lambda entry: { + "add_config_entry_id": entry.entry_id, + "add_config_subentry_id": "unknown-subentry", + }, + "has no subentry unknown-subentry", + id="add-unknown-subentry", + ), + pytest.param( + lambda entry: {"remove_config_subentry_id": "mock-subentry-id-1"}, + "Can't remove config subentry without specifying config entry", + id="remove-subentry-without-config-entry", + ), + pytest.param( + lambda entry: {"new_config_entry_id": "unknown-config-entry"}, + "Can't move device to unknown config entry unknown-config-entry", + id="new-unknown-config-entry", + ), + pytest.param( + lambda entry: {"new_config_subentry_id": "unknown-subentry"}, + "has no subentry unknown-subentry", + id="new-unknown-subentry", + ), + pytest.param( + lambda entry: { + "new_config_entry_id": entry.entry_id, + "add_config_entry_id": entry.entry_id, + }, + "Can't combine new_config_entry_id or new_config_subentry_id", + id="combine-new-and-add", + ), + ], +) +async def test_update_device_config_entry_grammar_errors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + make_update_kwargs: Callable[[MockConfigEntry], dict[str, Any]], + error_match: str, +) -> None: + """The config-entry/subentry mutation grammar validates its arguments.""" + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("bridgeid", "0123")}, + ) + + with pytest.raises(HomeAssistantError, match=error_match): + device_registry.async_update_device(device.id, **make_update_kwargs(entry)) + + +async def test_move_device_to_config_subentry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A device can be moved to another subentry of its config entry. + + Immediately via new_config_subentry_id, or deferred via a pending move + (add_config_entry_id + add_config_subentry_id, completed by removing the current + owner). There is no subentry-only deferred move - add_config_subentry_id and + remove_config_subentry_id without a config entry raise (see + test_update_device_config_entry_grammar_errors). + """ + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + config_subentry_id="mock-subentry-id-1", + identifiers={("bridgeid", "0123")}, + ) + + # new_config_subentry_id moves the device immediately + moved = device_registry.async_update_device( + device.id, new_config_subentry_id="mock-subentry-id-2" + ) + assert moved.config_entry_id == entry.entry_id + assert moved.config_subentry_id == "mock-subentry-id-2" + + # Deferred move: adding the (same) config entry with the target subentry records a + # pending move; it does not move the device on its own + device_registry.async_update_device( + device.id, + add_config_entry_id=entry.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + assert ( + device_registry.async_get(device.id).config_subentry_id == "mock-subentry-id-2" + ) + # Removing the current owner performs the pending move to the target subentry + moved_back = device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + assert moved_back is not None + assert moved_back.config_subentry_id == "mock-subentry-id-1" + + +async def test_move_device_to_config_entry_and_subentry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A deferred move can target another config entry and one of its subentries.""" + entry_a = MockConfigEntry() + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-b", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_b.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("bridgeid", "0123")} + ) + + # The pending move carries the (config entry, subentry) pair + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_b.entry_id, + add_config_subentry_id="mock-subentry-id-b", + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_a.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_b.entry_id + assert moved.config_subentry_id == "mock-subentry-id-b" + + +async def test_pending_move_overwritten_by_later_add( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A later add_config_entry_id / add_config_subentry_id overwrites the pending move.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Each add records a pending move, overwriting the previous one: first a subentry ... + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + ) + # ... a later add to the same entry overwrites just the subentry ... + device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-2", + ) + # ... a later add to a different entry overwrites the entry (subentry resets to None) + device_registry.async_update_device(device.id, add_config_entry_id=entry_3.entry_id) + + # None of the adds moved the device + assert device_registry.async_get(device.id).config_entry_id == entry_1.entry_id + + # Removing the owner performs the last recorded pending move + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_3.entry_id + assert moved.config_subentry_id is None + + +async def test_new_config_entry_id_clears_pending_move( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """An immediate new_config_entry_id move clears an earlier pending move. + + Otherwise removing the new owner would perform the stale deferred move instead of + deleting the device, which has no other config entry. + """ + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Record a pending move to entry_2, then immediately move the device to entry_3 + device_registry.async_update_device(device.id, add_config_entry_id=entry_2.entry_id) + device_registry.async_update_device(device.id, new_config_entry_id=entry_3.entry_id) + assert device_registry.async_get(device.id)._pending_move is None + + # Removing the new owner deletes the device rather than performing the stale move + assert ( + device_registry.async_update_device( + device.id, remove_config_entry_id=entry_3.entry_id + ) + is None + ) + assert device_registry.async_get(device.id) is None + + +async def test_pending_move_canceled_by_cross_domain_removal( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A removal from a different integration than the one that armed the move cancels it. + + Otherwise an incidental add_config_entry_id (e.g. device_tracker attaching a shared + MAC) would hijack the owning integration's later cleanup and move the device instead + of deleting it. + """ + entry_owner = MockConfigEntry(domain="owner") + entry_owner.add_to_hass(hass) + entry_target = MockConfigEntry(domain="attacher") + entry_target.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_owner.entry_id, identifiers={("test", "1")} + ) + + # The "attacher" integration arms a deferred move to its own entry + with patch.object(dr, "_current_integration_domain", return_value="attacher"): + device_registry.async_update_device( + device.id, add_config_entry_id=entry_target.entry_id + ) + assert ( + device_registry.async_get(device.id)._pending_move.origin_domain == "attacher" + ) + + # The owning integration later removes its entry - a different domain, so the stale + # move is canceled and the device is deleted rather than transferred. + with patch.object(dr, "_current_integration_domain", return_value="owner"): + result = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_owner.entry_id + ) + assert result is None + assert device_registry.async_get(device.id) is None + + +async def test_pending_move_completed_by_same_domain_removal( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """A removal from the same integration that armed the move completes it.""" + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "1")} + ) + + with patch.object(dr, "_current_integration_domain", return_value="mover"): + device_registry.async_update_device( + device.id, add_config_entry_id=entry_2.entry_id + ) + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + assert device_registry.async_get(device.id) is moved + + +async def test_composite_move_clears_sibling_pending_moves( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Completing one split's move clears the pending move on its composite siblings. + + Arming add_config_entry_id on a composite fans out to every split; once one split + moves to the target, the others must not also move there and collide. + """ + entry_1 = MockConfigEntry(domain="test") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="test") + entry_2.add_to_hass(hass) + entry_target = MockConfigEntry(domain="test") + entry_target.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("test", "shared")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("test", "shared")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + # Arm a deferred move on the composite id: fans out to both splits + with patch.object(dr, "_current_integration_domain", return_value="test"): + device_registry.async_update_device( + old_id, add_config_entry_id=entry_target.entry_id + ) + assert device_registry.async_get(device_1.id)._pending_move is not None + assert device_registry.async_get(device_2.id)._pending_move is not None + + # Complete the move on split 1; split 2's pending move must be cleared + with patch.object(dr, "_current_integration_domain", return_value="test"): + device_registry.async_update_device( + device_1.id, remove_config_entry_id=entry_1.entry_id + ) + assert ( + device_registry.async_get(device_1.id).config_entry_id == entry_target.entry_id + ) + assert device_registry.async_get(device_2.id)._pending_move is None + + # Split 2's own removal now deletes it instead of colliding on the shared identifier + with patch.object(dr, "_current_integration_domain", return_value="test"): + assert ( + device_registry.async_update_device( + device_2.id, remove_config_entry_id=entry_2.entry_id + ) + is None + ) + assert device_registry.async_get(device_2.id) is None + + +async def test_add_and_remove_config_entry_in_one_call( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """add_config_entry_id and remove_config_entry_id of the owner move in a single call.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ] + ) + entry_2.add_to_hass(hass) + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Adding the new entry/subentry and removing the current owner in one call moves at once + moved = device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-1", + remove_config_entry_id=entry_1.entry_id, + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + assert moved.config_subentry_id == "mock-subentry-id-1" + + await hass.async_block_till_done() + assert len(update_events) == 2 + assert update_events[1].data == { + "action": "update", + "device_id": device.id, + "changes": { + "config_entry_id": entry_1.entry_id, + "config_subentry_id": None, + }, + } + + +async def test_remove_non_owner_config_entry_keeps_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """remove_config_entry_id of a non-owning entry does not perform the pending move.""" + entry_1 = MockConfigEntry() + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry() + entry_2.add_to_hass(hass) + entry_3 = MockConfigEntry() + entry_3.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("bridgeid", "0123")} + ) + + # Add a pending move to entry_2, but remove a config entry the device does not own + result = device_registry.async_update_device( + device.id, + add_config_entry_id=entry_2.entry_id, + remove_config_entry_id=entry_3.entry_id, + ) + # The device is neither moved nor removed: only removing the owner performs the move + assert result is not None + assert result.config_entry_id == entry_1.entry_id + + # The pending move to entry_2 was still recorded; removing the owner now performs it + moved = device_registry.async_update_device( + device.id, remove_config_entry_id=entry_1.entry_id + ) + assert moved is not None + assert moved.config_entry_id == entry_2.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_reregistration_replaces_composite_identifiers( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """First re-registration replaces the copied identifiers with the provided ones.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert split_a.has_composite_identifiers is True + + reregistered = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert reregistered.id == split_a.id + assert reregistered.identifiers == {("domain_a", "1")} # domain_b copy pruned + # assert the copied composite connection is cleared + assert reregistered.connections == set() + assert reregistered.has_composite_identifiers is False + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_get_returns_restored_composite( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test async_get on the legacy id returns a merged, on-demand composite.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.id == COMPOSITE_ID + assert composite.config_entries == {entry_a.entry_id, entry_b.entry_id} + assert composite.config_entries_subentries == { + entry_a.entry_id: {None}, + entry_b.entry_id: {None}, + } + assert composite.identifiers == {("domain_a", "1"), ("domain_b", "1")} + assert composite.serial_number == "SERIAL" + + # Invisible to membership, enumeration and identifier search + assert COMPOSITE_ID not in device_registry.devices + assert COMPOSITE_ID not in {d.id for d in device_registry.devices.values()} + assert ( + device_registry.async_get_device(identifiers={("domain_a", "1")}).id + != COMPOSITE_ID + ) + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_restored_composite_preserves_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """The restored composite reports the pre-migration composite's former primary. + + The composite's primary_config_entry is recorded on each split device + (composite_primary_config_entry) so the restored composite can report it, even when + it is not the first split. + """ + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's primary is entry_b, which is not its first config entry + storage = _composite_device_storage(entry_a, entry_b) + storage["data"]["devices"][0]["primary_config_entry"] = entry_b.entry_id + hass_storage[dr.STORAGE_KEY] = storage + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + composite = device_registry.async_get(COMPOSITE_ID) + splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + + # The former primary (entry_b) is preserved, even though it is not the first split + assert composite.primary_config_entry == entry_b.entry_id + assert composite.primary_config_entry != splits[0].config_entry_id + # It is a valid member of the merged config entries + assert composite.primary_config_entry in composite.config_entries + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_clear_config_entry_clears_composite_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Clearing the composite's former primary config entry clears the dangling ref.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's former primary is entry_a + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert split_b.composite_primary_config_entry == entry_a.entry_id + + # Clearing entry_a removes its split and clears the reference on entry_b's split + device_registry.async_clear_config_entry(entry_a.entry_id) + + assert ( + _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + is None + ) + split_b = _get_device_for_config_entry( + device_registry, entry_b.entry_id, identifiers={("domain_a", "1")} + ) + assert split_b is not None + assert split_b.composite_primary_config_entry is None + + # The restored composite still works, falling back to the remaining split + composite = device_registry.async_get(COMPOSITE_ID) + assert composite is not None + assert composite.primary_config_entry == entry_b.entry_id + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_clear_non_primary_config_entry_keeps_composite_primary_config_entry( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Clearing a non-primary config entry leaves composite_primary_config_entry intact.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The composite's former primary is entry_a + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + + dr.async_setup(hass) + await dr.async_load(hass) + device_registry = dr.async_get(hass) + + # Clearing entry_b (not the former primary) removes its split but keeps the reference + device_registry.async_clear_config_entry(entry_b.entry_id) + + split_a = _get_device_for_config_entry( + device_registry, entry_a.entry_id, identifiers={("domain_a", "1")} + ) + assert split_a is not None + assert split_a.composite_primary_config_entry == entry_a.entry_id + + +async def test_dict_repr_dual_writes_deprecated_keys( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test dict_repr exposes both the new and the deprecated compatibility keys.""" + entry = MockConfigEntry(domain="domain_a") + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("domain_a", "1")} + ) + + repr_ = device.dict_repr + assert repr_["config_entry_id"] == entry.entry_id + assert repr_["config_subentry_id"] is None + assert repr_["config_entries"] == [entry.entry_id] + assert repr_["config_entries_subentries"] == {entry.entry_id: [None]} + assert repr_["primary_config_entry"] == entry.entry_id + # Internal split-migration fields are not exposed in dict_repr + assert "composite_device_id" not in repr_ + assert "composite_primary_config_entry" not in repr_ + assert "split_at" not in repr_ + assert "has_composite_identifiers" not in repr_ diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 6cc0de0fc2e2..9cbe26135226 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2837,56 +2837,3 @@ async def test_add_entity_unknown_subentry( "Can't add entities to unknown subentry unknown-subentry " "of config entry super-mock-id" ) in caplog.text - - -@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) -@pytest.mark.usefixtures("mock_integration_frame") -@pytest.mark.parametrize( - "deprecated_attribute", - [ - "component_translations", - "platform_translations", - "object_id_component_translations", - "object_id_platform_translations", - "default_language_platform_translations", - ], -) -async def test_deprecated_attributes( - hass: HomeAssistant, - deprecated_attribute: str, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test setting the device name based on input info.""" - - platform = MockPlatform() - entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) - - assert getattr(entity_platform, deprecated_attribute) is getattr( - entity_platform.platform_data, deprecated_attribute - ) - assert ( - f"The deprecated function {deprecated_attribute} was called from " - "my_integration. It will be removed in HA Core 2026.8. Use platform_data." - f"{deprecated_attribute} instead, please report it to the author of the " - "'my_integration' custom integration" in caplog.text - ) - - -@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_deprecated_async_load_translations( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test setting the device name based on input info.""" - - platform = MockPlatform() - entity_platform = MockEntityPlatform(hass, platform_name="test", platform=platform) - - await entity_platform.async_load_translations() - assert ( - "The deprecated function async_load_translations was called from " - "my_integration. It will be removed in HA Core 2026.8. Use platform_data." - "async_load_translations instead, please report it to the author of the " - "'my_integration' custom integration" in caplog.text - ) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index a24f7f4b994a..b69752d29603 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -554,6 +554,39 @@ async def test_entity_registry_loading_waits_for_device_registry( assert registry.async_get("test.my_entity") is not None +@pytest.mark.parametrize("load_registries", [False]) +async def test_entity_load_detaches_from_dropped_device( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An entity referencing a device that no longer exists is detached on load. + + The device migration drops a device with no config entry; an entity that pointed at + it must be detached rather than left on a removed device id. + """ + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "test.my_entity", + "device_id": "gone-device", + "platform": "test_platform", + "unique_id": "unique-1", + }, + ] + }, + } + + dr.async_setup(hass) + await asyncio.gather(er.async_load(hass), dr.async_load(hass)) + + registry = er.async_get(hass) + entity = registry.async_get("test.my_entity") + assert entity is not None + assert entity.device_id is None + + def test_get_available_entity_id_considers_registered_entities( entity_registry: er.EntityRegistry, ) -> None: @@ -1813,6 +1846,12 @@ async def test_migration_1_21( "area_id": None, "config_entries": ["mock_entry"], "config_entries_subentries": {"mock_entry": [None]}, + "config_entry_id": "mock_entry", + "config_subentry_id": None, + "composite_device_id": None, + "composite_primary_config_entry": None, + "split_at": None, + "has_composite_identifiers": False, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -2777,66 +2816,59 @@ async def test_remove_config_entry_from_device_removes_entities( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that we remove entities tied to a device when config entry is removed.""" + """Test that we remove entities tied to a device when its config entry is removed.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") config_entry_2.add_to_hass(hass) - # Create device with two config entries - device_registry.async_get_or_create( + # Same connections on different config entries are separate devices + device_entry_1 = 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")}, ) - device_entry = device_registry.async_get_or_create( + device_entry_2 = device_registry.async_get_or_create( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - } + assert device_entry_1.id != device_entry_2.id - # Create one entity for each config entry + # Create one entity for each device entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", config_entry=config_entry_1, - device_id=device_entry.id, + device_id=device_entry_1.id, ) - entry_2 = entity_registry.async_get_or_create( "sensor", "device_tracker", "6789", config_entry=config_entry_2, - device_id=device_entry.id, + device_id=device_entry_2.id, ) - assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the first config entry from the device, the entity associated with it - # should be removed + # Removing the first config entry removes its device and the tied entity device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_1.entry_id + device_entry_1.id, remove_config_entry_id=config_entry_1.entry_id ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) + assert not device_registry.async_get(device_entry_1.id) assert not entity_registry.async_is_registered(entry_1.entity_id) + assert device_registry.async_get(device_entry_2.id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the second config entry from the device, the entity associated with it - # (and the device itself) should be removed + # Removing the second config entry removes its device and entity too device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_2.entry_id + device_entry_2.id, remove_config_entry_id=config_entry_2.entry_id ) await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not device_registry.async_get(device_entry_2.id) assert not entity_registry.async_is_registered(entry_2.entity_id) @@ -2845,72 +2877,148 @@ async def test_remove_config_entry_from_device_removes_entities_2( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test we don't remove entities w/o config entry when device is modified.""" + """Test we don't remove entities not tied to the removed config entry.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_1.add_to_hass(hass) - config_entry_2 = MockConfigEntry(domain="device_tracker") + config_entry_2 = MockConfigEntry(domain="some_helper") config_entry_2.add_to_hass(hass) - config_entry_3 = MockConfigEntry(domain="some_helper") - config_entry_3.add_to_hass(hass) - # Create device with two config entries - device_registry.async_get_or_create( + 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")}, ) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry_2.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - assert device_entry.config_entries == { - config_entry_1.entry_id, - config_entry_2.entry_id, - } - # Create an entity without config entry + # An entity without a config entry, tied to the device entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", device_id=device_entry.id, ) - # Create an entity with a config entry not in the device + # An entity with a different config entry, tied to the device entry_2 = entity_registry.async_get_or_create( "light", "some_helper", "5678", - config_entry=config_entry_3, + config_entry=config_entry_2, device_id=device_entry.id, ) - assert entry_1.entity_id != entry_2.entity_id assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the first config entry from the device + # Removing the device's config entry removes the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - # Entities which are not tied to the removed config entry should not be removed + assert not device_registry.async_get(device_entry.id) + # Entities not tied to the removed config entry are kept, but detached assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None - # Remove the second config entry from the device (this removes the device) + +async def test_move_device_config_entry_removes_old_entry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Moving a device to another config entry removes the old entry's entities.""" + entry_a = MockConfigEntry(domain="hue") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="tado") + entry_b.add_to_hass(hass) + entry_c = MockConfigEntry(domain="some_helper") + entry_c.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=entry_a.entry_id, identifiers={("hue", "1")} + ) + # An entity owned by the departing entry A, and a helper entity of a third entry C + entry_a_entity = entity_registry.async_get_or_create( + "light", "hue", "a", config_entry=entry_a, device_id=device_entry.id + ) + entry_c_entity = entity_registry.async_get_or_create( + "sensor", "some_helper", "c", config_entry=entry_c, device_id=device_entry.id + ) + + # Move the device from entry A to entry B (an update, not a removal) device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry_2.entry_id + device_entry.id, new_config_entry_id=entry_b.entry_id ) await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) - # Entities which are not tied to a config entry in the device should not be removed - assert entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - # Check the device link is set to None - assert entity_registry.async_get(entry_1.entity_id).device_id is None - assert entity_registry.async_get(entry_2.entity_id).device_id is None + # A no longer owns the device, so A's entity is removed; C's helper is untouched + assert not entity_registry.async_is_registered(entry_a_entity.entity_id) + assert entity_registry.async_is_registered(entry_c_entity.entity_id) + + +@pytest.mark.parametrize("old_subentry_id", [None, "sub-1"]) +async def test_move_device_config_subentry_removes_old_subentry_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + old_subentry_id: str | None, +) -> None: + """Moving a device to another subentry removes the old subentry's entities. + + Includes a departing subentry of None (the main entry): the change is detected by the + old config_subentry_id being present in the event, not by its truthiness. + """ + config_entry = MockConfigEntry( + domain="hue", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="sub-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + config_subentry_id=old_subentry_id, + identifiers={("hue", "1")}, + ) + # Entity on the departing subentry, and one on the destination subentry sub-2 + old_entity = entity_registry.async_get_or_create( + "light", + "hue", + "old", + config_entry=config_entry, + config_subentry_id=old_subentry_id, + device_id=device_entry.id, + ) + sub2_entity = entity_registry.async_get_or_create( + "light", + "hue", + "2", + config_entry=config_entry, + config_subentry_id="sub-2", + device_id=device_entry.id, + ) + + # Move the device to subentry sub-2 (an update, not a removal) + device_registry.async_update_device(device_entry.id, new_config_subentry_id="sub-2") + await hass.async_block_till_done() + + # The departing subentry's entity is removed; sub-2's entity is kept + assert not entity_registry.async_is_registered(old_entity.entity_id) + assert entity_registry.async_is_registered(sub2_entity.entity_id) async def test_remove_config_subentry_from_device_removes_entities( @@ -2918,7 +3026,7 @@ async def test_remove_config_subentry_from_device_removes_entities( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test that we remove entities tied to a device when config subentry is removed.""" + """Test that we remove entities tied to a device when its config subentry is removed.""" config_entry_1 = MockConfigEntry( domain="hue", subentries_data=[ @@ -2940,27 +3048,15 @@ async def test_remove_config_subentry_from_device_removes_entities( ) config_entry_1.add_to_hass(hass) - # Create device with three config subentries - device_registry.async_get_or_create( + # A device belongs to a single config subentry + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-2", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - 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")}, - ) - assert device_entry.config_entries == {config_entry_1.entry_id} - assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, - } + assert device_entry.config_subentry_id == "mock-subentry-id-1" - # Create one entity entry for each config entry or subentry + # Entity tied to the device's subentry entry_1 = entity_registry.async_get_or_create( "light", "hue", @@ -2969,7 +3065,7 @@ async def test_remove_config_subentry_from_device_removes_entities( config_subentry_id="mock-subentry-id-1", device_id=device_entry.id, ) - + # Entity tied to a different subentry of the same config entry entry_2 = entity_registry.async_get_or_create( "light", "hue", @@ -2978,22 +3074,11 @@ async def test_remove_config_subentry_from_device_removes_entities( config_subentry_id="mock-subentry-id-2", device_id=device_entry.id, ) - - entry_3 = entity_registry.async_get_or_create( - "sensor", - "device_tracker", - "6789", - config_entry=config_entry_1, - config_subentry_id=None, - device_id=device_entry.id, - ) - assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the first config subentry from the device, the entity associated with it - # should be removed + # Removing the device's config subentry deletes the device; the entity tied to that + # subentry is removed, the entity tied to another subentry is detached device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, @@ -3001,55 +3086,18 @@ async def test_remove_config_subentry_from_device_removes_entities( ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the second config subentry from the device, the entity associated with it - # should be removed - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, - ) - await hass.async_block_till_done() - - assert device_registry.async_get(device_entry.id) - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the third config subentry from the device, the entity associated with it - # (and the device itself) should be removed - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-2", - ) - await hass.async_block_till_done() - assert not device_registry.async_get(device_entry.id) assert not entity_registry.async_is_registered(entry_1.entity_id) - assert not entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_get(entry_2.entity_id).device_id is None -@pytest.mark.parametrize( - ("subentries_in_device", "subentry_in_entity"), - [ - (["mock-subentry-id-1", "mock-subentry-id-2"], None), - ([None, "mock-subentry-id-2"], "mock-subentry-id-1"), - ], -) async def test_remove_config_subentry_from_device_removes_entities_2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - subentries_in_device: list[str | None], - subentry_in_entity: str | None, ) -> None: - """Test we don't remove entities w/o config entry when device is modified.""" + """Test we don't remove entities not tied to the removed config subentry.""" config_entry_1 = MockConfigEntry( domain="hue", subentries_data=[ @@ -3067,95 +3115,49 @@ async def test_remove_config_subentry_from_device_removes_entities_2( title="Mock title", unique_id="test", ), - config_entries.ConfigSubentryData( - data={}, - subentry_id="mock-subentry-id-3", - subentry_type="test", - title="Mock title", - unique_id="test", - ), ], ) config_entry_1.add_to_hass(hass) - # Create device with two config subentries - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id=subentries_in_device[0], - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, - config_subentry_id=subentries_in_device[1], + config_subentry_id="mock-subentry-id-1", connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {config_entry_1.entry_id} - assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: set(subentries_in_device), - } - # Create an entity without config entry or subentry + # An entity without a config entry entry_1 = entity_registry.async_get_or_create( "light", "hue", "5678", device_id=device_entry.id, ) - # Create an entity for same config entry but subentry not in device + # An entity tied to a different subentry of the same config entry entry_2 = entity_registry.async_get_or_create( "light", - "some_helper", - "5678", - config_entry=config_entry_1, - config_subentry_id=subentry_in_entity, - device_id=device_entry.id, - ) - # Create an entity for same config entry but subentry not in device - entry_3 = entity_registry.async_get_or_create( - "light", - "some_helper", + "hue", "abcd", config_entry=config_entry_1, - config_subentry_id="mock-subentry-id-3", + config_subentry_id="mock-subentry-id-2", device_id=device_entry.id, ) - - assert len({entry_1.entity_id, entry_2.entity_id, entry_3.entity_id}) == 3 assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the first config subentry from the device + # Removing the device's config subentry deletes the device; entities not tied to + # that subentry are kept but detached device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=subentries_in_device[0], - ) - await hass.async_block_till_done() - - assert device_registry.async_get(device_entry.id) - # Entities with a config subentry not in the device are not removed - assert entity_registry.async_is_registered(entry_1.entity_id) - assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - - # Remove the second config subentry from the device, this removes the device - device_registry.async_update_device( - device_entry.id, - remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=subentries_in_device[1], + remove_config_subentry_id="mock-subentry-id-1", ) await hass.async_block_till_done() assert not device_registry.async_get(device_entry.id) - # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) assert entity_registry.async_is_registered(entry_2.entity_id) - assert entity_registry.async_is_registered(entry_3.entity_id) - # Check the device link is set to None assert entity_registry.async_get(entry_1.entity_id).device_id is None assert entity_registry.async_get(entry_2.entity_id).device_id is None - assert entity_registry.async_get(entry_3.entity_id).device_id is None async def test_update_device_race( @@ -3642,9 +3644,9 @@ async def test_resolve_entity_ids(entity_registry: er.EntityRegistry) -> None: er.async_validate_entity_ids(entity_registry, ["unknown_uuid"]) -def test_entity_registry_items() -> None: +async def test_entity_registry_items(hass: HomeAssistant) -> None: """Test the EntityRegistryItems container.""" - entities = er.EntityRegistryItems() + entities = er.EntityRegistryItems(hass) assert entities.get_entity_id(("a", "b", "c")) is None assert entities.get_entry("abc") is None @@ -3703,6 +3705,47 @@ async def test_device_does_not_exist(entity_registry: er.EntityRegistry) -> None entity_registry.async_update_entity(entity_id, device_id="blah") +async def test_composite_device_id_not_allowed( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test an entity cannot be linked to a pre-migration composite device id. + + async_get resolves a composite id to a synthesized read-only device, but it is not a + real device, so linking an entity to it must be rejected. + """ + entry_1 = MockConfigEntry(domain="itg1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="itg2") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("itg1", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("itg2", "1")} + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + # The composite id resolves to a synthesized device, but is not a real registry entry + assert device_registry.async_get(old_id) is not None + assert old_id not in device_registry.devices + + match = f"Device {old_id} does not exist" + with pytest.raises(ValueError, match=match): + entity_registry.async_get_or_create("light", "hue", "1234", device_id=old_id) + + entity_id = entity_registry.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError, match=match): + entity_registry.async_update_entity(entity_id, device_id=old_id) + + async def test_disabled_by_str_not_allowed(entity_registry: er.EntityRegistry) -> None: """Test we need to pass disabled by type.""" with pytest.raises(ValueError): @@ -5406,3 +5449,293 @@ async def test_subentry( config_subentry_id="mock-subentry-id-2-1", ) assert entry.config_subentry_id == "mock-subentry-id-2-1" + + +COMPOSITE_ID = "composite0000000000000000000000" + + +def _composite_device_storage( + entry_a: MockConfigEntry, entry_b: MockConfigEntry +) -> dict[str, Any]: + """Return a v1.10 device registry store with one composite device.""" + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.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": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_repoints_entities( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Entities are moved to the split device matching their config entry.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + by_entry = { + d.config_entry_id: d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + assert entity_registry.async_get("sensor.a").device_id == by_entry[entry_a.entry_id] + assert entity_registry.async_get("sensor.b").device_id == by_entry[entry_b.entry_id] + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_repoints_entities_fallbacks( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An entity not exactly matching a split falls back by config entry, then first split.""" + entry_a = MockConfigEntry( + domain="domain_a", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-sub", + subentry_type="test", + title="t", + unique_id="u", + ) + ], + ) + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + # The split for entry_a is on the "mock-sub" subentry + device_store = _composite_device_storage(entry_a, entry_b) + device_store["data"]["devices"][0]["config_entries_subentries"] = { + entry_a.entry_id: ["mock-sub"], + entry_b.entry_id: [None], + } + hass_storage[dr.STORAGE_KEY] = device_store + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + # config entry matches a split, but the subentry does not + "entity_id": "sensor.sub", + "platform": "domain_a", + "unique_id": "sub", + "config_entry_id": entry_a.entry_id, + "config_subentry_id": None, + "device_id": COMPOSITE_ID, + }, + { + # no split matches the config entry (it has none) + "entity_id": "sensor.none", + "platform": "domain_a", + "unique_id": "none", + "config_entry_id": None, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + splits = device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + by_entry = {d.config_entry_id: d.id for d in splits} + # Subentry mismatch falls back to the split owning the entity's config entry + assert ( + entity_registry.async_get("sensor.sub").device_id == by_entry[entry_a.entry_id] + ) + # No matching config entry falls back to the first split + assert entity_registry.async_get("sensor.none").device_id in {d.id for d in splits} + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_async_entries_for_device_legacy_composite_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """A legacy composite device id resolves to its split devices' entities.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _composite_device_storage(entry_a, entry_b) + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + # The composite id is no longer a live device; its entities were repointed to splits + assert COMPOSITE_ID not in device_registry.devices + + # get_entries_for_device_id resolves the composite id to the split entities + assert { + entry.entity_id + for entry in entity_registry.entities.get_entries_for_device_id(COMPOSITE_ID) + } == {"sensor.a", "sensor.b"} + + # The public helper resolves the composite id via the device registry + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID) + } == {"sensor.a", "sensor.b"} + + # Disabled entities are only included when requested, across the split devices + entity_registry.async_update_entity( + "sensor.b", disabled_by=er.RegistryEntryDisabler.USER + ) + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, COMPOSITE_ID) + } == {"sensor.a"} + assert { + entry.entity_id + for entry in er.async_entries_for_device( + entity_registry, COMPOSITE_ID, include_disabled_entities=True + ) + } == {"sensor.a", "sensor.b"} + + # A live split device id returns just its own entity + splits = { + device.config_entry_id: device.id + for device in device_registry.async_get_devices_for_composite_device_id( + COMPOSITE_ID + ) + } + assert { + entry.entity_id + for entry in er.async_entries_for_device( + entity_registry, splits[entry_a.entry_id] + ) + } == {"sensor.a"} + + +async def test_async_entries_for_device_composite_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """A pre-migration composite id resolves to the underlying devices' entities. + + Backwards compatibility for unmodified integrations: before the single-config-entry + rewrite a shared identifier resolved to one multi-config-entry device, so + async_entries_for_device(composite_id) returned all of that device's entities. After + the split, the composite's virtual id must resolve to the same union so a legacy + reference keeps working. + """ + entry_1 = MockConfigEntry(domain="itg1") + entry_1.add_to_hass(hass) + entry_2 = MockConfigEntry(domain="itg2") + entry_2.add_to_hass(hass) + device_1 = device_registry.async_get_or_create( + config_entry_id=entry_1.entry_id, identifiers={("itg1", "1")} + ) + device_2 = device_registry.async_get_or_create( + config_entry_id=entry_2.entry_id, identifiers={("itg2", "1")} + ) + entity_1 = entity_registry.async_get_or_create( + "sensor", "itg1", "u1", config_entry=entry_1, device_id=device_1.id + ) + entity_2 = entity_registry.async_get_or_create( + "sensor", "itg2", "u2", config_entry=entry_2, device_id=device_2.id + ) + old_id = "composite00000000000000000000ab" + # Simulate a migration split: both devices carry the pre-migration composite id + device_registry.devices[device_1.id] = attr.evolve( + device_1, composite_device_id=old_id + ) + device_registry.devices[device_2.id] = attr.evolve( + device_2, composite_device_id=old_id + ) + + assert old_id not in device_registry.devices + assert { + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, old_id) + } == {entity_1.entity_id, entity_2.entity_id} diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 640b2ff011af..77b6ae2d2dfb 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -1,7 +1,7 @@ """Tests for the helper entity helpers.""" from collections.abc import Generator -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -213,6 +213,67 @@ def listen_entity_registry_events( return events +@pytest.mark.parametrize("add_helper_config_entry_to_device", [True, False]) +async def test_async_handle_source_entity_changes_deprecated_kwarg( + hass: HomeAssistant, + add_helper_config_entry_to_device: bool, +) -> None: + """The removed add_helper_config_entry_to_device kwarg is accepted but reported. + + It is swallowed by **kwargs so callers still passing it don't raise, and reported on + its presence rather than its value, since it no longer has any effect either way. + """ + with patch("homeassistant.helpers.helper_integration.report_usage") as report_usage: + unsub = async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + add_helper_config_entry_to_device=add_helper_config_entry_to_device, + ) + unsub() + + report_usage.assert_called_once() + assert "add_helper_config_entry_to_device" in report_usage.call_args[0][0] + + +async def test_async_handle_source_entity_changes_rejects_unknown_kwarg( + hass: HomeAssistant, +) -> None: + """An unknown keyword argument still raises, as it did before **kwargs was added. + + **kwargs only exists to swallow the deprecated add_helper_config_entry_to_device; + anything else (e.g. a misspelling) must not be silently accepted. + """ + with pytest.raises(TypeError, match="unexpected keyword arguments 'unknown_kwarg'"): + async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + unknown_kwarg=True, + ) + + +async def test_async_handle_source_entity_changes_without_deprecated_kwarg( + hass: HomeAssistant, +) -> None: + """Not passing the removed add_helper_config_entry_to_device kwarg is not reported.""" + with patch("homeassistant.helpers.helper_integration.report_usage") as report_usage: + unsub = async_handle_source_entity_changes( + hass, + helper_config_entry_id="helper_config_entry_id", + set_source_entity_id_or_uuid=Mock(), + source_device_id=None, + source_entity_id_or_uuid="sensor.test", + ) + unsub() + + report_usage.assert_not_called() + + @pytest.mark.parametrize("source_entity_removed", [None]) @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") @@ -230,33 +291,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( set_source_entity_id_or_uuid: Mock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_device.id, add_config_entry_id=other_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) - # Remove the source entitys's config entry from the device, this removes the - # source entity - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_config_entry.entry_id - ) + # Remove the source entity + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -267,10 +312,6 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is not removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -294,34 +335,18 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h set_source_entity_id_or_uuid: Mock, source_entity_removed: AsyncMock, ) -> None: - """Test the helper config entry is removed when the source entity is removed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Add another config entry to the source device - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - device_registry.async_update_device( - source_device.id, add_config_entry_id=other_config_entry.entry_id - ) - + """Test the source_entity_removed handler is called when the source entity is removed.""" assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) - # Remove the source entitys's config entry from the device, this removes the - # source entity - device_registry.async_update_device( - source_device.id, remove_config_entry_id=source_config_entry.entry_id - ) + # Remove the source entity + entity_registry.async_remove(source_entity_entry.entity_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -331,9 +356,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed_custom_h async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is not removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries + # Check that the custom handler took over: the helper entity is left linked to the + # source device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -357,21 +383,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev set_source_entity_id_or_uuid: Mock, ) -> None: """Test the source entity removed from the source device.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Remove the source entity from the device @@ -381,9 +399,9 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev async_unload_entry.assert_called_once() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is removed from the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries + # Check that the helper entity is not linked to the source device anymore + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id is None # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -408,11 +426,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi set_source_entity_id_or_uuid: Mock, ) -> None: """Test the source entity is moved to another device.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - # Create another device to move the source entity to source_device_2 = device_registry.async_get_or_create( config_entry_id=source_config_entry.entry_id, @@ -422,15 +435,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - source_device_2 = device_registry.async_get(source_device_2.id) - assert helper_config_entry.entry_id not in source_device_2.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Move the source entity to another device @@ -442,11 +450,9 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi async_unload_entry.assert_called_once() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is moved to the other device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries - source_device_2 = device_registry.async_get(source_device_2.id) - assert helper_config_entry.entry_id in source_device_2.config_entries + # Check that the helper entity is relinked to the other device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device_2.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -475,21 +481,13 @@ async def test_async_handle_source_entity_new_entity_id( set_source_entity_id_calls: int, ) -> None: """Test the source entity's entity ID is changed.""" - # Add the helper config entry to the source device - device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id - ) - assert await hass.config_entries.async_setup(helper_config_entry.entry_id) await hass.async_block_till_done() - # Check preconditions + # Check preconditions - the helper entity is linked to the source device helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) assert helper_entity_entry.device_id == source_entity_entry.device_id - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries - events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) # Change the source entity's entity ID @@ -501,9 +499,9 @@ async def test_async_handle_source_entity_new_entity_id( assert len(async_unload_entry.mock_calls) == unload_calls assert len(set_source_entity_id_or_uuid.mock_calls) == set_source_entity_id_calls - # Check that the helper config is still in the device - source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id in source_device.config_entries + # Check that the helper entity is still linked to the source device + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_device.id # Check that the helper config entry is not removed assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -520,13 +518,25 @@ async def test_async_remove_helper_config_entry_from_source_device( entity_registry: er.EntityRegistry, helper_config_entry: MockConfigEntry, helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, source_device: dr.DeviceEntry, ) -> None: """Test removing the helper config entry from the source device.""" - # Add the helper config entry to the source device + # In the single-owner model the migration helper only acts when the helper config + # entry owns the source device. Move the source device to the helper config entry + # and record a pending move back to the source config entry, so removing the helper + # config entry hands the device back to the source config entry instead of deleting + # it. device_registry.async_update_device( - source_device.id, add_config_entry_id=helper_config_entry.entry_id + source_device.id, + add_config_entry_id=helper_config_entry.entry_id, + remove_config_entry_id=source_config_entry.entry_id, ) + device_registry.async_update_device( + source_device.id, add_config_entry_id=source_config_entry.entry_id + ) + source_device = device_registry.async_get(source_device.id) + assert source_device.config_entries == {helper_config_entry.entry_id} # Create a helper entity entry, not connected to the source device extra_helper_entity_entry = entity_registry.async_get_or_create( diff --git a/tests/helpers/test_http.py b/tests/helpers/test_http.py new file mode 100644 index 000000000000..81bcfc18bd89 --- /dev/null +++ b/tests/helpers/test_http.py @@ -0,0 +1,40 @@ +"""Tests for the HTTP helpers.""" + +from http import HTTPStatus + +from aiohttp import web +import pytest + +from homeassistant.helpers.http import MIN_COMPRESSED_RESPONSE_SIZE, HomeAssistantView + +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("body_size", "expect_compression"), + [ + pytest.param(8, False, id="small-body-not-compressed"), + pytest.param( + MIN_COMPRESSED_RESPONSE_SIZE * 2, True, id="large-body-compressed" + ), + ], +) +@pytest.mark.usefixtures("socket_enabled") +async def test_json_response_compression_threshold( + aiohttp_client: ClientSessionGenerator, + body_size: int, + expect_compression: bool, +) -> None: + """Test HomeAssistantView.json only compresses bodies above the threshold.""" + + async def handler(request: web.Request) -> web.Response: + return HomeAssistantView.json({"data": "x" * body_size}) + + app = web.Application() + app.router.add_get("/", handler) + client = await aiohttp_client(app) + + resp = await client.get("/", headers={"Accept-Encoding": "gzip, deflate"}) + + assert resp.status == HTTPStatus.OK + assert ("Content-Encoding" in resp.headers) is expect_compression diff --git a/tests/helpers/test_integration_platform.py b/tests/helpers/test_integration_platform.py index 6784f1d9f083..0ad53bce417b 100644 --- a/tests/helpers/test_integration_platform.py +++ b/tests/helpers/test_integration_platform.py @@ -471,3 +471,25 @@ async def test_lazy_integration_platforms_concurrent(hass: HomeAssistant) -> Non assert results == [loaded_platform, loaded_platform] # The platform was imported and processed exactly once. assert processed == ["loaded"] + + +async def test_lazy_integration_platforms_async_process(hass: HomeAssistant) -> None: + """Test a coroutine process callback is awaited and its result cached.""" + loaded_platform = Mock() + mock_platform(hass, "loaded.platform_to_check", loaded_platform) + hass.config.components.add("loaded") + + processed: list[str] = [] + + async def _process_platform(hass: HomeAssistant, domain: str, platform: Any) -> Any: + processed.append(domain) + return platform + + platforms = LazyIntegrationPlatforms(hass, "platform_to_check", _process_platform) + + assert await platforms.async_get_platform("loaded") is loaded_platform + assert processed == ["loaded"] + + # The awaited result is cached, so a subsequent request does not reprocess. + assert await platforms.async_get_platform("loaded") is loaded_platform + assert processed == ["loaded"] diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 8f2a978a14eb..805dd3239c90 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1,18 +1,16 @@ """Tests for the llm helpers.""" -from datetime import timedelta from decimal import Decimal from unittest.mock import patch import pytest import voluptuous as vol -from homeassistant.components import calendar, todo from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.components.script import ScriptConfig from homeassistant.const import EntityStateAttribute -from homeassistant.core import Context, HomeAssistant, State, SupportsResponse +from homeassistant.core import Context, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, @@ -25,10 +23,15 @@ from homeassistant.helpers import ( selector, ) from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType -from tests.common import MockConfigEntry, async_mock_service +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +async def setup_llm(hass: HomeAssistant) -> None: + """Set up the llm integration so the Assist API can pull tools from it.""" + assert await async_setup_component(hass, "llm", {}) @pytest.fixture @@ -38,7 +41,7 @@ def llm_context() -> llm.LLMContext: platform="", context=None, language=None, - assistant=None, + assistant="conversation", device_id=None, ) @@ -142,22 +145,13 @@ async def test_call_tool_no_existing( async def test_assist_api( hass: HomeAssistant, - entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry, floor_registry: fr.FloorRegistry, ) -> None: - """Test Assist API.""" + """Test calling an IntentTool through the Assist API.""" assert await async_setup_component(hass, "homeassistant", {}) - entity_registry.async_get_or_create( - "light", - "kitchen", - "mock-id-kitchen", - original_name="Kitchen", - suggested_object_id="kitchen", - ).write_unavailable_state(hass) - test_context = Context() llm_context = llm.LLMContext( platform="test_platform", @@ -176,32 +170,10 @@ async def test_assist_api( class MyIntentHandler(intent.IntentHandler): intent_type = "test_intent" slot_schema = schema - platforms = set() # Match none intent_handler = MyIntentHandler() - intent.async_register(hass, intent_handler) - - assert len(llm.async_get_apis(hass)) == 1 - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == ["GetDateTime", "GetLiveContext"] - - # Match all - intent_handler.platforms = None - - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == [ - "test_intent", - "GetDateTime", - "GetLiveContext", - ] - - # Match specific domain - intent_handler.platforms = {"light"} - - api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 3 - tool = api.tools[0] + tool = llm.IntentTool("test_intent", intent_handler) assert tool.name == "test_intent" assert tool.description == "Execute Home Assistant test_intent intent" assert tool.parameters == vol.Schema( @@ -213,6 +185,14 @@ async def test_assist_api( ) assert str(tool) == "" + api = next(api for api in llm.async_get_apis(hass) if api.id == "assist") + instance = llm.APIInstance( + api=api, + api_prompt="", + llm_context=llm_context, + tools=[tool], + ) + assert test_context.json_fragment # To reproduce an error case in tracing intent_response = intent.IntentResponse("*") intent_response.async_set_states( @@ -230,7 +210,7 @@ async def test_assist_api( with patch( "homeassistant.helpers.intent.async_handle", return_value=intent_response ) as mock_intent_handle: - response = await api.async_call_tool(tool_input) + response = await instance.async_call_tool(tool_input) mock_intent_handle.assert_awaited_once_with( hass=hass, @@ -286,7 +266,7 @@ async def test_assist_api( with patch( "homeassistant.helpers.intent.async_handle", return_value=intent_response ) as mock_intent_handle: - response = await api.async_call_tool(tool_input) + response = await instance.async_call_tool(tool_input) mock_intent_handle.assert_awaited_once_with( hass=hass, @@ -346,57 +326,16 @@ async def test_assist_api_get_timer_tools( assert "HassStartTimer" in [tool.name for tool in api.tools] -async def test_assist_api_tools( - hass: HomeAssistant, llm_context: llm.LLMContext -) -> None: - """Test getting timer tools with Assist API.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) - - llm_context.device_id = "test_device" - - async_register_timer_handler(hass, "test_device", lambda *args: None) - - class MyIntentHandler(intent.IntentHandler): - intent_type = "Super crazy intent with unique nåme" - description = "my intent handler" - - intent.async_register(hass, MyIntentHandler()) - - api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == [ - "HassTurnOn", - "HassTurnOff", - "HassSetPosition", - "HassStopMoving", - "HassStartTimer", - "HassCancelTimer", - "HassCancelAllTimers", - "HassIncreaseTimer", - "HassDecreaseTimer", - "HassPauseTimer", - "HassUnpauseTimer", - "HassTimerStatus", - "Super_crazy_intent_with_unique_name", - "GetDateTime", - ] - - async def test_assist_api_description( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: - """Test intent description with Assist API.""" + """Test that the intent handler description is used for the tool.""" class MyIntentHandler(intent.IntentHandler): intent_type = "test_intent" description = "my intent handler" - intent.async_register(hass, MyIntentHandler()) - - assert len(llm.async_get_apis(hass)) == 1 - api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 2 - tool = api.tools[0] + tool = llm.IntentTool("test_intent", MyIntentHandler()) assert tool.name == "test_intent" assert tool.description == "my intent handler" @@ -769,9 +708,9 @@ Static Context: An overview of the areas and the devices in this smart home: ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -794,9 +733,9 @@ Static Context: An overview of the areas and the devices in this smart home: ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -811,9 +750,9 @@ Static Context: An overview of the areas and the devices in this smart home: ) api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt} {no_timer_prompt}""" ) @@ -824,355 +763,23 @@ Static Context: An overview of the areas and the devices in this smart home: api = await llm.async_get_api(hass, "assist", llm_context) # The no_timer_prompt is gone assert api.api_prompt == ( - f"""{first_part_prompt} -{dynamic_context_prompt} + f"""{dynamic_context_prompt} {stateless_exposed_entities_prompt} +{first_part_prompt} {area_prompt}""" ) -async def test_get_live_context_tool_filter( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - area_registry: ar.AreaRegistry, -) -> None: - """Test the filter parameters of the GetLiveContext tool.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - - entry = MockConfigEntry(title=None) - entry.add_to_hass(hass) - - office = area_registry.async_create("Office") - area_registry.async_update(office.id, aliases={"Workspace"}) - area_registry.async_create("Kitchen") - - office_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "office-1")}, - suggested_area="Office", - ) - kitchen_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "kitchen-1")}, - suggested_area="Kitchen", - ) - - office_light = entity_registry.async_get_or_create( - "light", - "test", - "office_light", - original_name="Office Light", - device_id=office_device.id, - suggested_object_id="office_light", - ) - kitchen_light = entity_registry.async_get_or_create( - "light", - "test", - "kitchen_light", - original_name="Kitchen Light", - device_id=kitchen_device.id, - suggested_object_id="kitchen_light", - ) - office_switch = entity_registry.async_get_or_create( - "switch", - "test", - "office_switch", - original_name="Office Switch", - device_id=office_device.id, - suggested_object_id="office_switch", - ) - front_door = entity_registry.async_get_or_create( - "lock", - "test", - "front_door", - original_name="Front Door", - suggested_object_id="front_door", - ) - # Two entities sharing the same name in different areas - office_ac = entity_registry.async_get_or_create( - "climate", - "test", - "office_ac", - original_name="AC", - device_id=office_device.id, - suggested_object_id="office_ac", - ) - kitchen_ac = entity_registry.async_get_or_create( - "climate", - "test", - "kitchen_ac", - original_name="AC", - device_id=kitchen_device.id, - suggested_object_id="kitchen_ac", - ) - entity_registry.async_update_entity( - kitchen_light.entity_id, aliases=[er.COMPUTED_NAME, "Cooking Lamp"] - ) - - for entity_id in ( - office_light.entity_id, - kitchen_light.entity_id, - office_switch.entity_id, - front_door.entity_id, - office_ac.entity_id, - kitchen_ac.entity_id, - ): - async_expose_entity(hass, "conversation", entity_id, True) - - hass.states.async_set(office_light.entity_id, "on") - hass.states.async_set(kitchen_light.entity_id, "off") - hass.states.async_set(office_switch.entity_id, "on") - hass.states.async_set(front_door.entity_id, "locked") - hass.states.async_set(office_ac.entity_id, "cool") - hass.states.async_set(kitchen_ac.entity_id, "heat") - - api = await llm.async_get_api(hass, "assist", llm_context) - - # Filter by area and domain (example 1) - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": "Office", "domain": "light"}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] - assert "Front Door" not in result["result"] - - # Filter by name (example 2) - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "Front Door"}, - ) - ) - assert result["success"] is True - assert "Front Door" in result["result"] - assert "Office Light" not in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] - - # Name filter is case insensitive - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "front door"}, - ) - ) - assert result["success"] is True - assert "Front Door" in result["result"] - - # Area filter matches area aliases - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": "workspace"}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Front Door" not in result["result"] - - # Domain filter accepts a list - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"domain": ["switch", "lock"]}, - ) - ) - assert result["success"] is True - assert "Office Switch" in result["result"] - assert "Front Door" in result["result"] - assert "Office Light" not in result["result"] - assert "Kitchen Light" not in result["result"] - - # Domain filter is case insensitive - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"domain": "Light"}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" in result["result"] - assert "Office Switch" not in result["result"] - assert "Front Door" not in result["result"] - - # No filters returns all exposed entities - result = await api.async_call_tool( - llm.ToolInput(tool_name="GetLiveContext", tool_args={}) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Front Door" in result["result"] - - # Filter that matches nothing returns a descriptive error - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "Does Not Exist"}, - ) - ) - assert result == { - "success": False, - "error": "No exposed entities matched name 'Does Not Exist'", - } - - # Name filter strips surrounding whitespace - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": " Front Door "}, - ) - ) - assert result["success"] is True - assert "Front Door" in result["result"] - - # Area filter strips surrounding whitespace - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": " Office "}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - - # Name filter accepts entity_id - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": office_light.entity_id}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] - - # Area filter accepts area_id - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": office.id}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Front Door" not in result["result"] - - # Name filter matches entity aliases - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "cooking lamp"}, - ) - ) - assert result["success"] is True - assert "Kitchen Light" in result["result"] - assert "Office Light" not in result["result"] - - # Combining name + area narrows the result - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "Office Light", "area": "Office"}, - ) - ) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" not in result["result"] - - # Combining name + area returns the failing constraint in the error - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "Office Light", "area": "Kitchen"}, - ) - ) - assert result == { - "success": False, - "error": "No exposed entities found in area 'Kitchen'", - } - - # Unknown area distinguishes "invalid area" from "no entities in area" - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"area": "Garage"}, - ) - ) - assert result == { - "success": False, - "error": "Area 'Garage' does not exist", - } - - # Unknown domain reports which domain(s) failed - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"domain": "fan"}, - ) - ) - assert result == { - "success": False, - "error": "No exposed entities found in domain(s): fan", - } - - # Entities sharing a name are all returned rather than failing as an - # ambiguous match, since this tool only returns context. - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "AC"}, - ) - ) - assert result["success"] is True - assert result["result"].count("domain: climate") == 2 - assert "Office" in result["result"] - assert "Kitchen" in result["result"] - - # Combining a shared name with an area narrows to the single match - result = await api.async_call_tool( - llm.ToolInput( - tool_name="GetLiveContext", - tool_args={"name": "AC", "area": "Kitchen"}, - ) - ) - assert result["success"] is True - assert result["result"].count("domain: climate") == 1 - assert "Kitchen" in result["result"] - assert "Office" not in result["result"] - - -async def test_script_tool( +async def test_action_tool( hass: HomeAssistant, entity_registry: er.EntityRegistry, area_registry: ar.AreaRegistry, floor_registry: fr.FloorRegistry, ) -> None: - """Test ScriptTool for the assist API.""" + """Test ActionTool schema, area/floor resolution and parameter caching. + + Exercised through scripts, the only ActionTool the assist API builds. + """ assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "intent", {}) context = Context() @@ -1234,7 +841,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] + tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] assert len(tools) == 2 tool = tools[0] @@ -1254,15 +861,10 @@ async def test_script_tool( } assert tool.parameters.schema == schema + # The parameter cache stores the base description; ScriptTool appends aliases. assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { - "test_script": ( - "This is a test script. Aliases: ['script alias', 'script name']", - vol.Schema(schema), - ), - "script_with_no_fields": ( - "This is another test script. Aliases: ['test script 2']", - vol.Schema({}), - ), + "test_script": ("This is a test script", vol.Schema(schema)), + "script_with_no_fields": ("This is another test script", vol.Schema({})), } # Test script with response @@ -1358,7 +960,7 @@ async def test_script_tool( api = await llm.async_get_api(hass, "assist", llm_context) - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] + tools = [tool for tool in api.tools if isinstance(tool, llm.ActionTool)] assert len(tools) == 2 tool = tools[0] @@ -1371,56 +973,11 @@ async def test_script_tool( assert tool.parameters.schema == schema assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { - "test_script": ( - "This is a new test script. Aliases: ['script alias', 'script name']", - vol.Schema(schema), - ), - "script_with_no_fields": ( - "This is another test script. Aliases: ['test script 2']", - vol.Schema({}), - ), + "test_script": ("This is a new test script", vol.Schema(schema)), + "script_with_no_fields": ("This is another test script", vol.Schema({})), } -async def test_script_tool_name(hass: HomeAssistant) -> None: - """Test that script tool name is not started with a digit.""" - assert await async_setup_component(hass, "homeassistant", {}) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - - # Create a script with a unique ID - assert await async_setup_component( - hass, - "script", - { - "script": { - "123456": { - "description": "This is a test script", - "sequence": [], - "fields": { - "beer": {"description": "Number of beers", "required": True}, - }, - }, - } - }, - ) - async_expose_entity(hass, "conversation", "script.123456", True) - - api = await llm.async_get_api(hass, "assist", llm_context) - - tools = [tool for tool in api.tools if isinstance(tool, llm.ScriptTool)] - assert len(tools) == 1 - - tool = tools[0] - assert tool.name == "_123456" - - async def test_selector_serializer( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: @@ -1696,253 +1253,6 @@ async def test_selector_serializer( } -async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: - """Test the calendar get events tool.""" - assert await async_setup_component(hass, "homeassistant", {}) - hass.states.async_set( - "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} - ) - async_expose_entity(hass, "conversation", "calendar.test_calendar", True) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - api = await llm.async_get_api(hass, "assist", llm_context) - tool = next( - (tool for tool in api.tools if tool.name == "calendar_get_events"), None - ) - assert tool is not None - assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] - - calls = async_mock_service( - hass, - domain=calendar.DOMAIN, - service=calendar.SERVICE_GET_EVENTS, - schema=calendar.SERVICE_GET_EVENTS_SCHEMA, - response={ - "calendar.test_calendar": { - "events": [ - { - "start": "2025-09-17", - "end": "2025-09-18", - "summary": "Home Assistant 12th birthday", - "description": "", - }, - { - "start": "2025-09-17T14:00:00-05:00", - "end": "2025-09-18T15:00:00-05:00", - "summary": "Champagne", - "description": "", - }, - ] - } - }, - supports_response=SupportsResponse.ONLY, - ) - - tool_input = llm.ToolInput( - tool_name="calendar_get_events", - tool_args={ - "calendar": "Mock Calendar Name", - "range": "today", - }, - ) - now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): - response = await api.async_call_tool(tool_input) - - assert len(calls) == 1 - call = calls[0] - assert call.domain == calendar.DOMAIN - assert call.service == calendar.SERVICE_GET_EVENTS - assert call.data == { - "entity_id": ["calendar.test_calendar"], - "start_date_time": now, - "end_date_time": dt_util.start_of_local_day() + timedelta(days=1), - } - - assert response == { - "success": True, - "result": [ - { - "start": "2025-09-17", - "end": "2025-09-18", - "summary": "Home Assistant 12th birthday", - "description": "", - "all_day": True, - }, - { - "start": "2025-09-17T14:00:00-05:00", - "end": "2025-09-18T15:00:00-05:00", - "summary": "Champagne", - "description": "", - }, - ], - } - - tool_input.tool_args["range"] = "week" - with patch("homeassistant.util.dt.now", return_value=now): - response = await api.async_call_tool(tool_input) - - assert len(calls) == 2 - call = calls[1] - assert call.data == { - "entity_id": ["calendar.test_calendar"], - "start_date_time": now, - "end_date_time": dt_util.start_of_local_day() + timedelta(days=7), - } - - -async def test_todo_get_items_tool(hass: HomeAssistant) -> None: - """Test the todo get items tool.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "todo", {}) - hass.states.async_set( - "todo.test_list", "0", {"friendly_name": "Mock Todo List Name"} - ) - async_expose_entity(hass, "conversation", "todo.test_list", True) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - api = await llm.async_get_api(hass, "assist", llm_context) - tool = next((tool for tool in api.tools if tool.name == "todo_get_items"), None) - assert tool is not None - assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] - - calls = async_mock_service( - hass, - domain=todo.DOMAIN, - service=todo.TodoServices.GET_ITEMS, - schema=cv.make_entity_service_schema(todo.TODO_SERVICE_GET_ITEMS_SCHEMA), - response={ - "todo.test_list": { - "items": [ - { - "uid": "1234", - "summary": "Buy milk", - "status": "needs_action", - }, - { - "uid": "5678", - "summary": "Call mom", - "status": "needs_action", - "due": "2025-09-17", - "description": "Remember birthday", - }, - ] - } - }, - ) - - # Test without status filter (defaults to needs_action) - result = await tool.async_call( - hass, - llm.ToolInput("todo_get_items", {"todo_list": "Mock Todo List Name"}), - llm_context, - ) - - assert len(calls) == 1 - assert calls[0].data == { - "entity_id": ["todo.test_list"], - "status": ["needs_action"], - } - assert result == { - "success": True, - "result": [ - { - "uid": "1234", - "status": "needs_action", - "summary": "Buy milk", - }, - { - "uid": "5678", - "status": "needs_action", - "summary": "Call mom", - "due": "2025-09-17", - "description": "Remember birthday", - }, - ], - } - - # Test that the status filter is passed correctly to the service call. - # We don't assert on the response since it is fixed above. - calls.clear() - result = await tool.async_call( - hass, - llm.ToolInput( - "todo_get_items", - {"todo_list": "Mock Todo List Name", "status": "completed"}, - ), - llm_context, - ) - assert len(calls) == 1 - assert calls[0].data == { - "entity_id": ["todo.test_list"], - "status": ["completed"], - } - - # Test that the status filter is passed correctly to the service call. - # We don't assert on the response since it is fixed above. - calls.clear() - result = await tool.async_call( - hass, - llm.ToolInput( - "todo_get_items", - {"todo_list": "Mock Todo List Name", "status": "all"}, - ), - llm_context, - ) - assert len(calls) == 1 - assert calls[0].data == { - "entity_id": ["todo.test_list"], - "status": ["needs_action", "completed"], - } - - -async def test_get_date_time_tool(hass: HomeAssistant) -> None: - """Test the GetDateTime tool.""" - - assert await async_setup_component(hass, "homeassistant", {}) - context = Context() - llm_context = llm.LLMContext( - platform="test_platform", - context=context, - language="*", - assistant="conversation", - device_id=None, - ) - api = await llm.async_get_api(hass, "assist", llm_context) - tool = next((tool for tool in api.tools if tool.name == "GetDateTime"), None) - assert tool is not None - - now = dt_util.parse_datetime("2025-09-22 12:30:45Z") - - with patch("homeassistant.util.dt.now", return_value=now): - result = await tool.async_call( - hass, - llm.ToolInput("GetDateTime", {}), - llm_context, - ) - assert result == { - "success": True, - "result": { - "date": "2025-09-22", - "time": "12:30:45", - "timezone": "UTC", - "weekday": "Monday", - }, - } - - async def test_no_tools_exposed(hass: HomeAssistant) -> None: """Test that tools are not exposed when no entities are exposed.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1955,7 +1265,8 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: device_id=None, ) api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool.name for tool in api.tools] == ["GetDateTime"] + # GetLiveContext is always offered; it reports when nothing is exposed. + assert [tool.name for tool in api.tools] == ["GetLiveContext", "GetDateTime"] async def test_merged_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: @@ -2012,68 +1323,12 @@ This is prompt 2 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"] +async def test_deprecated_async_render_no_api_prompt( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the deprecated async_render_no_api_prompt helper.""" + assert llm.async_render_no_api_prompt(hass) == "" + assert ( + "The deprecated function async_render_no_api_prompt was called. It will be " + "removed in HA Core 2027.2. Use an empty string instead" + ) in caplog.text diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index ee739e1367f5..d33edd0dd3b7 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4307,6 +4307,45 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_repeat_count_seq"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "light.is_on", + "target": {"label_id": "label_repeat_while_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_repeat_while_seq"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "light.is_on", + "target": {"label_id": "label_repeat_until_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"label_id": "label_repeat_until_seq"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "state", @@ -4345,6 +4384,11 @@ async def test_referenced_labels(hass: HomeAssistant) -> None: "label_in_data_template", "label_in_target", "label_parallel", + "label_repeat_count_seq", + "label_repeat_until_cond", + "label_repeat_until_seq", + "label_repeat_while_cond", + "label_repeat_while_seq", "label_sequence", "label_service_list_1", "label_service_list_2", @@ -4461,6 +4505,45 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_repeat_count_seq"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "light.is_on", + "target": {"floor_id": "floor_repeat_while_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_repeat_while_seq"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "light.is_on", + "target": {"floor_id": "floor_repeat_until_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"floor_id": "floor_repeat_until_seq"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "state", @@ -4499,6 +4582,11 @@ async def test_referenced_floors(hass: HomeAssistant) -> None: "floor_in_data_template", "floor_in_target", "floor_parallel", + "floor_repeat_count_seq", + "floor_repeat_until_cond", + "floor_repeat_until_seq", + "floor_repeat_while_cond", + "floor_repeat_while_seq", "floor_sequence", "floor_service_list", "floor_service_not_list", @@ -4614,6 +4702,45 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_repeat_count_seq"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "light.is_on", + "target": {"area_id": "area_repeat_while_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_repeat_while_seq"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "light.is_on", + "target": {"area_id": "area_repeat_until_cond"}, + }, + "sequence": [ + { + "action": "test.script", + "data": {"area_id": "area_repeat_until_seq"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "state", @@ -4652,6 +4779,11 @@ async def test_referenced_areas(hass: HomeAssistant) -> None: "area_in_data_template", "area_in_target", "area_parallel", + "area_repeat_count_seq", + "area_repeat_until_cond", + "area_repeat_until_seq", + "area_repeat_while_cond", + "area_repeat_while_seq", "area_sequence", "area_service_list", "area_service_not_list", @@ -4701,6 +4833,18 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: }, {"action": "test.script", "data": {"without": "entity_id"}}, {"scene": "scene.hello"}, + { + "domain": "light", + "device_id": "abcdefgh", + "entity_id": "light.device_action", + "type": "turn_on", + }, + { + "domain": "light", + "device_id": "abcdefgh", + "entity_id": "1234567890abcdef1234567890abcdef", + "type": "turn_on", + }, { "choose": [ { @@ -4734,6 +4878,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: ], }, {"event": "test_event"}, + { + "event": "test_event", + "event_data": {"entity_id": "light.event_data"}, + }, + { + "event": "test_event", + "event_data": {"entity_id": "not-a-valid-entity-id"}, + }, {"delay": "{{ delay_period }}"}, { "if": [], @@ -4779,6 +4931,47 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.repeat_count_seq"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "state", + "entity_id": "sensor.repeat_while_cond", + "state": "100", + }, + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.repeat_while_seq"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "state", + "entity_id": "sensor.repeat_until_cond", + "state": "100", + }, + "sequence": [ + { + "action": "test.script", + "data": {"entity_id": "light.repeat_until_seq"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "state", @@ -4808,15 +5001,22 @@ async def test_referenced_entities(hass: HomeAssistant) -> None: "light.condition_list_2", "light.condition_target", "light.default_seq", + "light.device_action", "light.direct_entity_referenced", "light.entity_in_data_template", "light.entity_in_target", + "light.event_data", "light.service_list", "light.service_not_list", "light.if_then", "light.if_else", "light.parallel", + "light.repeat_count_seq", + "light.repeat_until_seq", + "light.repeat_while_seq", "light.sequence", + "sensor.repeat_until_cond", + "sensor.repeat_while_cond", # "light.service_template", # no entity extraction from template "scene.hello", "sensor.condition", @@ -4939,6 +5139,47 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: } ], }, + { + "repeat": { + "count": 3, + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "repeat-count-seq-device"}, + } + ], + }, + }, + { + "repeat": { + "while": { + "condition": "device", + "device_id": "repeat-while-cond-dev-id", + "domain": "switch", + }, + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "repeat-while-seq-device"}, + } + ], + }, + }, + { + "repeat": { + "until": { + "condition": "device", + "device_id": "repeat-until-cond-dev-id", + "domain": "switch", + }, + "sequence": [ + { + "action": "test.script", + "target": {"device_id": "repeat-until-seq-device"}, + } + ], + }, + }, { "wait_for_trigger": { "platform": "device", @@ -4946,6 +5187,11 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "domain": "switch", }, }, + {"event": "test_event"}, + { + "event": "test_event", + "event_data": {"device_id": "event-data-device"}, + }, { "wait_for_trigger": { "platform": "state", @@ -4982,6 +5228,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "data-string-id", "data-template-string-id", "default-device-target", + "event-data-device", "script-dev-id", "target-list-id-1", "target-list-id-2", @@ -4989,6 +5236,11 @@ async def test_referenced_devices(hass: HomeAssistant) -> None: "if-then", "if-else", "parallel-device", + "repeat-count-seq-device", + "repeat-until-cond-dev-id", + "repeat-until-seq-device", + "repeat-while-cond-dev-id", + "repeat-while-seq-device", "sequence-device", "wait-trigger-device", "wait-trigger-target", diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 95bbaaad87fc..9d8a2e764487 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -300,6 +300,38 @@ def test_device_selector_schema_error(schema) -> None: ( { "filter": [ + { + "device": { + "manufacturer": "mock-manuf", + "model": "mock-model", + "model_id": "mock-model_id", + } + } + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + { + "domain": "binary_sensor", + "device": { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + "model_id": "mock-model_id", + }, + }, + { + "device": { + "integration": "matter", + "manufacturer": "other-mock-manuf", + "model": "other-mock-model", + "model_id": "other-mock-model_id", + }, + }, {"unit_of_measurement": "baguette"}, ] }, @@ -341,6 +373,10 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"unit_of_measurement": ["currywurst", "bratwurst"]}, # Invalid unit_of_measurement {"filter": [{"unit_of_measurement": 42}]}, + # Device properties must be grouped under the device key + {"filter": [{"manufacturer": "mock-manuf"}]}, + {"filter": [{"model": "mock-model"}]}, + {"filter": [{"model_id": "mock-model_id"}]}, # reorder can only be used when multiple is true {"reorder": True}, {"reorder": True, "multiple": False}, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index f6de318d4400..e9e459a4d60a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Callable, Generator, Iterable from copy import deepcopy -import dataclasses import io import threading from typing import Any @@ -164,10 +163,18 @@ def floor_area_mock(hass: HomeAssistant) -> None: }, ) - device_in_area = dr.DeviceEntry(area_id="test-area") - device_no_area = dr.DeviceEntry(id="device-no-area-id") - device_diff_area = dr.DeviceEntry(area_id="diff-area") - device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") + device_in_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", area_id="test-area" + ) + device_no_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-no-area-id" + ) + device_diff_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", area_id="diff-area" + ) + device_area_a = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a" + ) mock_device_registry( hass, @@ -331,13 +338,21 @@ def label_mock(hass: HomeAssistant) -> None: }, ) - device_has_label1 = dr.DeviceEntry(labels={"label1"}) - device_has_label2 = dr.DeviceEntry(labels={"label2"}) + device_has_label1 = dr.DeviceEntry( + config_entry_id="mock-config-entry", labels={"label1"} + ) + device_has_label2 = dr.DeviceEntry( + config_entry_id="mock-config-entry", labels={"label2"} + ) device_has_labels = dr.DeviceEntry( - labels={"label1", "label2"}, area_id=area_with_labels.id + config_entry_id="mock-config-entry", + labels={"label1", "label2"}, + area_id=area_with_labels.id, ) device_no_labels = dr.DeviceEntry( - id="device-no-labels", area_id=area_without_labels.id + config_entry_id="mock-config-entry", + id="device-no-labels", + area_id=area_without_labels.id, ) mock_device_registry( @@ -2492,7 +2507,10 @@ async def test_async_extract_entities_warn_referenced( async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: """Test we can find devices that have no entities.""" - device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"}) + device_no_entities = dr.DeviceEntry( + config_entry_id="abc", + id="device-no-entities", + ) call = ServiceCall( hass, @@ -2773,83 +2791,6 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: assert reloaded == unordered(["target1"]) -async def test_deprecated_service_target_selector_class(hass: HomeAssistant) -> None: - """Test that the deprecated ServiceTargetSelector class forwards correctly.""" - call = ServiceCall( - hass, - "test", - "test", - { - "entity_id": ["light.test", "switch.test"], - "area_id": "kitchen", - "device_id": ["device1", "device2"], - "floor_id": "first_floor", - "label_id": ["label1", "label2"], - }, - ) - selector = service.ServiceTargetSelector(call) - - assert selector.entity_ids == {"light.test", "switch.test"} - assert selector.area_ids == {"kitchen"} - assert selector.device_ids == {"device1", "device2"} - assert selector.floor_ids == {"first_floor"} - assert selector.label_ids == {"label1", "label2"} - assert selector.has_any_target is True - - -async def test_deprecated_selected_entities_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test that the deprecated SelectedEntities class forwards correctly.""" - selected = service.SelectedEntities( - referenced={"entity.test"}, - indirectly_referenced=set(), - referenced_devices=set(), - referenced_areas=set(), - missing_devices={"missing_device"}, - missing_areas={"missing_area"}, - missing_floors={"missing_floor"}, - missing_labels={"missing_label"}, - ) - - missing_entities = {"entity.missing"} - selected.log_missing(missing_entities) - assert ( - "Referenced floors missing_floor, areas missing_area, " - "devices missing_device, entities entity.missing, " - "labels missing_label are missing or not currently available" in caplog.text - ) - - -async def test_deprecated_async_extract_referenced_entity_ids( - hass: HomeAssistant, -) -> None: - """Test deprecated async_extract_referenced_entity_ids forwards correctly.""" - from homeassistant.helpers import target # noqa: PLC0415 - - mock_selected = target.SelectedEntities( - referenced={"entity.test"}, - indirectly_referenced={"entity.indirect"}, - ) - with patch( - "homeassistant.helpers.target.async_extract_referenced_entity_ids", - return_value=mock_selected, - ) as mock_target_func: - call = ServiceCall(hass, "test", "test", {"entity_id": "light.test"}) - result = service.async_extract_referenced_entity_ids( - hass, call, expand_group=False - ) - - # Verify target helper was called with correct parameters - mock_target_func.assert_called_once() - args = mock_target_func.call_args - assert args[0][0] is hass - assert args[0][1].entity_ids == {"light.test"} - assert args[0][2] is False - - assert dataclasses.asdict(result) == dataclasses.asdict(mock_selected) - - async def test_register_platform_entity_service( hass: HomeAssistant, ) -> None: diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index 9d72951868ed..93b9adf7a67d 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Mapping +from typing import Any import pytest @@ -109,13 +110,30 @@ def registries_mock(hass: HomeAssistant) -> None: }, ) - device_in_area = dr.DeviceEntry(id="device-test-area", area_id="test-area") - device_no_area = dr.DeviceEntry(id="device-no-area-id") - device_diff_area = dr.DeviceEntry(id="device-diff-area", area_id="diff-area") - device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") - device_has_label1 = dr.DeviceEntry(id="device-has-label1-id", labels={"label1"}) - device_has_label2 = dr.DeviceEntry(id="device-has-label2-id", labels={"label2"}) + device_in_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-test-area", area_id="test-area" + ) + device_no_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-no-area-id" + ) + device_diff_area = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-diff-area", area_id="diff-area" + ) + device_area_a = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-area-a-id", area_id="area-a" + ) + device_has_label1 = dr.DeviceEntry( + config_entry_id="mock-config-entry", + id="device-has-label1-id", + labels={"label1"}, + ) + device_has_label2 = dr.DeviceEntry( + config_entry_id="mock-config-entry", + id="device-has-label2-id", + labels={"label2"}, + ) device_has_labels = dr.DeviceEntry( + config_entry_id="mock-config-entry", id="device-has-labels-id", labels={"label1", "label2"}, area_id=area_with_labels.id, @@ -988,3 +1006,94 @@ async def test_async_track_target_selector_no_on_entities_update( assert len(events) == 1 unsub() + + +COMPOSITE_ID = "composite0000000000000000000000" + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_target_trickle_down_to_splits( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Targeting the legacy id reaches the split devices' entities.""" + entry_a = MockConfigEntry(domain="domain_a") + entry_a.add_to_hass(hass) + entry_b = MockConfigEntry(domain="domain_b") + entry_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + { + "area_id": "area_1", + "config_entries": [entry_a.entry_id, entry_b.entry_id], + "config_entries_subentries": { + entry_a.entry_id: [None], + entry_b.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": None, + "hw_version": None, + "id": COMPOSITE_ID, + "identifiers": [["domain_a", "1"], ["domain_b", "1"]], + "labels": ["lab"], + "manufacturer": "man", + "model": "mod", + "name": "composite", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": "custom name", + "primary_config_entry": entry_a.entry_id, + "serial_number": "SERIAL", + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "data": { + "entities": [ + { + "entity_id": "sensor.a", + "platform": "domain_a", + "unique_id": "a", + "config_entry_id": entry_a.entry_id, + "device_id": COMPOSITE_ID, + }, + { + "entity_id": "sensor.b", + "platform": "domain_b", + "unique_id": "b", + "config_entry_id": entry_b.entry_id, + "device_id": COMPOSITE_ID, + }, + ] + }, + } + + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + device_registry = dr.async_get(hass) + + selected = target.async_extract_referenced_entity_ids( + hass, target.TargetSelection({"device_id": COMPOSITE_ID}) + ) + assert COMPOSITE_ID not in selected.missing_devices + splits = { + d.id + for d in device_registry.async_get_devices_for_composite_device_id(COMPOSITE_ID) + } + # The composite id resolves to its splits only; it is not itself referenced (it is not + # a real device), so a device-id consumer does not act on the same device twice. + assert selected.referenced_devices == splits + assert COMPOSITE_ID not in selected.referenced_devices + assert selected.indirectly_referenced == {"sensor.a", "sensor.b"} diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 3704dbdef4ba..23b77b056db5 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -5896,6 +5896,23 @@ def mock_test_modern_trigger(hass: HomeAssistant) -> None: ["calendar.x"], id="calendar", ), + pytest.param( + {"platform": "time", "at": "05:00:00"}, + [], + id="time-plain", + ), + pytest.param( + { + "platform": "time", + "at": [ + "05:00:00", + "input_datetime.alarm", + {"entity_id": "sensor.next_alarm", "offset": "-00:05:00"}, + ], + }, + ["input_datetime.alarm", "sensor.next_alarm"], + id="time-entities", + ), pytest.param( { "platform": "zone", @@ -6003,6 +6020,54 @@ async def test_async_extract_entities( assert trigger.async_extract_entities(trigger_conf) == expected +@pytest.mark.parametrize( + ("trigger_conf", "expected"), + [ + pytest.param( + { + "platform": "device", + "device_id": "abcdefgh", + "domain": "light", + "entity_id": "light.kitchen", + "type": "turned_on", + }, + ["light.kitchen"], + id="resolved-entity-id", + ), + pytest.param( + { + "platform": "device", + "device_id": "abcdefgh", + "domain": "light", + "entity_id": "1234567890abcdef1234567890abcdef", + "type": "turned_on", + }, + [], + id="unresolved-registry-id", + ), + pytest.param( + { + "platform": "device", + "device_id": "abcdefgh", + "domain": "sensor", + "type": "battery_level", + }, + [], + id="no-entity-id", + ), + ], +) +def test_async_extract_entities_device_trigger( + trigger_conf: dict[str, Any], expected: list[str] +) -> None: + """Test extracting entities from device trigger configs. + + Validation resolves the entity registry id to an entity id; extraction + ignores unresolved registry ids. + """ + assert trigger.async_extract_entities(trigger_conf) == expected + + _MOCK_DEVICE_ID = "_mock_device_id_" diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 93ab1de77f68..aedeb1ae6cbd 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -1097,25 +1097,32 @@ async def test_config_entry( not in caplog.text ) - # Default without context should log a warning + # Default without context should raise caplog.clear() - crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") - assert crd.config_entry is None - assert ( - "Detected that integration 'my_integration' relies on ContextVar, " - "but should pass the config entry explicitly." - ) in caplog.text + crd = None + with pytest.raises( + RuntimeError, + match=( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ), + ): + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd is None - # Default with context should log a warning + # Default with context should raise caplog.clear() frame._REPORTED_INTEGRATIONS.clear() config_entries.current_entry.set(entry) - crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") - assert ( - "Detected that integration 'my_integration' relies on ContextVar, " - "but should pass the config entry explicitly." - ) in caplog.text - assert crd.config_entry is entry + with pytest.raises( + RuntimeError, + match=( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ), + ): + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd is None @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) diff --git a/tests/pylint/config_flow/test_serial_port_usb_dependency.py b/tests/pylint/config_flow/test_serial_port_usb_dependency.py new file mode 100644 index 000000000000..8d637cca07b7 --- /dev/null +++ b/tests/pylint/config_flow/test_serial_port_usb_dependency.py @@ -0,0 +1,112 @@ +"""Tests for the serial_port_selector usb dependency pylint plugin.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import astroid +from pylint.checkers import BaseChecker +from pylint.testutils.unittest_linter import UnittestLinter +import pytest + +from tests.pylint import assert_no_messages, walk_checker + + +def _write_integration(tmp_path: Path, domain: str, manifest: dict) -> Path: + """Create an integration directory with a manifest and config_flow.""" + integration_dir = tmp_path / "homeassistant" / "components" / domain + integration_dir.mkdir(parents=True) + (integration_dir / "config_flow.py").touch() + (integration_dir / "manifest.json").write_text(json.dumps(manifest)) + return integration_dir + + +def test_serial_port_selector_with_usb_dependency( + linter: UnittestLinter, + enforce_serial_port_selector_usb_checker: BaseChecker, + tmp_path: Path, +) -> None: + """A config flow declaring usb as a hard dependency is not flagged.""" + integration_dir = _write_integration( + tmp_path, "my_device", {"domain": "my_device", "dependencies": ["usb"]} + ) + + code = """ + vol.Schema({vol.Required(CONF_PORT): SerialPortSelector()}) + """ + root_node = astroid.parse(code, "homeassistant.components.my_device.config_flow") + root_node.file = str(integration_dir / "config_flow.py") + + with assert_no_messages(linter): + walk_checker(linter, enforce_serial_port_selector_usb_checker, root_node) + + +@pytest.mark.parametrize( + ("code", "module_name"), + [ + pytest.param( + """ + vol.Schema({vol.Required(CONF_HOST): TextSelector()}) + """, + "homeassistant.components.my_device.config_flow", + id="other_selector", + ), + pytest.param( + """ + SerialPortSelector() + """, + "homeassistant.components.my_device.sensor", + id="not_config_flow", + ), + ], +) +def test_serial_port_selector_not_flagged( + linter: UnittestLinter, + enforce_serial_port_selector_usb_checker: BaseChecker, + tmp_path: Path, + code: str, + module_name: str, +) -> None: + """Cases that must not be flagged even without a usb dependency.""" + integration_dir = _write_integration(tmp_path, "my_device", {"domain": "my_device"}) + + root_node = astroid.parse(code, module_name) + root_node.file = str(integration_dir / f"{module_name.rsplit('.', 1)[1]}.py") + + with assert_no_messages(linter): + walk_checker(linter, enforce_serial_port_selector_usb_checker, root_node) + + +@pytest.mark.parametrize( + "manifest", + [ + pytest.param({"domain": "my_device"}, id="no_dependency"), + pytest.param( + {"domain": "my_device", "after_dependencies": ["usb"]}, + id="after_dependencies_only", + ), + ], +) +def test_serial_port_selector_without_usb_dependency( + linter: UnittestLinter, + enforce_serial_port_selector_usb_checker: BaseChecker, + tmp_path: Path, + manifest: dict, +) -> None: + """A config flow without usb as a hard dependency is flagged once.""" + integration_dir = _write_integration(tmp_path, "my_device", manifest) + + code = """ + vol.Schema({ + vol.Required(CONF_PORT): SerialPortSelector(), + vol.Optional(CONF_OTHER): SerialPortSelector(), + }) + """ + root_node = astroid.parse(code, "homeassistant.components.my_device.config_flow") + root_node.file = str(integration_dir / "config_flow.py") + + walk_checker(linter, enforce_serial_port_selector_usb_checker, root_node) + messages = linter.release_messages() + assert len(messages) == 1 + assert messages[0].msg_id == "home-assistant-serial-port-selector-usb-dependency" diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 8ac3720f2eb2..4b60bfbb8d73 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -9,6 +9,9 @@ from pylint_home_assistant.checkers.config_flow.no_name import ( from pylint_home_assistant.checkers.config_flow.no_polling import ( HassEnforceConfigFlowNoPollingChecker, ) +from pylint_home_assistant.checkers.config_flow.serial_port_usb_dependency import ( + HassEnforceSerialPortSelectorUsbChecker, +) from pylint_home_assistant.checkers.config_flow.unique_id_no_ip import ( HassEnforceConfigEntryUniqueIdNoIpChecker, ) @@ -95,6 +98,17 @@ def enforce_config_entry_unique_id_no_ip_checker_fixture( return checker +@pytest.fixture(name="enforce_serial_port_selector_usb_checker") +def enforce_serial_port_selector_usb_checker_fixture( + linter: UnittestLinter, +) -> BaseChecker: + """Fixture to provide a serial_port_selector usb dependency checker.""" + clear_caches() + checker = HassEnforceSerialPortSelectorUsbChecker(linter) + checker.module = "homeassistant.components.pylint_test" + return checker + + @pytest.fixture(name="enforce_config_flow_no_name_checker") def enforce_config_flow_no_name_checker_fixture( linter: UnittestLinter, diff --git a/tests/pylint/test_runtime_data.py b/tests/pylint/test_runtime_data.py index 7230b596d355..e62efe9247f7 100644 --- a/tests/pylint/test_runtime_data.py +++ b/tests/pylint/test_runtime_data.py @@ -93,6 +93,28 @@ from . import assert_no_messages, walk_checker "homeassistant.components.test", id="pop_from_hass_data", ), + pytest.param( + """ + hass.data.pop(DOMAIN) + """, + "homeassistant.components.test", + id="pop_hass_data_domain", + ), + pytest.param( + """ + if DOMAIN in hass.data: + pass + """, + "homeassistant.components.test", + id="domain_in_hass_data", + ), + pytest.param( + """ + hass.data.setdefault(OTHER_KEY, {}) + """, + "homeassistant.components.test", + id="setdefault_non_domain_key", + ), ], ) def test_enforce_runtime_data( @@ -147,6 +169,34 @@ def test_enforce_runtime_data( "homeassistant.components.test", id="async_setup_entry", ), + pytest.param( + """ + hass.data.setdefault(DOMAIN, {}) + """, + "homeassistant.components.test", + id="setdefault_hass_data_domain", + ), + pytest.param( + """ + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = some_value + """, + "homeassistant.components.test", + id="setdefault_hass_data_domain_nested", + ), + pytest.param( + """ + value = hass.data.get(DOMAIN) + """, + "homeassistant.components.test.sensor", + id="get_hass_data_domain", + ), + pytest.param( + """ + value = self.hass.data.setdefault(DOMAIN, {}) + """, + "homeassistant.components.test.coordinator", + id="self_setdefault_hass_data_domain", + ), ], ) def test_enforce_runtime_data_bad( diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr index e50a244d7895..9b296dffe0df 100644 --- a/tests/snapshots/test_bootstrap.ambr +++ b/tests/snapshots/test_bootstrap.ambr @@ -61,6 +61,7 @@ 'labs', 'lawn_mower', 'light', + 'llm', 'lock', 'logger', 'lovelace', @@ -99,6 +100,7 @@ 'update', 'vacuum', 'valve', + 'vibration', 'wake_word', 'water_heater', 'weather', @@ -169,6 +171,7 @@ 'labs', 'lawn_mower', 'light', + 'llm', 'lock', 'logger', 'lovelace', @@ -207,6 +210,7 @@ 'update', 'vacuum', 'valve', + 'vibration', 'wake_word', 'water_heater', 'weather', diff --git a/tests/syrupy.py b/tests/syrupy.py index a87799631098..aa1b5446e942 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -3,22 +3,14 @@ from contextlib import suppress import dataclasses from enum import IntFlag -import json -import os from pathlib import Path from typing import Any import attr import attrs -import pytest -from syrupy.constants import EXIT_STATUS_FAIL_UNUSED -from syrupy.data import Snapshot, SnapshotCollection, SnapshotCollections from syrupy.extensions.amber import AmberDataSerializer, AmberSnapshotExtension from syrupy.location import PyTestLocation -from syrupy.report import SnapshotReport -from syrupy.session import ItemStatus, SnapshotSession from syrupy.types import PropertyFilter, PropertyMatcher, PropertyPath, SerializableData -from syrupy.utils import is_xdist_controller, is_xdist_worker import voluptuous as vol import voluptuous_serialize @@ -44,6 +36,17 @@ ANY = _ANY() __all__ = ["HomeAssistantSnapshotExtension"] +# DeviceEntry attributes that are internal bookkeeping and should not appear in snapshots. +# Underscore attributes (_cache, _suggested_area and the transient _pending_move / +# _composite_subentries) are excluded separately. The composite-device migration +# attributes below can be removed in HA Core 2027.8. +_INTERNAL_DEVICE_ENTRY_ATTRIBUTES = ( + "composite_device_id", + "composite_primary_config_entry", + "has_composite_identifiers", + "split_at", +) + class AreaRegistryEntrySnapshot(dict): """Tiny wrapper to represent an area registry entry in snapshots.""" @@ -158,21 +161,25 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): cls, data: dr.DeviceEntry ) -> SerializableData: """Prepare a Home Assistant device registry entry for serialization.""" + # Exclude internal attributes (caches, transient move state, and the + # composite-device migration bookkeeping) from the snapshot serialized = DeviceRegistryEntrySnapshot( - attrs.asdict(data) - | { - "config_entries": ANY, - "config_entries_subentries": ANY, - "id": ANY, - } + attr.asdict( + data, + retain_collection_types=True, + filter=lambda attribute, _: ( + not attribute.name.startswith("_") + and attribute.name not in _INTERNAL_DEVICE_ENTRY_ATTRIBUTES + ), + ) + | {"id": ANY} ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - if serialized["primary_config_entry"] is not None: - serialized["primary_config_entry"] = ANY - serialized.pop("_cache") - # This can be removed when suggested_area is removed from DeviceEntry - serialized.pop("_suggested_area") + + serialized["config_entry_id"] = ANY + serialized["config_subentry_id"] = ANY + return cls._remove_created_and_modified_at(serialized) @classmethod @@ -272,164 +279,3 @@ class HomeAssistantSnapshotExtension(AmberSnapshotExtension): """ test_dir = Path(test_location.filepath).parent return str(test_dir.joinpath("snapshots")) - - -# Classes and Methods to override default finish behavior in syrupy -# This is needed to handle the xdist plugin in pytest -# The default implementation does not handle the xdist plugin -# and will not work correctly when running tests in parallel -# with pytest-xdist. -# Temporary workaround until it is finalised inside syrupy -# See https://github.com/syrupy-project/syrupy/pull/901 - - -class _FakePytestObject: - """Fake object.""" - - def __init__(self, collected_item: dict[str, str]) -> None: - """Initialise fake object.""" - self.__module__ = collected_item["modulename"] - self.__name__ = collected_item["methodname"] - - -class _FakePytestItem: - """Fake pytest.Item object.""" - - def __init__(self, collected_item: dict[str, str]) -> None: - """Initialise fake pytest.Item object.""" - self.nodeid = collected_item["nodeid"] - self.name = collected_item["name"] - self.path = Path(collected_item["path"]) - self.obj = _FakePytestObject(collected_item) - - -def _serialize_collections(collections: SnapshotCollections) -> dict[str, Any]: - return { - k: [c.name for c in v] for k, v in collections._snapshot_collections.items() - } - - -def _serialize_report( - report: SnapshotReport, - collected_items: set[pytest.Item], - selected_items: dict[str, ItemStatus], -) -> dict[str, Any]: - return { - "discovered": _serialize_collections(report.discovered), - "created": _serialize_collections(report.created), - "failed": _serialize_collections(report.failed), - "matched": _serialize_collections(report.matched), - "updated": _serialize_collections(report.updated), - "used": _serialize_collections(report.used), - "_collected_items": [ - { - "nodeid": c.nodeid, - "name": c.name, - "path": str(c.path), - "modulename": c.obj.__module__, - "methodname": c.obj.__name__, - } - for c in list(collected_items) - ], - "_selected_items": { - key: status.value for key, status in selected_items.items() - }, - } - - -def _merge_serialized_collections( - collections: SnapshotCollections, json_data: dict[str, list[str]] -) -> None: - if not json_data: - return - for location, names in json_data.items(): - snapshot_collection = SnapshotCollection(location=location) - for name in names: - snapshot_collection.add(Snapshot(name)) - collections.update(snapshot_collection) - - -def _merge_serialized_report(report: SnapshotReport, json_data: dict[str, Any]) -> None: - _merge_serialized_collections(report.discovered, json_data["discovered"]) - _merge_serialized_collections(report.created, json_data["created"]) - _merge_serialized_collections(report.failed, json_data["failed"]) - _merge_serialized_collections(report.matched, json_data["matched"]) - _merge_serialized_collections(report.updated, json_data["updated"]) - _merge_serialized_collections(report.used, json_data["used"]) - for collected_item in json_data["_collected_items"]: - custom_item = _FakePytestItem(collected_item) - if not any( - t.nodeid == custom_item.nodeid and t.name == custom_item.nodeid - for t in report.collected_items - ): - report.collected_items.add(custom_item) - for key, selected_item in json_data["_selected_items"].items(): - if key in report.selected_items: - status = ItemStatus(selected_item) - if status is not ItemStatus.NOT_RUN: - report.selected_items[key] = status - else: - report.selected_items[key] = ItemStatus(selected_item) - - -def override_syrupy_finish(self: SnapshotSession) -> int: - """Override the finish method to allow for custom handling.""" - exitstatus = 0 - self.flush_snapshot_write_queue() - self.report = SnapshotReport( - base_dir=self.pytest_session.config.rootpath, - collected_items=self._collected_items, - selected_items=self._selected_items, - assertions=self._assertions, - options=self.pytest_session.config.option, - ) - - needs_xdist_merge = self.update_snapshots or bool( - self.pytest_session.config.option.include_snapshot_details - ) - - if is_xdist_worker(): - if not needs_xdist_merge: - return exitstatus - with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: - f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) - with open( - f".pytest_syrupy_{os.getenv('PYTEST_XDIST_WORKER')}_result", - "w", - encoding="utf-8", - ) as f: - json.dump( - _serialize_report( - self.report, self._collected_items, self._selected_items - ), - f, - indent=2, - ) - return exitstatus - if is_xdist_controller(): - return exitstatus - - if needs_xdist_merge: - worker_count = None - try: - with open(".pytest_syrupy_worker_count", encoding="utf-8") as f: - worker_count = f.read() - os.remove(".pytest_syrupy_worker_count") - except FileNotFoundError: - pass - - if worker_count: - for i in range(int(worker_count)): - with open(f".pytest_syrupy_gw{i}_result", encoding="utf-8") as f: - _merge_serialized_report(self.report, json.load(f)) - os.remove(f".pytest_syrupy_gw{i}_result") - - if self.report.num_unused: - if self.update_snapshots: - self.remove_unused_snapshots( - unused_snapshot_collections=self.report.unused, - used_snapshot_collections=self.report.used, - ) - elif not self.warn_unused_snapshots: - exitstatus |= EXIT_STATUS_FAIL_UNUSED - return exitstatus diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index e509788053f6..68c434592c32 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -76,7 +76,7 @@ def disable_block_async_io(disable_block_async_io): def mock_http_start_stop() -> Generator[None]: """Mock HTTP start and stop.""" with ( - patch("homeassistant.components.http.start_http_server_and_save_config"), + patch("homeassistant.components.http.HomeAssistantHTTP.start"), patch("homeassistant.components.http.HomeAssistantHTTP.stop"), ): yield @@ -619,9 +619,11 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert "recorder" in hass.config.components assert "http" in hass.config.components - assert order == [ - "http", - "an_after_dep", + # http (a dependency) and an_after_dep (an after_dependency) are both set + # up in the frontend substage of stage 0; their relative order depends on + # set iteration order and is not guaranteed. + assert set(order[:2]) == {"http", "an_after_dep"} + assert order[2:] == [ "frontend", "recorder", "normal_integration", diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 62ebc4de916f..d2b672d500be 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6260,6 +6260,57 @@ async def test_loading_old_data( assert entry.pref_disable_new_entities is True +async def test_async_initialize_sets_event_with_empty_store( + hass: HomeAssistant, +) -> None: + """The initialized event is set when there is no stored data to load. + + The device registry waits on this event during its own load. + """ + manager = config_entries.ConfigEntries(hass, {}) + assert not manager._initialized.is_set() + + with patch.object(manager._store, "async_load", return_value=None): + await manager.async_initialize() + + assert manager._initialized.is_set() + await manager.async_wait_initialized() + assert manager.async_entries() == [] + + +async def test_async_initialize_sets_event_with_existing_store( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """The initialized event is set when loading an existing store. + + The device registry waits on this event during its own load. + """ + hass_storage[config_entries.STORAGE_KEY] = { + "version": 1, + "data": { + "entries": [ + { + "version": 5, + "domain": "my_domain", + "entry_id": "mock-id", + "data": {"my": "data"}, + "source": "user", + "title": "Mock title", + "system_options": {"disable_new_entities": True}, + } + ] + }, + } + manager = config_entries.ConfigEntries(hass, {}) + assert not manager._initialized.is_set() + + await manager.async_initialize() + + assert manager._initialized.is_set() + await manager.async_wait_initialized() + assert len(manager.async_entries()) == 1 + + async def test_deprecated_disabled_by_str_ctor() -> None: """Test deprecated str disabled_by constructor enumizes and logs a warning.""" with pytest.raises(