mirror of
https://github.com/home-assistant/core.git
synced 2026-06-03 18:03:43 +02:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af08e5e7d0 | |||
| b03d87dc21 | |||
| d8a9ea1d9d | |||
| 5ff07fcc49 | |||
| 6f59bb0661 | |||
| c82d32bbae | |||
| 4fbc363965 | |||
| 8622f0f4de | |||
| b49a6b89b6 | |||
| 0bfd4c44bb | |||
| c09216650f | |||
| 6057d32636 | |||
| 51c9d0c6e5 | |||
| 323304664e | |||
| 3dda7d9848 | |||
| 5e56d74257 | |||
| e5f9c7892a | |||
| a0d713a4a7 | |||
| 84f4f876b1 | |||
| 7b06228a5a | |||
| 06b2ec22f0 | |||
| 7950998083 | |||
| 86999063d7 | |||
| 9843fdad2c | |||
| e53914a0ef | |||
| f7afe22318 | |||
| acfecd7f5c | |||
| 56057a11e6 | |||
| 0d079c57e4 | |||
| 3ad3e1fafb | |||
| 0677ed824f | |||
| 4b9945e012 | |||
| 9fa0132b1c | |||
| 10a25368a0 | |||
| fbb68c26b6 | |||
| 25875de414 | |||
| 22ace88b2c | |||
| a47105d314 | |||
| b50bfda00c | |||
| 0d37319ba9 | |||
| 24a5c75cf2 | |||
| dd43b1135d | |||
| de0a202c4e | |||
| d550d1da90 | |||
| ce8875ae8c | |||
| 3364096b2b | |||
| c2b75b9634 | |||
| ae278d3c80 | |||
| 25f9cd9ab8 | |||
| 796d82d6ed | |||
| 4b517fb164 | |||
| 2d74091a36 | |||
| 504e22ee3e | |||
| c95a39c26e | |||
| 8ec3eac705 | |||
| 589d2637c9 | |||
| 26cf728165 | |||
| b61559bdbb | |||
| 57259132d9 | |||
| 2776e966ff | |||
| 5f9872886d | |||
| f728a1bf09 | |||
| df65132268 | |||
| c13822b776 | |||
| c6d696db0c | |||
| 114c9bbafa | |||
| 323ce99fda | |||
| 7a7ef85db2 | |||
| 7ab402618d | |||
| aa87295a1e | |||
| 3bd979e976 | |||
| 9dddf76548 | |||
| 1828579f03 | |||
| 47bca8d8c2 | |||
| 6f3fb5c7bd | |||
| d9b4b5b3d0 | |||
| 342b364af6 | |||
| 951cd71741 | |||
| e86a54f81c | |||
| ba8b33e1a9 | |||
| b6c40ba3fc | |||
| f2f29c07c7 | |||
| 50a3ab115d | |||
| c204054847 | |||
| 28d6eab2dd | |||
| 6b1ee57bd5 | |||
| 7247f95b05 | |||
| cdeafdfd42 | |||
| 9d60fce72e | |||
| 2e4c6c4370 | |||
| b7e36e297b | |||
| 7e178efe63 | |||
| 38f25c4b41 | |||
| 2c2e70a11c | |||
| 190350aec3 | |||
| a87083b6c1 | |||
| d5be54fd40 | |||
| 46f2ad9eb2 | |||
| add75622d6 | |||
| 2f334d657d | |||
| fd69d384be | |||
| fce17c8e6f |
@@ -24,7 +24,6 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -344,13 +344,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -380,7 +380,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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -394,7 +394,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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -523,14 +523,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -39,7 +39,7 @@ on:
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.7"
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
+67
-25
@@ -20,36 +20,22 @@ jobs:
|
||||
permissions:
|
||||
issues: write # To label and close stale issues
|
||||
pull-requests: write # To label and close stale PRs
|
||||
actions: write # To delete stalebot state
|
||||
steps:
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 60 day stale policy for PRs
|
||||
# Used for:
|
||||
# - PRs
|
||||
# - No PRs marked as no-stale
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
- name: 60 days stale PRs policy and 90 days stale issue policy
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
operations-per-run: 150
|
||||
remove-stale-when-updated: true
|
||||
operations-per-run: 350
|
||||
# pr policy
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 7
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "no-stale"
|
||||
stale-pr-message: >
|
||||
@@ -62,9 +48,65 @@ jobs:
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
# issue policy
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
# This is only used for issues.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest Home Assistant version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
# The 30 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues that are pending more information (incomplete issues)
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
days-before-stale: 14
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted"
|
||||
stale-issue-message: >
|
||||
|
||||
@@ -286,7 +286,6 @@ homeassistant.components.huawei_lte.*
|
||||
homeassistant.components.humidifier.*
|
||||
homeassistant.components.husqvarna_automower.*
|
||||
homeassistant.components.huum.*
|
||||
homeassistant.components.hvv_departures.*
|
||||
homeassistant.components.hydrawise.*
|
||||
homeassistant.components.hyperion.*
|
||||
homeassistant.components.hypontech.*
|
||||
|
||||
Generated
-6
@@ -501,8 +501,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
|
||||
/homeassistant/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/tests/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50 @roberty99
|
||||
@@ -720,8 +718,6 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
/homeassistant/components/hegel/ @boazca
|
||||
/tests/components/hegel/ @boazca
|
||||
/homeassistant/components/helty/ @ebaschiera
|
||||
/tests/components/helty/ @ebaschiera
|
||||
/homeassistant/components/heos/ @andrewsayre
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
@@ -840,8 +836,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/imgw_pib/ @bieniu
|
||||
/homeassistant/components/immich/ @mib1185
|
||||
/tests/components/immich/ @mib1185
|
||||
/homeassistant/components/imou/ @Imou-OpenPlatform
|
||||
/tests/components/imou/ @Imou-OpenPlatform
|
||||
/homeassistant/components/improv_ble/ @emontnemery
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
|
||||
@@ -92,7 +92,8 @@ def _extract_backup(
|
||||
):
|
||||
ostf.tar.extractall(
|
||||
path=Path(tempdir, "extracted"),
|
||||
filter="tar",
|
||||
members=securetar.secure_path(ostf.tar),
|
||||
filter="fully_trusted",
|
||||
)
|
||||
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
||||
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
||||
@@ -118,7 +119,8 @@ def _extract_backup(
|
||||
) as istf:
|
||||
istf.extractall(
|
||||
path=Path(tempdir, "homeassistant"),
|
||||
filter="tar",
|
||||
members=securetar.secure_path(istf),
|
||||
filter="fully_trusted",
|
||||
)
|
||||
if restore_content.restore_homeassistant:
|
||||
keep = list(KEEP_BACKUPS)
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
PERCENTAGE,
|
||||
UV_INDEX,
|
||||
UnitOfIrradiance,
|
||||
@@ -46,8 +47,6 @@ from .coordinator import (
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
PARTS_PER_CUBIC_METER = "p/m³"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AccuWeatherSensorDescription(SensorEntityDescription):
|
||||
@@ -82,7 +81,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Grass",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -108,7 +107,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Mold",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -117,7 +116,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Ragweed",
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
@@ -185,7 +184,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Tree",
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
"requirements": ["aioamazondevices==13.8.2"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
@@ -37,6 +38,18 @@ STANDARD_SUPPORTED_FEATURES = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
|
||||
"""Describes an Alexa Devices media player entity."""
|
||||
|
||||
|
||||
MEDIA_PLAYERS: Final = (
|
||||
AmazonDevicesMediaPlayerEntityDescription(
|
||||
key="media",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
@@ -56,10 +69,9 @@ async def async_setup_entry(
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.append(
|
||||
AlexaDevicesMediaPlayer(
|
||||
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
|
||||
)
|
||||
new_entities.extend(
|
||||
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||
for description in MEDIA_PLAYERS
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
@@ -73,6 +85,8 @@ async def async_setup_entry(
|
||||
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
"""Representation of an Alexa device media player."""
|
||||
|
||||
entity_description: AmazonDevicesMediaPlayerEntityDescription
|
||||
|
||||
_attr_name = None # Uses the device name
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 0.05
|
||||
@@ -81,7 +95,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: MediaPlayerEntityDescription,
|
||||
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
@@ -200,7 +214,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Content type — tells HA what kind of media is playing."""
|
||||
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
|
||||
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
|
||||
return MediaType.MUSIC
|
||||
return None
|
||||
|
||||
@@ -213,8 +227,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
provider = media_type.value if isinstance(media_type, MediaType) else media_type
|
||||
await self.async_call_alexa_music(media_id, provider)
|
||||
await self.async_call_alexa_music(media_id, media_type)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
@@ -113,7 +114,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import JsonArrayType, JsonObjectType
|
||||
|
||||
from .const import (
|
||||
@@ -371,7 +372,7 @@ def _convert_content( # noqa: C901
|
||||
)
|
||||
if (
|
||||
content.native.container is not None
|
||||
and content.native.container.expires_at > dt_util.utcnow()
|
||||
and content.native.container.expires_at > datetime.now(UTC)
|
||||
):
|
||||
container_id = content.native.container.id
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["apprise"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["apprise==1.11.0"]
|
||||
"requirements": ["apprise==1.9.1"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"codeowners": [],
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/arwn",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["arwn-client==0.2.1"]
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy"
|
||||
}
|
||||
|
||||
@@ -3,26 +3,113 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arwn_client import parse_message
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "arwn"
|
||||
|
||||
DATA_ARWN = "arwn"
|
||||
TOPIC = "arwn/#"
|
||||
|
||||
|
||||
def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None:
|
||||
"""Given a topic, dynamically create the right sensor type.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
parts = topic.split("/")
|
||||
unit = payload.get("units", "")
|
||||
domain = parts[1]
|
||||
if domain == "temperature":
|
||||
name = parts[2]
|
||||
if unit == "F":
|
||||
unit = UnitOfTemperature.FAHRENHEIT
|
||||
else:
|
||||
unit = UnitOfTemperature.CELSIUS
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE
|
||||
)
|
||||
]
|
||||
if domain == "moisture":
|
||||
name = f"{parts[2]} Moisture"
|
||||
return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")]
|
||||
if domain == "rain":
|
||||
if len(parts) >= 3 and parts[2] == "today":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic,
|
||||
"Rain Since Midnight",
|
||||
"since_midnight",
|
||||
UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
)
|
||||
]
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/total",
|
||||
"Total Rainfall",
|
||||
"total",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/rate",
|
||||
"Rainfall Rate",
|
||||
"rate",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
]
|
||||
if domain == "barometer":
|
||||
return [
|
||||
ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines")
|
||||
]
|
||||
if domain == "wind":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/speed",
|
||||
"Wind Speed",
|
||||
"speed",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/gust",
|
||||
"Wind Gust",
|
||||
"gust",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/dir",
|
||||
"Wind Direction",
|
||||
"direction",
|
||||
DEGREE,
|
||||
"mdi:compass",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
]
|
||||
return None
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return f"sensor.arwn_{slugify(name)}"
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -31,25 +118,28 @@ async def async_setup_platform(
|
||||
) -> None:
|
||||
"""Set up the ARWN platform."""
|
||||
|
||||
# Make sure MQTT integration is enabled and the client is available
|
||||
if not await mqtt.async_wait_for_mqtt_client(hass):
|
||||
_LOGGER.error("MQTT integration is not available")
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
|
||||
"""Process MQTT events as sensors."""
|
||||
try:
|
||||
event = json_loads_object(msg.payload)
|
||||
device = parse_message(msg.topic, event)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Failed to parse ARWN message on topic %s",
|
||||
msg.topic,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
"""Process events as sensors.
|
||||
|
||||
if device is None:
|
||||
When a new event on our topic (arwn/#) is received we map it
|
||||
into a known kind of sensor based on topic name. If we've
|
||||
never seen this before, we keep this sensor around in a global
|
||||
cache. If we have seen it before, we update the values of the
|
||||
existing sensor. Either way, we push an ha state update at the
|
||||
end for the new event we've seen.
|
||||
|
||||
This lets us dynamically incorporate sensors without any
|
||||
configuration on our side.
|
||||
"""
|
||||
event = json_loads_object(msg.payload)
|
||||
sensors = discover_sensors(msg.topic, event)
|
||||
if not sensors:
|
||||
return
|
||||
|
||||
if (store := hass.data.get(DATA_ARWN)) is None:
|
||||
@@ -58,71 +148,22 @@ async def async_setup_platform(
|
||||
if "timestamp" in event:
|
||||
del event["timestamp"]
|
||||
|
||||
new_sensors: list[ArwnSensor] = []
|
||||
for reading in device.readings:
|
||||
if not reading.expose:
|
||||
continue
|
||||
|
||||
unique_id = (
|
||||
f"{msg.topic}/{reading.sensor_key}"
|
||||
if len(device.readings) > 1
|
||||
else msg.topic
|
||||
)
|
||||
|
||||
try:
|
||||
device_class = (
|
||||
SensorDeviceClass(reading.device_class)
|
||||
if reading.device_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown device_class=%s for sensor %s",
|
||||
reading.device_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
device_class = None
|
||||
|
||||
try:
|
||||
state_class = (
|
||||
SensorStateClass(reading.state_class)
|
||||
if reading.state_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown state_class=%s for sensor %s",
|
||||
reading.state_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
state_class = None
|
||||
|
||||
if unique_id not in store:
|
||||
sensor = ArwnSensor(
|
||||
unique_id=unique_id,
|
||||
name=reading.sensor_name,
|
||||
state_key=reading.sensor_key,
|
||||
units=reading.unit,
|
||||
icon=reading.icon,
|
||||
device_class=device_class,
|
||||
state_class=state_class,
|
||||
event=event,
|
||||
)
|
||||
store[unique_id] = sensor
|
||||
for sensor in sensors:
|
||||
if sensor.name not in store:
|
||||
sensor.hass = hass
|
||||
sensor.set_event(event)
|
||||
store[sensor.name] = sensor
|
||||
_LOGGER.debug(
|
||||
"Registering sensor %(name)s => %(event)s",
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
{"name": sensor.name, "event": event},
|
||||
)
|
||||
new_sensors.append(sensor)
|
||||
async_add_entities((sensor,), True)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Recording sensor %(name)s => %(event)s",
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
{"name": sensor.name, "event": event},
|
||||
)
|
||||
store[unique_id].set_event(event)
|
||||
|
||||
if new_sensors:
|
||||
async_add_entities(new_sensors, True)
|
||||
store[sensor.name].set_event(event)
|
||||
|
||||
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
|
||||
|
||||
@@ -134,29 +175,29 @@ class ArwnSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
topic: str,
|
||||
name: str,
|
||||
state_key: str,
|
||||
units: str,
|
||||
icon: str | None = None,
|
||||
device_class: SensorDeviceClass | None = None,
|
||||
state_class: SensorStateClass | None = None,
|
||||
event: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_id = _slug(name)
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
# This mqtt topic for the sensor which is its uid
|
||||
self._attr_unique_id = topic
|
||||
self._state_key = state_key
|
||||
self._attr_native_unit_of_measurement = units
|
||||
self._attr_icon = icon
|
||||
self._attr_device_class = device_class
|
||||
self._attr_state_class = state_class
|
||||
if event is not None:
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(state_key)
|
||||
|
||||
def set_event(self, event: dict[str, Any]) -> None:
|
||||
"""Update the sensor with the most recent event."""
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(self._state_key)
|
||||
ev: dict[str, Any] = {}
|
||||
ev.update(event)
|
||||
self._attr_extra_state_attributes = ev
|
||||
self._attr_native_value = ev.get(self._state_key)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
|
||||
import avea
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothReachabilityIntent,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
)
|
||||
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type AveaConfigEntry = ConfigEntry[avea.Bulb]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -21,20 +15,12 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Set up Avea from a config entry."""
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
ble_device = async_ble_device_from_address(hass, address, connectable=True)
|
||||
ble_device = async_ble_device_from_address(
|
||||
hass, entry.data[CONF_ADDRESS], connectable=True
|
||||
)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
|
||||
)
|
||||
|
||||
entry.runtime_data = avea.Bulb(ble_device)
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Avea device with address {address}: {reason}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
|
||||
|
||||
@@ -6,6 +6,7 @@ from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.session import ApiHost
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -17,9 +18,10 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DEFAULT_SETUP_TIMEOUT
|
||||
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
|
||||
from .helpers import get_maybe_authenticated_session
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[Box]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -33,6 +35,8 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bool:
|
||||
"""Set up BleBox devices from a config entry."""
|
||||
@@ -54,10 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
|
||||
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
coordinator = BleBoxCoordinator(hass, entry, product)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
entry.runtime_data = product
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -11,11 +11,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
BINARY_SENSOR_TYPES = (
|
||||
BinarySensorEntityDescription(
|
||||
key="moisture",
|
||||
@@ -30,27 +27,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxBinarySensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("binary_sensors", [])
|
||||
BleBoxBinarySensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("binary_sensors", [])
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity):
|
||||
"""Representation of a BleBox binary sensor feature."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: BinarySensorFeature,
|
||||
description: BinarySensorEntityDescription,
|
||||
self, feature: BinarySensorFeature, description: BinarySensorEntityDescription
|
||||
) -> None:
|
||||
"""Initialize a BleBox binary sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,11 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -20,22 +16,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox button entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxButtonEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("buttons", [])
|
||||
BleBoxButtonEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("buttons", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
|
||||
"""Representation of BleBox buttons."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.button.Button) -> None:
|
||||
"""Initialize a BleBox button feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self._attr_icon = self.get_icon()
|
||||
|
||||
def get_icon(self) -> str | None:
|
||||
@@ -52,7 +45,6 @@ class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity
|
||||
return "mdi:arrow-down-circle"
|
||||
return None
|
||||
|
||||
@blebox_command
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self._feature.set()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox climate entity."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.climate
|
||||
@@ -16,9 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
BLEBOX_TO_HVACMODE = {
|
||||
0: HVACMode.OFF,
|
||||
@@ -40,12 +40,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox climate entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxClimateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("climates", [])
|
||||
BleBoxClimateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("climates", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
|
||||
@@ -109,7 +108,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
"""Return the desired thermostat temperature."""
|
||||
return self._feature.desired
|
||||
|
||||
@blebox_command
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the climate entity mode."""
|
||||
if hvac_mode in [HVACMode.HEAT, HVACMode.COOL]:
|
||||
@@ -118,7 +116,6 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
|
||||
await self._feature.async_off()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the thermostat temperature."""
|
||||
value = kwargs[ATTR_TEMPERATURE]
|
||||
|
||||
@@ -33,14 +33,23 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
STEP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
def create_schema(previous_input=None):
|
||||
"""Create a schema with given values as default."""
|
||||
if previous_input is not None:
|
||||
host = previous_input[CONF_HOST]
|
||||
port = previous_input[CONF_PORT]
|
||||
else:
|
||||
host = DEFAULT_HOST
|
||||
port = DEFAULT_PORT
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=host): str,
|
||||
vol.Required(CONF_PORT, default=port): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LOG_MSG = {
|
||||
@@ -60,44 +69,18 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.device_config: dict[str, Any] = {}
|
||||
|
||||
def handle_step_exception(
|
||||
self, exception, schema, host, port, message_id, log_fn, step_id
|
||||
self, step, exception, schema, host, port, message_id, log_fn
|
||||
):
|
||||
"""Handle step exceptions."""
|
||||
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
errors={"base": message_id},
|
||||
description_placeholders={"address": f"{host}:{port}"},
|
||||
)
|
||||
|
||||
async def _async_from_host_or_form(
|
||||
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
|
||||
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
|
||||
"""Try to connect to the device; return product or an error form."""
|
||||
schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input)
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
try:
|
||||
return await Box.async_from_host(api_host), None
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
|
||||
)
|
||||
except Error as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id
|
||||
)
|
||||
except RuntimeError as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -162,11 +145,12 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle initial user-triggered config step."""
|
||||
hass = self.hass
|
||||
schema = create_schema(user_input)
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_SCHEMA,
|
||||
data_schema=schema,
|
||||
errors={},
|
||||
description_placeholders={},
|
||||
)
|
||||
@@ -189,60 +173,36 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
|
||||
)
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="user"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
try:
|
||||
product = await Box.async_from_host(api_host)
|
||||
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return self.handle_step_exception(
|
||||
"user",
|
||||
ex,
|
||||
schema,
|
||||
host,
|
||||
port,
|
||||
UNSUPPORTED_VERSION,
|
||||
_LOGGER.debug,
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error
|
||||
)
|
||||
|
||||
except Error as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning
|
||||
)
|
||||
|
||||
except RuntimeError as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, UNKNOWN, _LOGGER.error
|
||||
)
|
||||
|
||||
# Check if configured but IP changed since
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=product.name, data=user_input)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of a BleBox device."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_SCHEMA, reconfigure_entry.data
|
||||
),
|
||||
)
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
websession = get_maybe_authenticated_session(self.hass, password, username)
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
|
||||
)
|
||||
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="reconfigure"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
|
||||
if username is not None:
|
||||
data_updates[CONF_USERNAME] = username
|
||||
if password is not None:
|
||||
data_updates[CONF_PASSWORD] = password
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates=data_updates,
|
||||
)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""DataUpdateCoordinator for BleBox devices."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[BleBoxCoordinator]
|
||||
|
||||
|
||||
class BleBoxCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator for a single BleBox device."""
|
||||
|
||||
config_entry: BleBoxConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: BleBoxConfigEntry, box: Box
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=5),
|
||||
)
|
||||
self.box = box
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from the BleBox device."""
|
||||
try:
|
||||
await self.box.async_update_data()
|
||||
except Error as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
@@ -17,11 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BLEBOX_TO_COVER_DEVICE_CLASSES = {
|
||||
"gate": CoverDeviceClass.GATE,
|
||||
@@ -63,22 +59,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxCoverEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("covers", [])
|
||||
BleBoxCoverEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("covers", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Representation of a BleBox cover feature."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||
"""Initialize a BleBox cover feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
@@ -142,40 +135,33 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Return whether cover is closed."""
|
||||
return self._is_state(CoverState.CLOSED)
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover position."""
|
||||
await self._feature.async_open()
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover position."""
|
||||
await self._feature.async_close()
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover tilt."""
|
||||
position = 50 if self._feature.is_tilt_180 else 0
|
||||
await self._feature.async_set_tilt_position(position)
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover tilt."""
|
||||
# note: values are reversed
|
||||
await self._feature.async_set_tilt_position(100)
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Set the cover position."""
|
||||
position = kwargs[ATTR_POSITION]
|
||||
await self._feature.async_set_position(100 - position)
|
||||
|
||||
@blebox_command
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self._feature.async_stop()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Set the tilt position."""
|
||||
position = kwargs[ATTR_TILT_POSITION]
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
"""Base entity for the BleBox devices integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.feature import Feature
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BleBoxCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
"""Implements a common class for entities representing a BleBox feature."""
|
||||
|
||||
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
|
||||
def __init__(self, feature: _FeatureT) -> None:
|
||||
"""Initialize a BleBox entity."""
|
||||
super().__init__(coordinator)
|
||||
self._feature = feature
|
||||
self._attr_name = feature.full_name
|
||||
self._attr_unique_id = feature.unique_id
|
||||
@@ -27,3 +30,10 @@ class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
sw_version=product.firmware_version,
|
||||
configuration_url=f"http://{product.address}",
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity state."""
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
_LOGGER.error("Updating '%s' failed: %s", self.name, ex)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox light entities implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -23,13 +24,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -38,12 +37,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxLightEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("lights", [])
|
||||
BleBoxLightEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("lights", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
COLOR_MODE_MAP = {
|
||||
@@ -63,11 +61,9 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
_attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS
|
||||
_attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
|
||||
"""Initialize a BleBox light."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
if feature.effect_list:
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
@@ -169,7 +165,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
return None
|
||||
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex))
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
|
||||
@@ -229,7 +224,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
" effect list."
|
||||
) from exc
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self._feature.async_off()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import blebox_uniapi.sensor
|
||||
|
||||
@@ -28,10 +28,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
SENSOR_TYPES = (
|
||||
@@ -125,14 +124,13 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("sensors", [])
|
||||
BleBoxSensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("sensors", [])
|
||||
for description in SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
|
||||
@@ -140,12 +138,11 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: blebox_uniapi.sensor.BaseSensor,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a BleBox sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"address_already_configured": "A BleBox device is already configured at {address}.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -13,16 +11,6 @@
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Update the connection settings for your BleBox device.",
|
||||
"title": "Reconfigure BleBox device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
@@ -34,10 +22,5 @@
|
||||
"title": "Set up your BleBox device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"message": "An error occurred while communicating with the BleBox device: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""BleBox switch implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.switch
|
||||
@@ -10,9 +11,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,12 +21,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox switch entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSwitchEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("switches", [])
|
||||
BleBoxSwitchEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("switches", [])
|
||||
]
|
||||
async_add_entities(entities)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity):
|
||||
@@ -39,12 +38,10 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
|
||||
"""Return whether switch is on."""
|
||||
return self._feature.is_on
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self._feature.async_turn_on()
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self._feature.async_turn_off()
|
||||
|
||||
@@ -18,10 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
|
||||
|
||||
@@ -35,12 +33,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox update entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxUpdateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("updates", [])
|
||||
BleBoxUpdateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
||||
]
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
||||
@@ -51,16 +48,9 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return True because firmware versions cannot be fetched via coordinator."""
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.update.Update
|
||||
) -> None:
|
||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(coordinator, feature)
|
||||
super().__init__(feature)
|
||||
self._in_progress_old_version: str | None = None
|
||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
||||
self._poll_attempts: int = 0
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Utilities for BleBox."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
|
||||
def blebox_command[_BleBoxEntityT: BleBoxEntity, **_P, _R](
|
||||
func: Callable[Concatenate[_BleBoxEntityT, _P], Awaitable[_R]],
|
||||
) -> Callable[Concatenate[_BleBoxEntityT, _P], Coroutine[Any, Any, _R]]:
|
||||
"""Decorate BleBox calls that send commands to the device.
|
||||
|
||||
Catches BleBox errors and refreshes the coordinator after the command.
|
||||
"""
|
||||
|
||||
async def handler(self: _BleBoxEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except Error as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
return handler
|
||||
@@ -24,7 +24,6 @@ from homeassistant.components.alexa import (
|
||||
entities as alexa_entities,
|
||||
errors as alexa_errors,
|
||||
)
|
||||
from homeassistant.components.frontend import DATA_THEMES
|
||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
@@ -509,15 +508,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"custom_integrations": custom_integrations,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _get_themes_info(self, hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Collect information about user-installed custom themes."""
|
||||
themes: dict[str, Any] = hass.data.get(DATA_THEMES, {})
|
||||
return {
|
||||
"count": len(themes),
|
||||
"themes": sorted(themes),
|
||||
}
|
||||
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -579,25 +569,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
)
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Add custom themes information
|
||||
try:
|
||||
themes_info = self._get_themes_info(hass)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package generation
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += "Unable to collect themes information\n\n"
|
||||
else:
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += f"Custom themes: {themes_info['count']}\n\n"
|
||||
|
||||
if themes_info["themes"]:
|
||||
markdown += "<details><summary>Custom themes</summary>\n\n"
|
||||
markdown += "Name\n"
|
||||
markdown += "---\n"
|
||||
for theme in themes_info["themes"]:
|
||||
markdown += f"{theme}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
|
||||
}
|
||||
|
||||
@@ -12,19 +12,13 @@ from homeassistant.const import (
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.trigger import (
|
||||
TriggerActionType,
|
||||
TriggerInfo,
|
||||
# protected, but only used for legacy triggers
|
||||
_async_attach_trigger_cls,
|
||||
)
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -85,18 +79,16 @@ async def async_attach_trigger(
|
||||
event = zone.EVENT_ENTER
|
||||
else:
|
||||
event = zone.EVENT_LEAVE
|
||||
zone_config = await zone.LegacyZoneTrigger.async_validate_config(
|
||||
hass,
|
||||
{
|
||||
CONF_OPTIONS: {
|
||||
CONF_ENTITY_ID: [config[CONF_ENTITY_ID]],
|
||||
CONF_ZONE: config[CONF_ZONE],
|
||||
CONF_EVENT: event,
|
||||
}
|
||||
},
|
||||
)
|
||||
return await _async_attach_trigger_cls(
|
||||
hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info
|
||||
|
||||
zone_config = {
|
||||
CONF_PLATFORM: ZONE_DOMAIN,
|
||||
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
|
||||
CONF_ZONE: config[CONF_ZONE],
|
||||
CONF_EVENT: event,
|
||||
}
|
||||
zone_config = await zone.async_validate_trigger_config(hass, zone_config)
|
||||
return await zone.async_attach_trigger(
|
||||
hass, zone_config, action, trigger_info, platform_type="device"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Provide functionality to keep track of devices."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
@@ -23,7 +22,6 @@ from homeassistant.core import (
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
@@ -39,7 +37,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.loader import async_suggest_report_issue
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -55,8 +52,6 @@ from .const import (
|
||||
SourceType,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
||||
|
||||
|
||||
@@ -169,35 +164,11 @@ class BaseTrackerEntity(Entity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_source_type: SourceType
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "battery_level" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated battery_level property on "
|
||||
"a subclass of BaseTrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device.
|
||||
|
||||
Percentage from 0-100.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
return None
|
||||
|
||||
@@ -241,38 +212,13 @@ class TrackerEntity(
|
||||
_attr_in_zones: list[str] | None = None
|
||||
_attr_latitude: float | None = None
|
||||
_attr_location_accuracy: float = 0
|
||||
# _attr_location_name is deprecated and will be removed in Home Assistant 2027.7
|
||||
_attr_location_name: str | None = None
|
||||
_attr_longitude: float | None = None
|
||||
_attr_source_type: SourceType = SourceType.GPS
|
||||
|
||||
__active_zone: State | None = None
|
||||
# If we reported setting deprecated _attr_location_name
|
||||
__deprecated_attr_location_name_reported = False
|
||||
__in_zones: list[str] | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "location_name" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated location_name property on "
|
||||
"an instance of TrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling for entities that have location pushed."""
|
||||
@@ -303,32 +249,7 @@ class TrackerEntity(
|
||||
|
||||
@cached_property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
if (location_name := self._attr_location_name) is not None:
|
||||
if (
|
||||
not self.__deprecated_attr_location_name_reported
|
||||
and not self.__class__.__module__.startswith(
|
||||
"homeassistant.components."
|
||||
)
|
||||
):
|
||||
report_issue = async_suggest_report_issue(
|
||||
self.hass, module=self.__class__.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is setting the deprecated _attr_location_name attribute "
|
||||
"on an instance of TrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
self.__class__.__module__,
|
||||
self.__class__.__name__,
|
||||
report_issue,
|
||||
)
|
||||
self.__deprecated_attr_location_name_reported = True
|
||||
return location_name
|
||||
"""Return a location name for the current location of the device."""
|
||||
return self._attr_location_name
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
|
||||
if warnings is None:
|
||||
return []
|
||||
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
now = datetime.now(UTC)
|
||||
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
|
||||
|
||||
@property
|
||||
|
||||
@@ -106,7 +106,7 @@ async def async_migrate_entry(
|
||||
new_options = {**config_entry.options}
|
||||
|
||||
if config_entry.minor_version < 2:
|
||||
# Add defaults only if they're not already present
|
||||
# Add defaults only if they’re not already present
|
||||
if "stt_auto_language" not in new_options:
|
||||
new_options["stt_auto_language"] = False
|
||||
if "stt_model" not in new_options:
|
||||
|
||||
@@ -221,7 +221,6 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
|
||||
):
|
||||
try:
|
||||
value = float(current_state.state)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
|
||||
client.get_or_create_sensor(energyid_key).update(value, timestamp)
|
||||
except ValueError, TypeError:
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Envertech EVT800 integration."""
|
||||
|
||||
from pyenvertechevt800 import EnvertechEVT800
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
|
||||
type EnvertechEVT800ConfigEntry = ConfigEntry[EnvertechEVT800Coordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Envertech EVT800 from a config entry."""
|
||||
evt800 = EnvertechEVT800(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT])
|
||||
evt800.start()
|
||||
|
||||
coordinator = EnvertechEVT800Coordinator(hass, evt800, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Config flow for the ENVERTECH EVT800 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyenvertechevt800 import EnvertechEVT800
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DEFAULT_PORT, DOMAIN, TYPE_TCP_SERVER_MODE
|
||||
|
||||
SCHEMA_DEVICE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IP_ADDRESS): cv.string,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EnvertechFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Envertech EVT800."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""First step in config flow."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
ip_address = user_input[CONF_IP_ADDRESS]
|
||||
port = user_input[CONF_PORT]
|
||||
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_IP_ADDRESS: ip_address,
|
||||
CONF_PORT: port,
|
||||
}
|
||||
)
|
||||
evt800 = EnvertechEVT800(ip_address, port)
|
||||
|
||||
can_connect = await evt800.test_connection()
|
||||
|
||||
if not can_connect:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title="Envertech EVT800",
|
||||
data={CONF_TYPE: TYPE_TCP_SERVER_MODE, **user_input},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=SCHEMA_DEVICE,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Constants for the ENVERTECH EVT800 integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "envertech_evt800"
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
DEFAULT_PORT = 14889
|
||||
TYPE_TCP_SERVER_MODE = ["TCP_SERVER"]
|
||||
DEFAULT_SCAN_INTERVAL = 60
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Coordinator for Envertech EVT800 integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pyenvertechevt800
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import EnvertechEVT800ConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnvertechEVT800Coordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Data update coordinator for Envertech EVT800."""
|
||||
|
||||
config_entry: EnvertechEVT800ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: pyenvertechevt800.EnvertechEVT800,
|
||||
config_entry: EnvertechEVT800ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.client = client
|
||||
client.set_data_listener(self.async_set_updated_data)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from the device."""
|
||||
return self.client.data
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Envertech EVT800 entity."""
|
||||
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
|
||||
|
||||
class EnvertechEVT800Entity(CoordinatorEntity[EnvertechEVT800Coordinator]):
|
||||
"""Envertech EVT800 entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: EnvertechEVT800Coordinator) -> None:
|
||||
"""Initialize Envertech EVT800 entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
configuration_url=f"http://{coordinator.config_entry.data[CONF_IP_ADDRESS]}/",
|
||||
manufacturer="Envertech",
|
||||
model_id="EVT800",
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.coordinator.client.online
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "envertech_evt800",
|
||||
"name": "ENVERTECH EVT800",
|
||||
"codeowners": ["@daniel-bergmann-00"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/envertech_evt800",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenvertechevt800"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyenvertechevt800==0.2.4"]
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
The integration does not have any authentication.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration connects to a single device
|
||||
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not have any own exceptions.
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not support repairing issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single device per configuration entry.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
No websession is used
|
||||
strict-typing: todo
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Envertech EVT800 sensor."""
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import EnvertechEVT800ConfigEntry
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
from .entity import EnvertechEVT800Entity
|
||||
|
||||
SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="id_1",
|
||||
entity_registry_enabled_default=False,
|
||||
translation_key="mppt_id_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="id_2",
|
||||
entity_registry_enabled_default=False,
|
||||
translation_key="mppt_id_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="input_voltage_1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_key="input_voltage_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="input_voltage_2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_key="input_voltage_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power_1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_display_precision=0,
|
||||
translation_key="power_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power_2",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_display_precision=0,
|
||||
translation_key="power_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_1",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_display_precision=2,
|
||||
translation_key="current_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_2",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_display_precision=2,
|
||||
translation_key="current_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_frequency_1",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
translation_key="ac_frequency_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_frequency_2",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
translation_key="ac_frequency_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_voltage_1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=0,
|
||||
translation_key="ac_voltage_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_voltage_2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=0,
|
||||
translation_key="ac_voltage_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temperature_1",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
suggested_display_precision=1,
|
||||
translation_key="temperature_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temperature_2",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
suggested_display_precision=1,
|
||||
translation_key="temperature_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="total_energy_1",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_display_precision=2,
|
||||
translation_key="total_energy_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="total_energy_2",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_display_precision=2,
|
||||
translation_key="total_energy_2",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: EnvertechEVT800ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Envertech EVT800 sensors."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
EnvertechEVT800Sensor(coordinator, description) for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class EnvertechEVT800Sensor(EnvertechEVT800Entity, SensorEntity):
|
||||
"""Representation of an Envertech EVT800 sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EnvertechEVT800Coordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
return self.coordinator.client.data.get(self.entity_description.key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Unavailable if evt800 isn't connected."""
|
||||
return super().available and self.native_value is not None
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ip_address": "The IP address of your Envertech EVT800 device.",
|
||||
"port": "The Port of your Envertech EVT800 device."
|
||||
},
|
||||
"description": "Enter your EVT800 device information.",
|
||||
"title": "Setup EVT800 device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ac_frequency_1": {
|
||||
"name": "AC Frequency MPPT 1"
|
||||
},
|
||||
"ac_frequency_2": {
|
||||
"name": "AC Frequency MPPT 2"
|
||||
},
|
||||
"ac_voltage_1": {
|
||||
"name": "AC Voltage MPPT 1"
|
||||
},
|
||||
"ac_voltage_2": {
|
||||
"name": "AC Voltage MPPT 2"
|
||||
},
|
||||
"current_1": {
|
||||
"name": "DC Current MPPT 1"
|
||||
},
|
||||
"current_2": {
|
||||
"name": "DC Current MPPT 2"
|
||||
},
|
||||
"input_voltage_1": {
|
||||
"name": "DC Voltage MPPT 1"
|
||||
},
|
||||
"input_voltage_2": {
|
||||
"name": "DC Voltage MPPT 2"
|
||||
},
|
||||
"mppt_id_1": {
|
||||
"name": "MPPT ID 1"
|
||||
},
|
||||
"mppt_id_2": {
|
||||
"name": "MPPT ID 2"
|
||||
},
|
||||
"power_1": {
|
||||
"name": "DC Power MPPT 1"
|
||||
},
|
||||
"power_2": {
|
||||
"name": "DC Power MPPT 2"
|
||||
},
|
||||
"temperature_1": {
|
||||
"name": "Temperature MPPT 1"
|
||||
},
|
||||
"temperature_2": {
|
||||
"name": "Temperature MPPT 2"
|
||||
},
|
||||
"total_energy_1": {
|
||||
"name": "Total Energy MPPT 1"
|
||||
},
|
||||
"total_energy_2": {
|
||||
"name": "Total Energy MPPT 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather
|
||||
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada weather")
|
||||
|
||||
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
|
||||
radar_data = ECRadar(coordinates=(lat, lon))
|
||||
radar_coordinator = ECDataUpdateCoordinator(
|
||||
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for the Environment Canada radar imagery."""
|
||||
|
||||
from env_canada import ECMap
|
||||
from env_canada import ECRadar
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
@@ -11,20 +11,13 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_OBSERVATION_TIME
|
||||
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
|
||||
|
||||
SERVICE_SET_RADAR_TYPE = "set_radar_type"
|
||||
SET_RADAR_TYPE_SCHEMA: VolDictType = {
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow", "Precipitation type"]),
|
||||
}
|
||||
|
||||
_RADAR_TYPE_TO_LAYER: dict[str, str] = {
|
||||
"Rain": "rain",
|
||||
"Snow": "snow",
|
||||
"Precipitation type": "precip_type",
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow"]),
|
||||
}
|
||||
|
||||
|
||||
@@ -45,13 +38,13 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera):
|
||||
"""Implementation of an Environment Canada radar camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "radar"
|
||||
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECMap]) -> None:
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None:
|
||||
"""Initialize the camera."""
|
||||
super().__init__(coordinator)
|
||||
Camera.__init__(self)
|
||||
@@ -83,13 +76,6 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
|
||||
|
||||
async def async_set_radar_type(self, radar_type: str) -> None:
|
||||
"""Set the type of radar to retrieve."""
|
||||
if radar_type == "Auto":
|
||||
# Choose rain for months April through October, snow otherwise
|
||||
layer = "rain" if dt_util.now().month in range(4, 11) else "snow"
|
||||
else:
|
||||
layer = _RADAR_TYPE_TO_LAYER[radar_type]
|
||||
|
||||
# Apply new layer and clear cache to force refresh
|
||||
self.radar_object.layer = layer
|
||||
self.radar_object.clear_cache()
|
||||
await self.coordinator.async_request_refresh()
|
||||
self.radar_object.precip_type = radar_type.lower()
|
||||
await self.radar_object.update()
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -17,7 +17,7 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type ECConfigEntry = ConfigEntry[ECRuntimeData]
|
||||
type ECDataType = ECAirQuality | ECMap | ECWeather
|
||||
type ECDataType = ECAirQuality | ECRadar | ECWeather
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,7 +25,7 @@ class ECRuntimeData:
|
||||
"""Class to hold EC runtime data."""
|
||||
|
||||
aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECMap]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECRadar]
|
||||
weather_coordinator: ECDataUpdateCoordinator[ECWeather]
|
||||
|
||||
|
||||
|
||||
@@ -12,11 +12,10 @@ set_radar_type:
|
||||
fields:
|
||||
radar_type:
|
||||
required: true
|
||||
example: Rain
|
||||
example: Snow
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "Auto"
|
||||
- "Rain"
|
||||
- "Snow"
|
||||
- "Precipitation type"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for entities of the Evohome integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -13,7 +14,6 @@ from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
|
||||
@@ -161,7 +161,7 @@ class EvoChild(EvoEntity):
|
||||
or self._schedule is None
|
||||
or (
|
||||
(until := self._setpoints.get("next_sp_from")) is not None
|
||||
and until < dt_util.utcnow()
|
||||
and until < datetime.now(UTC)
|
||||
)
|
||||
): # must use self._setpoints, not self.setpoints
|
||||
await get_schedule()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from evohomeasync.auth import (
|
||||
@@ -12,7 +12,6 @@ from evohomeasync2.auth import AbstractTokenManager
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import STORAGE_KEY, STORAGE_VER
|
||||
|
||||
@@ -92,7 +91,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
|
||||
|
||||
session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
|
||||
if session_id_expires is None:
|
||||
self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15)
|
||||
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
|
||||
else:
|
||||
self._session_id_expires = datetime.fromisoformat(session_id_expires)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FlussConfigEntry, FlussDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -6,4 +6,3 @@ import logging
|
||||
DOMAIN = "fluss"
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_INTERVAL = timedelta(minutes=30)
|
||||
COMMAND_REFRESH_COOLDOWN = 10
|
||||
|
||||
@@ -13,11 +13,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import COMMAND_REFRESH_COOLDOWN, LOGGER, UPDATE_INTERVAL
|
||||
from .const import LOGGER, UPDATE_INTERVAL
|
||||
|
||||
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
|
||||
|
||||
@@ -36,24 +35,18 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
name=f"Fluss+ ({slugify(api_key[:8])})",
|
||||
config_entry=config_entry,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass,
|
||||
LOGGER,
|
||||
cooldown=COMMAND_REFRESH_COOLDOWN,
|
||||
immediate=False,
|
||||
),
|
||||
)
|
||||
|
||||
async def _async_get_status(self, device_id: str) -> dict[str, Any]:
|
||||
"""Return per-device status."""
|
||||
async def _async_get_connectivity(self, device_id: str) -> bool:
|
||||
"""Return connectivity for a device; False if the status call fails."""
|
||||
try:
|
||||
response = await self.api.async_get_device_status(device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise UpdateFailed(f"Error fetching status for {device_id}: {err}") from err
|
||||
return response["status"]
|
||||
status = await self.api.async_get_device_status(device_id)
|
||||
except FlussApiClientError:
|
||||
return False
|
||||
return status["status"]["internetConnected"]
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch Fluss+ devices and merge per-device status."""
|
||||
"""Fetch Fluss+ devices and merge per-device connectivity status."""
|
||||
try:
|
||||
devices = await self.api.async_get_devices()
|
||||
except FlussApiClientAuthenticationError as err:
|
||||
@@ -66,11 +59,10 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
for device in devices["devices"]
|
||||
if device["userPermissions"]["canUseWiFi"]
|
||||
]
|
||||
|
||||
statuses = await asyncio.gather(
|
||||
*(self._async_get_status(d["deviceId"]) for d in device_list)
|
||||
connectivity = await asyncio.gather(
|
||||
*(self._async_get_connectivity(d["deviceId"]) for d in device_list)
|
||||
)
|
||||
return {
|
||||
device["deviceId"]: {**device, **status}
|
||||
for device, status in zip(device_list, statuses, strict=False)
|
||||
device["deviceId"]: {**device, "internetConnected": connected}
|
||||
for device, connected in zip(device_list, connectivity, strict=False)
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Cover platform for Fluss+ devices that report an open/closed status."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FlussApiClientError, FlussConfigEntry
|
||||
from .entity import FlussEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
STATUS_OPEN = "Open"
|
||||
STATUS_CLOSED = "Closed"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FlussConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fluss covers for devices that report an open/closed status."""
|
||||
coordinator = entry.runtime_data
|
||||
added_device_ids: set[str] = set()
|
||||
|
||||
def _async_add_new_entities() -> None:
|
||||
new_entities = [
|
||||
FlussCover(coordinator, device_id, device)
|
||||
for device_id, device in coordinator.data.items()
|
||||
if "openCloseStatus" in device and device_id not in added_device_ids
|
||||
]
|
||||
if not new_entities:
|
||||
return
|
||||
|
||||
added_device_ids.update(entity.device_id for entity in new_entities)
|
||||
async_add_entities(new_entities)
|
||||
|
||||
_async_add_new_entities()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
|
||||
|
||||
|
||||
class FlussCover(FlussEntity, CoverEntity):
|
||||
"""Representation of a Fluss+ cover."""
|
||||
|
||||
_attr_device_class = CoverDeviceClass.GARAGE
|
||||
_attr_name = None
|
||||
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True only when the device is online."""
|
||||
return super().available and self.device["internetConnected"]
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return whether the cover is closed."""
|
||||
status = self.device.get("openCloseStatus")
|
||||
if status == STATUS_CLOSED:
|
||||
return True
|
||||
if status == STATUS_OPEN:
|
||||
return False
|
||||
return None
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
try:
|
||||
await self.coordinator.api.async_open_device(self.device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="command_failed"
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
try:
|
||||
await self.coordinator.api.async_close_device(self.device_id)
|
||||
except FlussApiClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="command_failed"
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -19,10 +19,5 @@
|
||||
"description": "Your Fluss API key, available in the profile page of the Fluss+ app"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"command_failed": {
|
||||
"message": "Failed to send command to Fluss+ device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.4"]
|
||||
"requirements": ["home-assistant-frontend==20260527.2"]
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
|
||||
super()._handle_coordinator_update()
|
||||
return
|
||||
|
||||
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
|
||||
time = datetime.now(UTC) + timedelta(seconds=value)
|
||||
if not self._attr_native_value:
|
||||
self._attr_native_value = time
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -199,7 +199,6 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.RECEIVER): TYPE_RECEIVER,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.SPEAKER): TYPE_SPEAKER,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.TV): TYPE_TV,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.PROJECTOR): TYPE_TV,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.AQI): TYPE_SENSOR,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY): TYPE_SENSOR,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE): TYPE_SENSOR,
|
||||
|
||||
@@ -2728,11 +2728,7 @@ class ChannelTrait(_Trait):
|
||||
if (
|
||||
domain == media_player.DOMAIN
|
||||
and (features & MediaPlayerEntityFeature.PLAY_MEDIA)
|
||||
and device_class
|
||||
in (
|
||||
media_player.MediaPlayerDeviceClass.TV,
|
||||
media_player.MediaPlayerDeviceClass.PROJECTOR,
|
||||
)
|
||||
and device_class == media_player.MediaPlayerDeviceClass.TV
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription):
|
||||
def _get_lowest_price_day_time(
|
||||
api: GreenPlanetEnergyAPI, data: dict[str, Any]
|
||||
) -> datetime | None:
|
||||
"""Return timestamp of the lowest-priced day hour (06:00-18:00)."""
|
||||
"""Return timestamp of the lowest-priced day hour (06:00–18:00)."""
|
||||
now = dt_util.now()
|
||||
now_h = now.hour
|
||||
hour = api.get_lowest_price_day_with_hour(data, now_h)[1]
|
||||
|
||||
@@ -10,8 +10,8 @@ Classic API (username/password):
|
||||
|
||||
Open API V1 (API token):
|
||||
- Stateless — no login call, token is sent as a Bearer header on every request.
|
||||
- Auth failure is signalled by raising GrowattV1ApiError with
|
||||
error_code=GrowattV1ApiErrorCode.NO_PRIVILEGE. The library NEVER returns a failure silently;
|
||||
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
|
||||
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
|
||||
any non-zero error_code raises an exception via _process_response().
|
||||
- Because the library always raises on error, return-value validation after a
|
||||
successful V1 API call is unnecessary — if it returned, the token was valid.
|
||||
@@ -19,7 +19,7 @@ Open API V1 (API token):
|
||||
Error handling pattern for reauth:
|
||||
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- V1 API: catch GrowattV1ApiError with error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE
|
||||
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
|
||||
"""
|
||||
@@ -30,7 +30,6 @@ from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
import growattServer
|
||||
from growattServer import GrowattV1ApiErrorCode
|
||||
from requests import RequestException
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
|
||||
@@ -59,6 +58,8 @@ from .const import (
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
PLATFORMS,
|
||||
SUPPORTED_DEVICE_TYPES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_API_ERROR_RATE_LIMITED,
|
||||
V1_DEVICE_TYPES,
|
||||
)
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
@@ -238,24 +239,15 @@ def _login_classic_api(
|
||||
login_response = api.login(username, password)
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
f"Error communicating with Growatt API during login: {ex}"
|
||||
) from ex
|
||||
|
||||
if not login_response.get("success"):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
_LOGGER.debug("Growatt login failed: %s", msg)
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
)
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
translation_placeholders={"message": msg},
|
||||
)
|
||||
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
|
||||
raise ConfigEntryError(f"Growatt login failed: {msg}")
|
||||
|
||||
return login_response
|
||||
|
||||
@@ -273,25 +265,17 @@ def get_device_list_v1(
|
||||
try:
|
||||
devices_dict = api.device_list(plant_id)
|
||||
except growattServer.GrowattV1ApiError as e:
|
||||
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": e.error_msg or str(e)},
|
||||
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
|
||||
) from e
|
||||
if e.error_code == GrowattV1ApiErrorCode.RATE_LIMITED:
|
||||
if e.error_code == V1_API_ERROR_RATE_LIMITED:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="rate_limited",
|
||||
translation_placeholders={"error": e.error_msg or str(e)},
|
||||
f"Growatt API rate limited, will retry: {e.error_msg or str(e)}"
|
||||
) from e
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error_with_code",
|
||||
translation_placeholders={
|
||||
"error": e.error_msg or str(e),
|
||||
"code": str(e.error_code),
|
||||
},
|
||||
f"API error during device list: {e.error_msg or str(e)}"
|
||||
f" (Code: {e.error_code})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
supported_devices = [
|
||||
@@ -365,15 +349,10 @@ async def async_setup_entry(
|
||||
devices = await hass.async_add_executor_job(api.device_list, plant_id)
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
f"Error communicating with Growatt API during device list: {ex}"
|
||||
) from ex
|
||||
else:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_auth_type",
|
||||
)
|
||||
raise ConfigEntryError("Unknown authentication type in config entry.")
|
||||
|
||||
# Create a coordinator for the total sensors
|
||||
total_coordinator = GrowattCoordinator(
|
||||
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import growattServer
|
||||
from growattServer import GrowattV1ApiErrorCode
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -33,6 +32,7 @@ from .const import (
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
SERVER_URLS_NAMES,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
)
|
||||
|
||||
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
|
||||
@@ -148,7 +148,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("Network error during credential update: %s", ex)
|
||||
errors["base"] = ERROR_CANNOT_CONNECT
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
errors["base"] = ERROR_INVALID_AUTH
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
@@ -301,7 +301,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
e.error_msg or str(e),
|
||||
e.error_code,
|
||||
)
|
||||
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
|
||||
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as ex:
|
||||
|
||||
@@ -42,6 +42,13 @@ PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
# Growatt Classic API error codes
|
||||
LOGIN_INVALID_AUTH_CODE = "502"
|
||||
|
||||
# Growatt Open API V1 error codes
|
||||
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
|
||||
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
|
||||
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
|
||||
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
|
||||
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
|
||||
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
|
||||
|
||||
# Config flow error types (also used as abort reasons)
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import growattServer
|
||||
from growattServer import GrowattV1ApiErrorCode
|
||||
from requests import RequestException
|
||||
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
@@ -28,6 +27,7 @@ from .const import (
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_DEVICE_TYPES,
|
||||
)
|
||||
from .models import GrowattRuntimeData
|
||||
@@ -113,11 +113,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if device.get("type") in V1_DEVICE_TYPES
|
||||
]
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
_LOGGER.debug("Failed to fetch V1 device list during scan: %s", err)
|
||||
self.device_list = None
|
||||
@@ -159,14 +157,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
"Username, password, or URL may be incorrect"
|
||||
)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
translation_placeholders={"message": msg},
|
||||
)
|
||||
raise UpdateFailed(f"Growatt login failed: {msg}")
|
||||
|
||||
if self.device_type == "total":
|
||||
if self.api_version == "v1":
|
||||
@@ -186,18 +179,13 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
total_info = self.api.plant_energy_overview(self.plant_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={
|
||||
"error": err.error_msg or str(err)
|
||||
},
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
f"Error fetching plant energy overview: {err}"
|
||||
) from err
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
@@ -224,17 +212,12 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
min_settings = self.api.min_settings(self.device_id)
|
||||
min_energy = self.api.min_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching min device data: {err}") from err
|
||||
|
||||
min_info = {**min_details, **min_settings, **min_energy}
|
||||
self.data = min_info
|
||||
@@ -257,17 +240,12 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
sph_detail = self.api.sph_detail(self.device_id)
|
||||
sph_energy = self.api.sph_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
|
||||
|
||||
combined = {**sph_detail, **sph_energy}
|
||||
|
||||
@@ -335,11 +313,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(self._sync_update_data)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching data: {err}") from err
|
||||
|
||||
def request_device_list_scan(self) -> None:
|
||||
"""Request that the next _sync_update_data also fetches the device list.
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["growattServer==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
|
||||
@@ -595,15 +595,6 @@
|
||||
"api_error": {
|
||||
"message": "Growatt API error: {error}"
|
||||
},
|
||||
"api_error_with_code": {
|
||||
"message": "API error: {error} (Code: {code})"
|
||||
},
|
||||
"auth_failed": {
|
||||
"message": "Authentication failed for Growatt API: {error}"
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "Error communicating with Growatt API: {error}"
|
||||
},
|
||||
"device_not_configured": {
|
||||
"message": "{device_type} device {serial_number} is not configured for actions."
|
||||
},
|
||||
@@ -613,9 +604,6 @@
|
||||
"device_not_growatt": {
|
||||
"message": "Device {device_id} is not a Growatt device."
|
||||
},
|
||||
"fetch_data_failed": {
|
||||
"message": "Error fetching data from Growatt API: {error}"
|
||||
},
|
||||
"invalid_batt_mode": {
|
||||
"message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}."
|
||||
},
|
||||
@@ -625,9 +613,6 @@
|
||||
"invalid_charge_stop_soc": {
|
||||
"message": "'Charge stop SOC' must be between 0 and 100, got {value}."
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"message": "Username, password, or URL may be incorrect"
|
||||
},
|
||||
"invalid_discharge_power": {
|
||||
"message": "'Discharge power' must be between 0 and 100, got {value}."
|
||||
},
|
||||
@@ -649,20 +634,11 @@
|
||||
"invalid_time_format_start_time": {
|
||||
"message": "'Start time' must be in HH:MM or HH:MM:SS format."
|
||||
},
|
||||
"login_failed": {
|
||||
"message": "Growatt login failed: {message}"
|
||||
},
|
||||
"no_devices_configured": {
|
||||
"message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access."
|
||||
},
|
||||
"rate_limited": {
|
||||
"message": "Growatt API rate limited, will retry: {error}"
|
||||
},
|
||||
"token_auth_required": {
|
||||
"message": "This action requires token authentication (V1 API)."
|
||||
},
|
||||
"unknown_auth_type": {
|
||||
"message": "Unknown authentication type in config entry"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""The Helty Flow integration."""
|
||||
|
||||
from pyhelty import HeltyClient
|
||||
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
|
||||
"""Set up Helty Flow from a config entry."""
|
||||
client = HeltyClient(entry.data[CONF_HOST])
|
||||
coordinator = HeltyDataUpdateCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Config flow for the Helty Flow integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyhelty import HeltyClient, HeltyConnectionError, HeltyError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
|
||||
class HeltyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Helty Flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial setup step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
client = HeltyClient(user_input[CONF_HOST])
|
||||
try:
|
||||
name = await client.async_get_name()
|
||||
except HeltyConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except HeltyError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=name or user_input[CONF_HOST], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Constants for the Helty Flow integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "helty"
|
||||
|
||||
#: How often the coordinator polls the unit.
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
# Fan preset mode identifiers (also used as translation keys).
|
||||
PRESET_BOOST = "boost"
|
||||
PRESET_NIGHT = "night"
|
||||
PRESET_FREE_COOLING = "free_cooling"
|
||||
@@ -1,45 +0,0 @@
|
||||
"""DataUpdateCoordinator for the Helty Flow integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from pyhelty import HeltyClient, HeltyConnectionError, HeltyData, HeltyError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HeltyConfigEntry = ConfigEntry[HeltyDataUpdateCoordinator]
|
||||
|
||||
|
||||
class HeltyDataUpdateCoordinator(DataUpdateCoordinator[HeltyData]):
|
||||
"""Coordinate a single poll of the Helty unit for all entities."""
|
||||
|
||||
config_entry: HeltyConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HeltyConfigEntry,
|
||||
client: HeltyClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> HeltyData:
|
||||
try:
|
||||
return await self.client.async_get_data()
|
||||
except HeltyConnectionError as err:
|
||||
raise UpdateFailed(f"Error communicating with Helty unit: {err}") from err
|
||||
except HeltyError as err:
|
||||
raise UpdateFailed(f"Unexpected response from Helty unit: {err}") from err
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Base entity for the Helty Flow integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HeltyDataUpdateCoordinator
|
||||
|
||||
|
||||
class HeltyEntity(CoordinatorEntity[HeltyDataUpdateCoordinator]):
|
||||
"""Common base for Helty entities sharing one device and coordinator."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
|
||||
"""Initialize the entity and its shared device info."""
|
||||
super().__init__(coordinator)
|
||||
# The unit exposes no serial/MAC, so the config entry id identifies it.
|
||||
self._device_id = coordinator.config_entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._device_id)},
|
||||
name=coordinator.data.name,
|
||||
manufacturer="Helty",
|
||||
model="Flow",
|
||||
)
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Fan platform for the Helty Flow integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyhelty import FanMode
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import PRESET_BOOST, PRESET_FREE_COOLING, PRESET_NIGHT
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
from .entity import HeltyEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# Ordered list of discrete fan speeds, lowest to highest.
|
||||
ORDERED_SPEEDS: list[FanMode] = [
|
||||
FanMode.LOW,
|
||||
FanMode.MEDIUM,
|
||||
FanMode.HIGH,
|
||||
FanMode.MAX,
|
||||
]
|
||||
|
||||
PRESET_TO_MODE: dict[str, FanMode] = {
|
||||
PRESET_BOOST: FanMode.BOOST,
|
||||
PRESET_NIGHT: FanMode.NIGHT,
|
||||
PRESET_FREE_COOLING: FanMode.FREE_COOLING,
|
||||
}
|
||||
MODE_TO_PRESET: dict[FanMode, str] = {
|
||||
mode: preset for preset, mode in PRESET_TO_MODE.items()
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HeltyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Helty fan."""
|
||||
async_add_entities([HeltyFan(entry.runtime_data)])
|
||||
|
||||
|
||||
class HeltyFan(HeltyEntity, FanEntity):
|
||||
"""The ventilation unit's fan, the device's primary feature."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_speed_count = len(ORDERED_SPEEDS)
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
|
||||
"""Initialize the fan."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = self._device_id
|
||||
self._attr_preset_modes = list(PRESET_TO_MODE)
|
||||
|
||||
@property
|
||||
def _mode(self) -> FanMode:
|
||||
return self.coordinator.data.fan_mode
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether the fan is running."""
|
||||
return self._mode is not FanMode.OFF
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed as a percentage, or None when on a preset."""
|
||||
if self._mode in ORDERED_SPEEDS:
|
||||
return ordered_list_item_to_percentage(ORDERED_SPEEDS, self._mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the active preset, or None when running on a discrete speed."""
|
||||
return MODE_TO_PRESET.get(self._mode)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set a discrete fan speed from a percentage."""
|
||||
if percentage == 0:
|
||||
await self._async_set_mode(FanMode.OFF)
|
||||
return
|
||||
await self._async_set_mode(
|
||||
percentage_to_ordered_list_item(ORDERED_SPEEDS, percentage)
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set a preset mode."""
|
||||
await self._async_set_mode(PRESET_TO_MODE[preset_mode])
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn the fan on."""
|
||||
if preset_mode is not None:
|
||||
await self.async_set_preset_mode(preset_mode)
|
||||
elif percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
else:
|
||||
await self._async_set_mode(FanMode.LOW)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
await self._async_set_mode(FanMode.OFF)
|
||||
|
||||
async def _async_set_mode(self, mode: FanMode) -> None:
|
||||
await self.coordinator.client.async_set_fan_mode(mode)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "helty",
|
||||
"name": "Helty Flow",
|
||||
"codeowners": ["@ebaschiera"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/helty",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyhelty"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyhelty==0.2.0"]
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: This integration does not subscribe to external events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration has no options to configure.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: This integration does not require authentication.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: The device does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
The device exposes no discovery protocol (no mDNS/SSDP) and no stable
|
||||
identifier such as a serial number or MAC over its interface.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: A config entry represents a single fixed device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: The fan entity uses the default fan icon.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration has no repairable issues to surface.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: A config entry represents a single fixed device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
The device is controlled over a raw TCP socket, not HTTP, so there is no
|
||||
web session to inject.
|
||||
strict-typing: todo
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Sensor platform for the Helty Flow integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyhelty import HeltyData
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
from .entity import HeltyEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HeltySensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a Helty sensor."""
|
||||
|
||||
value_fn: Callable[[HeltyData], float | None]
|
||||
|
||||
|
||||
SENSORS: tuple[HeltySensorEntityDescription, ...] = (
|
||||
HeltySensorEntityDescription(
|
||||
key="indoor_temperature",
|
||||
translation_key="indoor_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.indoor_temperature,
|
||||
),
|
||||
HeltySensorEntityDescription(
|
||||
key="outdoor_temperature",
|
||||
translation_key="outdoor_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.outdoor_temperature,
|
||||
),
|
||||
HeltySensorEntityDescription(
|
||||
key="indoor_humidity",
|
||||
translation_key="indoor_humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.indoor_humidity,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HeltyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Helty sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(HeltySensor(coordinator, description) for description in SENSORS)
|
||||
|
||||
|
||||
class HeltySensor(HeltyEntity, SensorEntity):
|
||||
"""An environmental sensor reported by the ventilation unit."""
|
||||
|
||||
entity_description: HeltySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HeltyDataUpdateCoordinator,
|
||||
description: HeltySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current sensor reading."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address or hostname of the Helty Flow unit on your network."
|
||||
},
|
||||
"title": "Connect to your Helty Flow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"indoor_humidity": {
|
||||
"name": "Indoor humidity"
|
||||
},
|
||||
"indoor_temperature": {
|
||||
"name": "Indoor temperature"
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"name": "Outdoor temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""The homee cover platform."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from pyHomee.const import AttributeType, NodeProfile
|
||||
from pyHomee.model import HomeeAttribute, HomeeNode
|
||||
@@ -35,12 +35,6 @@ COVER_DEVICE_PROFILES = {
|
||||
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
||||
}
|
||||
IS_CLOSED_ATTRIBUTES = [
|
||||
AttributeType.OPEN_CLOSE,
|
||||
AttributeType.UP_DOWN,
|
||||
AttributeType.POSITION,
|
||||
AttributeType.SHUTTER_SLAT_POSITION,
|
||||
]
|
||||
|
||||
|
||||
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
|
||||
@@ -89,23 +83,9 @@ async def add_cover_entities(
|
||||
nodes: list[HomeeNode],
|
||||
) -> None:
|
||||
"""Add homee cover entities."""
|
||||
entities: list[HomeeNode] = []
|
||||
for node in nodes:
|
||||
if is_cover_node(node):
|
||||
if any(
|
||||
node.get_attribute_by_type(attr) is not None
|
||||
for attr in IS_CLOSED_ATTRIBUTES
|
||||
):
|
||||
entities.append(node)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Cover %s could not be added, because it is missing an Attribute "
|
||||
"for closed indication. Please open an issue at "
|
||||
"https://github.com/home-assistant/core/issues",
|
||||
node.name,
|
||||
)
|
||||
|
||||
async_add_entities(HomeeCover(cover, config_entry) for cover in entities)
|
||||
async_add_entities(
|
||||
HomeeCover(node, config_entry) for node in nodes if is_cover_node(node)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -207,7 +187,7 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
if (
|
||||
attribute := self._node.get_attribute_by_type(AttributeType.POSITION)
|
||||
@@ -220,16 +200,15 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
|
||||
return self._open_close_attribute.get_value() == 0
|
||||
|
||||
# If none of the above is present, it will be a slat only cover.
|
||||
attribute = self._node.get_attribute_by_type(
|
||||
AttributeType.SHUTTER_SLAT_POSITION
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
# This case should not happen, because we check for
|
||||
# the presence of an IS_CLOSED_ATTRIBUTE when adding entities.
|
||||
assert attribute is not None
|
||||
# If none of the above is present, it might be a slat only cover.
|
||||
if (
|
||||
attribute := self._node.get_attribute_by_type(
|
||||
AttributeType.SHUTTER_SLAT_POSITION
|
||||
)
|
||||
) is not None:
|
||||
return attribute.get_value() == attribute.minimum
|
||||
|
||||
return attribute.get_value() == attribute.minimum
|
||||
return None
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
|
||||
@@ -202,10 +202,7 @@ def get_accessory( # noqa: C901
|
||||
|
||||
if device_class == MediaPlayerDeviceClass.RECEIVER:
|
||||
a_type = "ReceiverMediaPlayer"
|
||||
elif device_class in (
|
||||
MediaPlayerDeviceClass.TV,
|
||||
MediaPlayerDeviceClass.PROJECTOR,
|
||||
):
|
||||
elif device_class == MediaPlayerDeviceClass.TV:
|
||||
a_type = "TelevisionMediaPlayer"
|
||||
elif validate_media_player_features(state, feature_list):
|
||||
a_type = "MediaPlayer"
|
||||
|
||||
@@ -695,11 +695,7 @@ def state_needs_accessory_mode(state: State) -> bool:
|
||||
return (
|
||||
state.domain == MEDIA_PLAYER_DOMAIN
|
||||
and state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
in (
|
||||
MediaPlayerDeviceClass.TV,
|
||||
MediaPlayerDeviceClass.RECEIVER,
|
||||
MediaPlayerDeviceClass.PROJECTOR,
|
||||
)
|
||||
in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
|
||||
) or (
|
||||
state.domain == REMOTE_DOMAIN
|
||||
and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
@@ -6,14 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from pygti.exceptions import GTIError
|
||||
from pygti.models import (
|
||||
ElevatorState,
|
||||
SDName,
|
||||
SDNameType,
|
||||
StationInformationRequest,
|
||||
StationInformationResponse,
|
||||
)
|
||||
from pygti.exceptions import InvalidAuth
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -45,21 +38,20 @@ async def async_setup_entry(
|
||||
station = entry.data[CONF_STATION]
|
||||
|
||||
def get_elevator_entities_from_station_information(
|
||||
station_name: str,
|
||||
station_information: StationInformationResponse | None,
|
||||
) -> dict[str, Any]:
|
||||
station_name, station_information
|
||||
):
|
||||
"""Convert station information into a list of elevators."""
|
||||
elevators = {}
|
||||
|
||||
if station_information is None:
|
||||
return {}
|
||||
|
||||
for partial_station in station_information.partialStations or []:
|
||||
for elevator in partial_station.elevators or []:
|
||||
state = elevator.state != ElevatorState.READY
|
||||
available = elevator.state != ElevatorState.UNKNOWN
|
||||
label = elevator.label
|
||||
description = elevator.description
|
||||
for partial_station in station_information.get("partialStations", []):
|
||||
for elevator in partial_station.get("elevators", []):
|
||||
state = elevator.get("state") != "READY"
|
||||
available = elevator.get("state") != "UNKNOWN"
|
||||
label = elevator.get("label")
|
||||
description = elevator.get("description")
|
||||
|
||||
if label is not None:
|
||||
name = f"Elevator {label}"
|
||||
@@ -69,7 +61,7 @@ async def async_setup_entry(
|
||||
if description is not None:
|
||||
name += f" ({description})"
|
||||
|
||||
lines = elevator.lines
|
||||
lines = elevator.get("lines")
|
||||
|
||||
idx = f"{station_name}-{label}-{lines}"
|
||||
|
||||
@@ -78,35 +70,33 @@ async def async_setup_entry(
|
||||
"name": name,
|
||||
"available": available,
|
||||
"attributes": {
|
||||
"cabin_width": elevator.cabinWidth,
|
||||
"cabin_length": elevator.cabinLength,
|
||||
"door_width": elevator.doorWidth,
|
||||
"elevator_type": elevator.elevatorType,
|
||||
"button_type": elevator.buttonType,
|
||||
"cause": elevator.cause,
|
||||
"cabin_width": elevator.get("cabinWidth"),
|
||||
"cabin_length": elevator.get("cabinLength"),
|
||||
"door_width": elevator.get("doorWidth"),
|
||||
"elevator_type": elevator.get("elevatorType"),
|
||||
"button_type": elevator.get("buttonType"),
|
||||
"cause": elevator.get("cause"),
|
||||
"lines": lines,
|
||||
},
|
||||
}
|
||||
return elevators
|
||||
|
||||
async def async_update_data() -> dict[str, Any]:
|
||||
async def async_update_data():
|
||||
"""Fetch data from API endpoint.
|
||||
|
||||
This is the place to pre-process the data to lookup tables
|
||||
so entities can quickly look up their data.
|
||||
"""
|
||||
|
||||
payload = StationInformationRequest(
|
||||
station=SDName(id=station["id"], type=SDNameType(station["type"]))
|
||||
)
|
||||
payload = {"station": {"id": station["id"], "type": station["type"]}}
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
return get_elevator_entities_from_station_information(
|
||||
station_name, await hub.gti.getStationInformation(payload)
|
||||
station_name, await hub.gti.stationInformation(payload)
|
||||
)
|
||||
except GTIError as err:
|
||||
raise UpdateFailed(f"GTI API error: {err}") from err
|
||||
except InvalidAuth as err:
|
||||
raise UpdateFailed(f"Authentication failed: {err}") from err
|
||||
except ClientConnectorError as err:
|
||||
raise UpdateFailed(f"Network not available: {err}") from err
|
||||
except Exception as err:
|
||||
@@ -139,12 +129,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator[dict[str, Any]],
|
||||
idx: str,
|
||||
config_entry: HVVConfigEntry,
|
||||
) -> None:
|
||||
def __init__(self, coordinator, idx, config_entry):
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
self.coordinator = coordinator
|
||||
@@ -155,7 +140,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={
|
||||
( # type: ignore[arg-type]
|
||||
(
|
||||
DOMAIN,
|
||||
config_entry.entry_id,
|
||||
config_entry.data[CONF_STATION]["id"],
|
||||
@@ -169,7 +154,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return entity state."""
|
||||
return bool(self.coordinator.data[self.idx]["state"])
|
||||
return self.coordinator.data[self.idx]["state"]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -3,17 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from pygti.auth import GTI_DEFAULT_HOST
|
||||
from pygti.exceptions import GTIError, GTIUnauthorizedError
|
||||
from pygti.models import (
|
||||
CNRequest,
|
||||
DLRequest,
|
||||
GTITime,
|
||||
RegionalSDNameType,
|
||||
SDName,
|
||||
SDNameType,
|
||||
)
|
||||
from pygti.exceptions import CannotConnect, InvalidAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
@@ -75,10 +66,10 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
response = await self.hub.authenticate()
|
||||
_LOGGER.debug("Init gti: %r", response)
|
||||
except GTIUnauthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except GTIError, ClientConnectorError:
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
if not errors:
|
||||
self.data = user_input
|
||||
@@ -96,14 +87,15 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
check_name = await self.hub.gti.checkName(
|
||||
CNRequest(theName=SDName(name=user_input[CONF_STATION]), maxList=20)
|
||||
{"theName": {"name": user_input[CONF_STATION]}, "maxList": 20}
|
||||
)
|
||||
|
||||
stations = check_name.get("results")
|
||||
|
||||
self.stations = {
|
||||
station.name: station
|
||||
for station in (check_name.results or [])
|
||||
if station.type == RegionalSDNameType.STATION
|
||||
and station.name is not None
|
||||
f"{station.get('name')}": station
|
||||
for station in stations
|
||||
if station.get("type") == "STATION"
|
||||
}
|
||||
|
||||
if not self.stations:
|
||||
@@ -129,13 +121,7 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="station_select", data_schema=schema)
|
||||
|
||||
self.data.update(
|
||||
{
|
||||
"station": self.stations[user_input[CONF_STATION]].model_dump(
|
||||
mode="json", exclude_none=True
|
||||
)
|
||||
}
|
||||
)
|
||||
self.data.update({"station": self.stations[user_input[CONF_STATION]]})
|
||||
|
||||
title = self.data[CONF_STATION]["name"]
|
||||
|
||||
@@ -165,30 +151,32 @@ class OptionsFlowHandler(OptionsFlow):
|
||||
"""Manage the options."""
|
||||
errors = {}
|
||||
if not self.departure_filters:
|
||||
departure_list = {}
|
||||
hub = self.config_entry.runtime_data
|
||||
|
||||
try:
|
||||
departure_list = await hub.gti.departureList(
|
||||
DLRequest(
|
||||
station=SDName(
|
||||
id=self.config_entry.data[CONF_STATION].get("id"),
|
||||
type=SDNameType.STATION,
|
||||
),
|
||||
time=GTITime(date="heute", time="jetzt"),
|
||||
maxList=5,
|
||||
maxTimeOffset=200,
|
||||
useRealtime=True,
|
||||
returnFilters=True,
|
||||
)
|
||||
{
|
||||
"station": {
|
||||
"type": "STATION",
|
||||
"id": self.config_entry.data[CONF_STATION].get("id"),
|
||||
},
|
||||
"time": {"date": "heute", "time": "jetzt"},
|
||||
"maxList": 5,
|
||||
"maxTimeOffset": 200,
|
||||
"useRealtime": True,
|
||||
"returnFilters": True,
|
||||
}
|
||||
)
|
||||
except GTIUnauthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except GTIError, ClientConnectorError:
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
if not errors:
|
||||
self.departure_filters = {
|
||||
str(i): f.model_dump(mode="json", exclude_none=True)
|
||||
for i, f in enumerate(departure_list.filter or [])
|
||||
str(i): departure_filter
|
||||
for i, departure_filter in enumerate(departure_list["filter"])
|
||||
}
|
||||
|
||||
if user_input is not None and not errors:
|
||||
@@ -218,8 +206,8 @@ class OptionsFlowHandler(OptionsFlow):
|
||||
vol.Optional(CONF_FILTER, default=old_filter): cv.multi_select(
|
||||
{
|
||||
key: (
|
||||
f"{departure_filter.get('serviceName', '')},"
|
||||
f" {departure_filter.get('label', '')}"
|
||||
f"{departure_filter['serviceName']},"
|
||||
f" {departure_filter['label']}"
|
||||
)
|
||||
for key, departure_filter in self.departure_filters.items()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Hub."""
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from pygti.gti import GTI, Auth
|
||||
from pygti.models import InitRequest, InitResponse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
@@ -12,9 +10,7 @@ type HVVConfigEntry = ConfigEntry[GTIHub]
|
||||
class GTIHub:
|
||||
"""GTI Hub."""
|
||||
|
||||
def __init__(
|
||||
self, host: str, username: str, password: str, session: ClientSession
|
||||
) -> None:
|
||||
def __init__(self, host, username, password, session):
|
||||
"""Initialize."""
|
||||
self.host = host
|
||||
self.username = username
|
||||
@@ -22,7 +18,7 @@ class GTIHub:
|
||||
|
||||
self.gti = GTI(Auth(session, self.username, self.password, self.host))
|
||||
|
||||
async def authenticate(self) -> InitResponse:
|
||||
async def authenticate(self):
|
||||
"""Test if we can authenticate with the host."""
|
||||
|
||||
return await self.gti.init(InitRequest())
|
||||
return await self.gti.init()
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pygti"],
|
||||
"requirements": ["pygti==1.1.1"]
|
||||
"requirements": ["pygti==0.9.4"]
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientConnectorError, ClientSession
|
||||
from pygti.exceptions import GTIError, GTIUnauthorizedError
|
||||
from pygti.models import DLRequest, GTITime, SDName, SDNameType
|
||||
from aiohttp import ClientConnectorError
|
||||
from pygti.exceptions import InvalidAuth
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.const import ATTR_ID, CONF_OFFSET
|
||||
@@ -17,15 +16,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.util.dt import get_time_zone, utcnow
|
||||
|
||||
from .const import (
|
||||
ATTRIBUTION,
|
||||
CONF_FILTER,
|
||||
CONF_REAL_TIME,
|
||||
CONF_STATION,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
)
|
||||
from .hub import GTIHub, HVVConfigEntry
|
||||
from .const import ATTRIBUTION, CONF_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTURER
|
||||
from .hub import HVVConfigEntry
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
|
||||
MAX_LIST = 20
|
||||
@@ -70,17 +62,11 @@ class HVVDepartureSensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_available = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HVVConfigEntry,
|
||||
session: ClientSession,
|
||||
hub: GTIHub,
|
||||
) -> None:
|
||||
def __init__(self, hass, config_entry, session, hub):
|
||||
"""Initialize."""
|
||||
self.config_entry = config_entry
|
||||
self.station_name = self.config_entry.data[CONF_STATION]["name"]
|
||||
self._last_error: type[Exception] | Exception | None = None
|
||||
self._last_error = None
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
self.gti = hub.gti
|
||||
@@ -91,7 +77,7 @@ class HVVDepartureSensor(SensorEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={
|
||||
( # type: ignore[arg-type]
|
||||
(
|
||||
DOMAIN,
|
||||
config_entry.entry_id,
|
||||
config_entry.data[CONF_STATION]["id"],
|
||||
@@ -113,46 +99,39 @@ class HVVDepartureSensor(SensorEntity):
|
||||
|
||||
station = self.config_entry.data[CONF_STATION]
|
||||
|
||||
request = DLRequest(
|
||||
station=SDName(id=station["id"], type=SDNameType(station["type"])),
|
||||
time=GTITime(
|
||||
date=departure_time_tz_berlin.strftime("%d.%m.%Y"),
|
||||
time=departure_time_tz_berlin.strftime("%H:%M"),
|
||||
),
|
||||
maxList=MAX_LIST,
|
||||
maxTimeOffset=MAX_TIME_OFFSET,
|
||||
useRealtime=self.config_entry.options.get(CONF_REAL_TIME, False),
|
||||
filter=self.config_entry.options.get(CONF_FILTER),
|
||||
)
|
||||
payload = {
|
||||
"station": {"id": station["id"], "type": station["type"]},
|
||||
"time": {
|
||||
"date": departure_time_tz_berlin.strftime("%d.%m.%Y"),
|
||||
"time": departure_time_tz_berlin.strftime("%H:%M"),
|
||||
},
|
||||
"maxList": MAX_LIST,
|
||||
"maxTimeOffset": MAX_TIME_OFFSET,
|
||||
"useRealtime": self.config_entry.options.get(CONF_REAL_TIME, False),
|
||||
}
|
||||
|
||||
if "filter" in self.config_entry.options:
|
||||
payload.update({"filter": self.config_entry.options["filter"]})
|
||||
|
||||
try:
|
||||
data = await self.gti.departureList(request)
|
||||
except GTIUnauthorizedError as error:
|
||||
if self._last_error != GTIUnauthorizedError:
|
||||
data = await self.gti.departureList(payload)
|
||||
except InvalidAuth as error:
|
||||
if self._last_error != InvalidAuth:
|
||||
_LOGGER.error("Authentication failed: %r", error)
|
||||
self._last_error = GTIUnauthorizedError
|
||||
self._last_error = InvalidAuth
|
||||
self._attr_available = False
|
||||
return
|
||||
except GTIError as error:
|
||||
if self._last_error != GTIError:
|
||||
_LOGGER.warning("GTI API error: %r", error)
|
||||
self._last_error = GTIError
|
||||
self._attr_available = False
|
||||
return
|
||||
except ClientConnectorError as error:
|
||||
if self._last_error != ClientConnectorError:
|
||||
_LOGGER.warning("Network unavailable: %r", error)
|
||||
self._last_error = ClientConnectorError
|
||||
self._attr_available = False
|
||||
return
|
||||
except Exception as error: # noqa: BLE001
|
||||
if self._last_error != error:
|
||||
_LOGGER.error("Error occurred while fetching data: %r", error)
|
||||
self._last_error = error
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
if not data.departures:
|
||||
if not (data["returnCode"] == "OK" and data.get("departures")):
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
@@ -161,27 +140,25 @@ class HVVDepartureSensor(SensorEntity):
|
||||
|
||||
self._last_error = None
|
||||
|
||||
departure = data.departures[0]
|
||||
line = departure.line
|
||||
delay = departure.delay if departure.delay is not None else 0
|
||||
cancelled = departure.cancelled if departure.cancelled is not None else False
|
||||
extra = departure.extra if departure.extra is not None else False
|
||||
departure = data["departures"][0]
|
||||
line = departure["line"]
|
||||
delay = departure.get("delay", 0)
|
||||
cancelled = departure.get("cancelled", False)
|
||||
extra = departure.get("extra", False)
|
||||
self._attr_available = True
|
||||
self._attr_native_value = (
|
||||
departure_time
|
||||
+ timedelta(
|
||||
minutes=departure.timeOffset if departure.timeOffset is not None else 0
|
||||
)
|
||||
+ timedelta(minutes=departure["timeOffset"])
|
||||
+ timedelta(seconds=delay)
|
||||
)
|
||||
|
||||
self._attr_extra_state_attributes.update(
|
||||
{
|
||||
ATTR_LINE: line.name,
|
||||
ATTR_ORIGIN: line.origin,
|
||||
ATTR_DIRECTION: line.direction,
|
||||
ATTR_TYPE: line.type.shortInfo,
|
||||
ATTR_ID: line.id,
|
||||
ATTR_LINE: line["name"],
|
||||
ATTR_ORIGIN: line["origin"],
|
||||
ATTR_DIRECTION: line["direction"],
|
||||
ATTR_TYPE: line["type"]["shortInfo"],
|
||||
ATTR_ID: line["id"],
|
||||
ATTR_DELAY: delay,
|
||||
ATTR_CANCELLED: cancelled,
|
||||
ATTR_EXTRA: extra,
|
||||
@@ -189,27 +166,21 @@ class HVVDepartureSensor(SensorEntity):
|
||||
)
|
||||
|
||||
departures = []
|
||||
for departure in data.departures:
|
||||
line = departure.line
|
||||
delay = departure.delay if departure.delay is not None else 0
|
||||
cancelled = (
|
||||
departure.cancelled if departure.cancelled is not None else False
|
||||
)
|
||||
extra = departure.extra if departure.extra is not None else False
|
||||
for departure in data["departures"]:
|
||||
line = departure["line"]
|
||||
delay = departure.get("delay", 0)
|
||||
cancelled = departure.get("cancelled", False)
|
||||
extra = departure.get("extra", False)
|
||||
departures.append(
|
||||
{
|
||||
ATTR_DEPARTURE: departure_time
|
||||
+ timedelta(
|
||||
minutes=departure.timeOffset
|
||||
if departure.timeOffset is not None
|
||||
else 0
|
||||
)
|
||||
+ timedelta(minutes=departure["timeOffset"])
|
||||
+ timedelta(seconds=delay),
|
||||
ATTR_LINE: line.name,
|
||||
ATTR_ORIGIN: line.origin,
|
||||
ATTR_DIRECTION: line.direction,
|
||||
ATTR_TYPE: line.type.shortInfo,
|
||||
ATTR_ID: line.id,
|
||||
ATTR_LINE: line["name"],
|
||||
ATTR_ORIGIN: line["origin"],
|
||||
ATTR_DIRECTION: line["direction"],
|
||||
ATTR_TYPE: line["type"]["shortInfo"],
|
||||
ATTR_ID: line["id"],
|
||||
ATTR_DELAY: delay,
|
||||
ATTR_CANCELLED: cancelled,
|
||||
ATTR_EXTRA: extra,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Support for Imou devices."""
|
||||
|
||||
from pyimouapi.device import ImouDeviceManager
|
||||
from pyimouapi.ha_device import ImouHaDeviceManager
|
||||
from pyimouapi.openapi import ImouOpenApiClient
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, PLATFORMS
|
||||
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool:
|
||||
"""Set up Imou integration from a config entry."""
|
||||
imou_client = ImouOpenApiClient(
|
||||
entry.data[CONF_APP_ID],
|
||||
entry.data[CONF_APP_SECRET],
|
||||
API_URLS[entry.data[CONF_API_URL]],
|
||||
)
|
||||
device_manager = ImouDeviceManager(imou_client)
|
||||
imou_device_manager = ImouHaDeviceManager(device_manager)
|
||||
imou_coordinator = ImouDataUpdateCoordinator(hass, imou_device_manager, entry)
|
||||
await imou_coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = imou_coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# DataUpdateCoordinator schedules periodic refreshes only when it has
|
||||
# listeners. With zero entities (e.g. an empty account at setup), register a
|
||||
# no-op listener so polling continues and later devices are discovered via
|
||||
# new_device_callbacks.
|
||||
@callback
|
||||
def _async_keep_polling() -> None:
|
||||
"""Keep periodic polling when no entities are registered yet."""
|
||||
|
||||
entry.async_on_unload(imou_coordinator.async_add_listener(_async_keep_polling))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool:
|
||||
"""Handle removal of an entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Support for Imou button controls."""
|
||||
|
||||
from pyimouapi.exceptions import ImouException
|
||||
from pyimouapi.ha_device import ImouHaDevice
|
||||
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import PTZ_MOVE_DURATION_MS, imou_device_identifier
|
||||
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
|
||||
from .entity import ImouEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
# Button types
|
||||
PARAM_RESTART_DEVICE = "restart_device"
|
||||
PARAM_MUTE = "mute"
|
||||
PARAM_PTZ_UP = "ptz_up"
|
||||
PARAM_PTZ_DOWN = "ptz_down"
|
||||
PARAM_PTZ_LEFT = "ptz_left"
|
||||
PARAM_PTZ_RIGHT = "ptz_right"
|
||||
|
||||
BUTTON_TYPES = (
|
||||
PARAM_RESTART_DEVICE,
|
||||
PARAM_MUTE,
|
||||
PARAM_PTZ_UP,
|
||||
PARAM_PTZ_DOWN,
|
||||
PARAM_PTZ_LEFT,
|
||||
PARAM_PTZ_RIGHT,
|
||||
)
|
||||
|
||||
PTZ_BUTTON_TYPES = (
|
||||
PARAM_PTZ_UP,
|
||||
PARAM_PTZ_DOWN,
|
||||
PARAM_PTZ_LEFT,
|
||||
PARAM_PTZ_RIGHT,
|
||||
)
|
||||
|
||||
BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = {
|
||||
PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART,
|
||||
}
|
||||
|
||||
|
||||
def _iter_buttons(
|
||||
coordinator: ImouDataUpdateCoordinator,
|
||||
) -> list[tuple[str, ImouHaDevice]]:
|
||||
"""Return (button_type, device) pairs for supported buttons."""
|
||||
return [
|
||||
(button_type, device)
|
||||
for device in coordinator.devices
|
||||
for button_type in device.buttons
|
||||
if button_type in BUTTON_TYPES
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ImouConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Imou button entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _add_buttons(new_devices: list[ImouHaDevice]) -> None:
|
||||
device_keys = {imou_device_identifier(device) for device in new_devices}
|
||||
async_add_entities(
|
||||
ImouButton(coordinator, button_type, device)
|
||||
for button_type, device in _iter_buttons(coordinator)
|
||||
if imou_device_identifier(device) in device_keys
|
||||
)
|
||||
|
||||
coordinator.new_device_callbacks.append(_add_buttons)
|
||||
|
||||
@callback
|
||||
def _remove_new_device_callback() -> None:
|
||||
if _add_buttons in coordinator.new_device_callbacks:
|
||||
coordinator.new_device_callbacks.remove(_add_buttons)
|
||||
|
||||
entry.async_on_unload(_remove_new_device_callback)
|
||||
_add_buttons(coordinator.devices)
|
||||
|
||||
|
||||
class ImouButton(ImouEntity, ButtonEntity):
|
||||
"""Imou button entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ImouDataUpdateCoordinator,
|
||||
entity_type: str,
|
||||
device: ImouHaDevice,
|
||||
) -> None:
|
||||
"""Initialize the Imou button entity."""
|
||||
super().__init__(coordinator, entity_type, device)
|
||||
if device_class := BUTTON_DEVICE_CLASS.get(entity_type):
|
||||
self._attr_device_class = device_class
|
||||
self._attr_translation_key = None
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle button press."""
|
||||
duration = PTZ_MOVE_DURATION_MS if self._entity_type in PTZ_BUTTON_TYPES else 0
|
||||
try:
|
||||
await self.coordinator.device_manager.async_press_button(
|
||||
self.device,
|
||||
self._entity_type,
|
||||
duration,
|
||||
)
|
||||
except ImouException as e:
|
||||
raise HomeAssistantError(str(e)) from e
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Config flow for Imou."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyimouapi.exceptions import (
|
||||
ConnectFailedException,
|
||||
ImouException,
|
||||
InvalidAppIdOrSecretException,
|
||||
RequestFailedException,
|
||||
)
|
||||
from pyimouapi.openapi import ImouOpenApiClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImouConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Imou integration."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step of the config flow."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_APP_ID])
|
||||
self._abort_if_unique_id_configured()
|
||||
api_client = ImouOpenApiClient(
|
||||
user_input[CONF_APP_ID],
|
||||
user_input[CONF_APP_SECRET],
|
||||
API_URLS[user_input[CONF_API_URL]],
|
||||
)
|
||||
try:
|
||||
await api_client.async_get_token()
|
||||
except InvalidAppIdOrSecretException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ConnectFailedException, RequestFailedException:
|
||||
errors["base"] = "cannot_connect"
|
||||
except ImouException as exception:
|
||||
_LOGGER.debug("Imou error during config flow: %s", exception)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="Imou",
|
||||
data={
|
||||
CONF_APP_ID: user_input[CONF_APP_ID],
|
||||
CONF_APP_SECRET: user_input[CONF_APP_SECRET],
|
||||
CONF_API_URL: user_input[CONF_API_URL],
|
||||
},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_APP_ID): str,
|
||||
vol.Required(CONF_APP_SECRET): str,
|
||||
vol.Required(CONF_API_URL, default="sg"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(API_URLS),
|
||||
translation_key="api_url",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Constants."""
|
||||
|
||||
from pyimouapi.ha_device import ImouHaDevice
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "imou"
|
||||
|
||||
|
||||
def imou_device_identifier(device: ImouHaDevice) -> str:
|
||||
"""Return a device registry identifier (device_id + channel when present)."""
|
||||
if device.channel_id is not None:
|
||||
return f"{device.device_id}_{device.channel_id}"
|
||||
return device.device_id
|
||||
|
||||
|
||||
# API URL region mapping
|
||||
API_URLS: dict[str, str] = {
|
||||
"sg": "openapi-sg.easy4ip.com",
|
||||
"eu": "openapi-or.easy4ip.com",
|
||||
"na": "openapi-fk.easy4ip.com",
|
||||
"cn": "openapi.lechange.cn",
|
||||
}
|
||||
|
||||
CONF_API_URL = "api_url"
|
||||
CONF_APP_ID = "app_id"
|
||||
CONF_APP_SECRET = "app_secret"
|
||||
|
||||
PARAM_STATUS = "status"
|
||||
PARAM_STATE = "state"
|
||||
|
||||
|
||||
# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API).
|
||||
PTZ_MOVE_DURATION_MS = 500
|
||||
|
||||
# Upper bound for a full coordinator refresh (device list + status for all devices).
|
||||
UPDATE_TIMEOUT = 300
|
||||
|
||||
PLATFORMS = [Platform.BUTTON]
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Provides the Imou DataUpdateCoordinator."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyimouapi.exceptions import ImouException
|
||||
from pyimouapi.ha_device import ImouHaDevice, ImouHaDeviceManager
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, UPDATE_TIMEOUT, imou_device_identifier
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=120)
|
||||
|
||||
type ImouConfigEntry = ConfigEntry[ImouDataUpdateCoordinator]
|
||||
|
||||
|
||||
class ImouDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Data update coordinator for Imou devices."""
|
||||
|
||||
config_entry: ImouConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
device_manager: ImouHaDeviceManager,
|
||||
config_entry: ImouConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the Imou data update coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="ImouDataUpdateCoordinator",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
always_update=True,
|
||||
)
|
||||
self._device_manager = device_manager
|
||||
self.devices_by_key: dict[str, ImouHaDevice] = {}
|
||||
self._devices_initialized = False
|
||||
self.new_device_callbacks: list[Callable[[list[ImouHaDevice]], None]] = []
|
||||
|
||||
@property
|
||||
def devices(self) -> list[ImouHaDevice]:
|
||||
"""Return the list of devices."""
|
||||
return list(self.devices_by_key.values())
|
||||
|
||||
@property
|
||||
def device_manager(self) -> ImouHaDeviceManager:
|
||||
"""Return the device manager."""
|
||||
return self._device_manager
|
||||
|
||||
def get_device(self, device_key: str) -> ImouHaDevice | None:
|
||||
"""Return the current device for device_key, if still on the account."""
|
||||
return self.devices_by_key.get(device_key)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update coordinator data."""
|
||||
try:
|
||||
async with asyncio.timeout(UPDATE_TIMEOUT):
|
||||
fresh_devices = await self._device_manager.async_get_devices()
|
||||
except TimeoutError as err:
|
||||
raise UpdateFailed(f"Timeout while fetching data: {err}") from err
|
||||
except ImouException as err:
|
||||
raise UpdateFailed(f"Error fetching Imou devices: {err}") from err
|
||||
|
||||
fresh_by_key = {
|
||||
imou_device_identifier(device): device for device in fresh_devices
|
||||
}
|
||||
self._async_add_remove_devices(fresh_by_key)
|
||||
devices = list(self.devices_by_key.values())
|
||||
if not devices:
|
||||
return
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(UPDATE_TIMEOUT):
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
self._device_manager.async_update_device_status(device)
|
||||
for device in devices
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except TimeoutError as err:
|
||||
raise UpdateFailed(f"Timeout while fetching data: {err}") from err
|
||||
|
||||
failures: list[Exception] = []
|
||||
for device, result in zip(devices, results, strict=True):
|
||||
if isinstance(result, BaseException) and not isinstance(result, Exception):
|
||||
# Propagate CancelledError and other BaseExceptions instead of
|
||||
# swallowing them as a regular device failure.
|
||||
raise result
|
||||
if not isinstance(result, Exception):
|
||||
continue
|
||||
device_key = imou_device_identifier(device)
|
||||
_LOGGER.warning(
|
||||
"Error updating status for Imou device %s: %s",
|
||||
device_key,
|
||||
result,
|
||||
)
|
||||
failures.append(result)
|
||||
if failures and len(failures) == len(devices):
|
||||
raise UpdateFailed(
|
||||
f"Error updating Imou devices: {failures[0]}"
|
||||
) from failures[0]
|
||||
|
||||
def _async_add_remove_devices(self, fresh_by_key: dict[str, ImouHaDevice]) -> None:
|
||||
"""Add new devices, remove devices no longer in the account.
|
||||
|
||||
This only tracks which devices exist on the account; per-device state
|
||||
is updated in place by `async_update_device_status`, so devices that
|
||||
remain on the account keep their existing object and are not replaced.
|
||||
"""
|
||||
if not self._devices_initialized:
|
||||
self.devices_by_key = fresh_by_key
|
||||
self._devices_initialized = True
|
||||
return
|
||||
|
||||
current_keys = set(fresh_by_key)
|
||||
known_keys = set(self.devices_by_key)
|
||||
|
||||
if current_keys == known_keys:
|
||||
return
|
||||
|
||||
if removed_keys := known_keys - current_keys:
|
||||
_LOGGER.debug("Removed Imou device(s): %s", ", ".join(removed_keys))
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_key in removed_keys:
|
||||
del self.devices_by_key[device_key]
|
||||
if device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, device_key)}
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
if new_keys := current_keys - known_keys:
|
||||
_LOGGER.debug("New Imou device(s) found: %s", ", ".join(new_keys))
|
||||
new_devices = []
|
||||
for device_key in new_keys:
|
||||
self.devices_by_key[device_key] = fresh_by_key[device_key]
|
||||
new_devices.append(fresh_by_key[device_key])
|
||||
for callback in self.new_device_callbacks:
|
||||
callback(new_devices)
|
||||
@@ -1,59 +0,0 @@
|
||||
"""An abstract class common to all Imou entities."""
|
||||
|
||||
from pyimouapi.ha_device import DeviceStatus, ImouHaDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, PARAM_STATE, PARAM_STATUS, imou_device_identifier
|
||||
from .coordinator import ImouDataUpdateCoordinator
|
||||
|
||||
|
||||
class ImouEntity(CoordinatorEntity[ImouDataUpdateCoordinator]):
|
||||
"""Base class for all Imou entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ImouDataUpdateCoordinator,
|
||||
entity_type: str,
|
||||
device: ImouHaDevice,
|
||||
) -> None:
|
||||
"""Initialize the Imou entity."""
|
||||
super().__init__(coordinator)
|
||||
self._entity_type = entity_type
|
||||
self._device_key = imou_device_identifier(device)
|
||||
self._attr_unique_id = f"{self._device_key}${entity_type}"
|
||||
self._attr_translation_key = entity_type
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._device_key)},
|
||||
name=device.channel_name or device.device_name,
|
||||
manufacturer=device.manufacturer,
|
||||
model=device.model,
|
||||
sw_version=device.swversion,
|
||||
serial_number=device.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def device(self) -> ImouHaDevice:
|
||||
"""Return the live device from the coordinator.
|
||||
|
||||
Callers must guard with `available` first; accessing this for a device
|
||||
that has left the account raises `KeyError`.
|
||||
"""
|
||||
return self.coordinator.devices_by_key[self._device_key]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the entity is available."""
|
||||
if (
|
||||
not super().available
|
||||
or self._device_key not in self.coordinator.devices_by_key
|
||||
):
|
||||
return False
|
||||
if PARAM_STATUS not in self.device.sensors:
|
||||
return False
|
||||
return (
|
||||
self.device.sensors[PARAM_STATUS][PARAM_STATE] != DeviceStatus.OFFLINE.value
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"ptz_down": {
|
||||
"default": "mdi:arrow-down-bold"
|
||||
},
|
||||
"ptz_left": {
|
||||
"default": "mdi:arrow-left-bold"
|
||||
},
|
||||
"ptz_right": {
|
||||
"default": "mdi:arrow-right-bold"
|
||||
},
|
||||
"ptz_up": {
|
||||
"default": "mdi:arrow-up-bold"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "imou",
|
||||
"name": "Imou",
|
||||
"codeowners": ["@Imou-OpenPlatform"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/imou",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyimouapi==1.2.7"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user