Merge branch 'dev' into frenck/recorder-purge-skip-empty-cycle

This commit is contained in:
Franck Nijhof
2026-07-17 21:15:13 +02:00
committed by GitHub
2091 changed files with 120735 additions and 24000 deletions
@@ -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.
+5 -5
View File
@@ -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.
+3
View File
@@ -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/<base>`, then `origin/<base>`, then local `<base>`) 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
-49
View File
@@ -1,49 +0,0 @@
<!-- READ THIS FIRST:
- If you need additional help with this template, please refer to https://www.home-assistant.io/help/reporting_issues/
- Make sure you are running the latest version of Home Assistant before reporting an issue: https://github.com/home-assistant/core/releases
- Do not report issues for integrations if you are using custom components or integrations.
- Provide as many details as possible. Paste logs, configuration samples and code into the backticks.
DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed without comment.
-->
## The problem
<!--
Describe the issue you are experiencing here to communicate to the
maintainers. Tell us what you were trying to do and what happened.
-->
## Environment
<!--
Provide details about the versions you are using, which helps us to reproduce
and find the issue quicker. Version information is found in the
Home Assistant frontend: Settings -> About.
-->
- 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`
<!--
An example configuration that caused the problem for you. Fill this out even
if it seems unimportant to you. Please be sure to remove personal information
like passwords, private URLs and other credentials.
-->
```yaml
```
## Traceback/Error logs
<!--
If you come across any trace or error logs, please provide them.
-->
```txt
```
## Additional information
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 102 KiB

+125
View File
@@ -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
<!--
You are amazing! Thanks for contributing to our project!
Please, DO NOT DELETE ANY TEXT from this template! (unless instructed).
-->
## Breaking change
<!--
If your PR contains a breaking change for existing users, it is important
to tell them what breaks, how to make it work again and why we did this.
This piece of text is published with the release notes, so it helps if you
write it towards our users, not us.
Note: Remove this section if this PR is NOT a breaking change.
-->
## Proposed change
<!--
Describe the big picture of your changes here to communicate to the
maintainers why we should accept this pull request. If it fixes a bug
or resolves a feature request, be sure to link to that issue in the
additional information section.
-->
## Type of change
<!--
What type of change does your PR introduce to Home Assistant?
NOTE: Please, check only 1! box!
If your PR requires multiple boxes to be checked, you'll most likely need to
split it into multiple PRs. This makes things easier and faster to code review.
-->
- [ ] 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
<!--
Details are important, and help maintainers processing your PR.
Please be sure to fill out additional details, if applicable.
-->
- 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
<!--
Put an `x` in the boxes that apply. You can also fill these out after
creating the PR. If you're unsure about any of them, don't hesitate to ask.
We're here to help! This is simply a reminder of what we are going to look
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
-->
- [ ] I understand the code I am submitting and can explain how it works.
- [ ] The code change is tested and works locally.
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
- [ ] There is no commented out code in this PR.
- [ ] 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.
<!--
This project is very active and we have a high turnover of pull requests.
Unfortunately, the number of incoming pull requests is higher than what our
reviewers can review and merge so there is a long backlog of pull requests
waiting for review. You can help here!
By reviewing another pull request, you will help raise the code quality of
that pull request and the final review will be faster. This way the general
pace of pull request reviews will go up and your wait time will go down.
When picking a pull request to review, try to choose one that hasn't yet
been reviewed.
Thanks for helping out!
-->
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
<!--
Thank you for contributing <3
Below, some useful links you could explore:
-->
[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
+7 -7
View File
@@ -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
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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"
+95
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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/
+1 -1
View File
@@ -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:
+1
View File
@@ -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
+7
View File
@@ -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.*
Generated
+39 -14
View File
@@ -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
+3 -7
View File
@@ -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)
+1
View File
@@ -8,6 +8,7 @@
"google_cloud",
"google_drive",
"google_generative_ai_conversation",
"google_health",
"google_mail",
"google_maps",
"google_photos",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "mitsubishi",
"name": "Mitsubishi",
"integrations": ["melcloud", "mitsubishi_comfort"]
"integrations": ["melcloud", "melcloud_home", "mitsubishi_comfort"]
}
@@ -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,
+1 -1
View File
@@ -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
@@ -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 (
@@ -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
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.18"]
"requirements": ["aioacaia==0.2.0"]
}
+2 -1
View File
@@ -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,
@@ -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,
@@ -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
@@ -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)
@@ -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
@@ -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:
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyairnow"],
"requirements": ["pyairnow==1.3.1"]
"requirements": ["pyairnow==1.4.0"]
}
+1 -9
View File
@@ -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,
@@ -13,5 +13,5 @@
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "platinum",
"requirements": ["pyairobotrest==0.3.0"]
"requirements": ["pyairobotrest==0.4.0"]
}
+6 -17
View File
@@ -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,
@@ -26,9 +26,6 @@
"mold": {
"name": "Mold"
},
"radon": {
"name": "Radon"
},
"virus_risk": {
"name": "Virus Risk"
}
@@ -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
@@ -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(
@@ -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"
}
@@ -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)
+7 -6
View File
@@ -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)
@@ -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)
@@ -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"])
@@ -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
@@ -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)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.1.8"]
"requirements": ["aioamazondevices==14.2.0"]
}
@@ -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)
@@ -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
),
@@ -51,7 +51,7 @@ rules:
status: done
comment: |
The coordinator handles this.
parallel-updates: todo
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
@@ -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):
@@ -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,
}
+4 -15
View File
@@ -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)
@@ -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]),
+21 -4
View File
@@ -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"
@@ -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"]
}
+11 -7
View File
@@ -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:
@@ -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
+103 -29
View File
@@ -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
@@ -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)
@@ -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"
@@ -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"]
}
+14 -38
View File
@@ -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()
@@ -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"
}
}
}
+24 -50
View File
@@ -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
+3 -10
View File
@@ -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:
@@ -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
@@ -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)
+10 -10
View File
@@ -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,
}
+10 -4
View File
@@ -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
]
+4 -4
View File
@@ -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:
+1 -1
View File
@@ -1,4 +1,4 @@
"""The aurora component."""
"""The Aurora integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -1,4 +1,4 @@
"""The aurora component."""
"""The Aurora integration."""
from datetime import timedelta
import logging
+1 -1
View File
@@ -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
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==72"],
"requirements": ["axis==74"],
"ssdp": [
{
"manufacturer": "AXIS"
@@ -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
@@ -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)
@@ -29,6 +29,7 @@ BINARY_SENSOR_TYPES = (
),
BinarySensorEntityDescription(
key="input",
translation_key="input",
),
)
@@ -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:
@@ -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" },
+1 -1
View File
@@ -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)
@@ -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
@@ -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"
]
}
@@ -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)
@@ -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]]):
+2 -2
View File
@@ -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]
@@ -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:
+7 -4
View File
@@ -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)
+25 -6
View File
@@ -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
@@ -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*",
+68 -95
View File
@@ -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(
@@ -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:
@@ -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"
},
+29 -12
View File
@@ -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:
@@ -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)
+1
View File
@@ -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
+106
View File
@@ -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)])
+12 -6
View File
@@ -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:
+1 -1
View File
@@ -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"]
}
+1 -23
View File
@@ -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:
@@ -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,
@@ -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)

Some files were not shown because too many files have changed in this diff Show More