mirror of
https://github.com/home-assistant/core.git
synced 2026-06-03 18:03:43 +02:00
Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53211759cb | |||
| 4593059db2 | |||
| 593ae9eb80 | |||
| 37b4bcaa39 | |||
| 6bda3ea3a5 | |||
| f4db5fb346 | |||
| f04b0ee2c6 | |||
| 52c3e17de9 | |||
| 96c286f2e0 | |||
| 3e356de4e1 | |||
| 90a874d81b | |||
| 165024c6c9 | |||
| 66e4db3c0e | |||
| 0e6128c657 | |||
| 16febb36ba | |||
| dd7bd0c8a4 | |||
| c462a1c188 | |||
| 96c5110b7e | |||
| 64e8ed2737 | |||
| 4171d092f7 | |||
| 7af867ad4d | |||
| 907fe40304 | |||
| 261914c592 | |||
| 09637c1a3a | |||
| 0816385185 | |||
| 4b64b26870 | |||
| b20f9ad40a | |||
| 99d279bdd8 | |||
| 69e0e11077 | |||
| 9e3c143bd0 | |||
| 45c55543e9 | |||
| fb02e93a0c | |||
| a54b97eeca | |||
| 61c196405b | |||
| 9a047ad115 | |||
| 07a584057c | |||
| 5873dff1d9 | |||
| 30a2bd9b92 | |||
| 1065dce882 | |||
| 878a39194a | |||
| 2e2f4a7dcb | |||
| 46627984f8 | |||
| 5445f9e42b | |||
| 8ce2a5257d | |||
| 787828d7de | |||
| 9e96912a1e | |||
| fd578cfd4c | |||
| 94de8646c6 | |||
| 2d19e84d15 | |||
| a17cfbc2a5 | |||
| c552b0a067 | |||
| 80241a44d9 | |||
| d8b02ea6d6 | |||
| 36d2e85351 | |||
| 174ac9eafe | |||
| 772c426d5d | |||
| a32d028e3d | |||
| bc66c2610e | |||
| c22823ff8d | |||
| a21212ab7e | |||
| 71eefdc716 | |||
| f5819d400e | |||
| 31fcbe7bce | |||
| 3664eb4942 | |||
| 2f03b7c427 | |||
| 2d8cebf99d | |||
| 8ca4471418 | |||
| 02720605ae | |||
| fb28825f1f | |||
| 25ce81732b | |||
| 9c1cc55482 | |||
| 477756da5b | |||
| ec995a3472 | |||
| a19f3045e7 | |||
| 6a836bd1d9 | |||
| 01d390293b | |||
| b069bc2f03 | |||
| 7e36d265ed | |||
| 155cb38090 | |||
| 0f01148207 | |||
| a65503f203 | |||
| 063fa8df7e | |||
| 1865c16041 | |||
| cad177cdff | |||
| be2aaf926b | |||
| d7219aa025 | |||
| 7fb475aad1 | |||
| 25f18c6082 | |||
| c901160fb3 | |||
| 018e42e670 | |||
| 04442bb73e | |||
| eb3fd52619 | |||
| 8e19fd280e | |||
| e45f64b880 | |||
| 480a8d536f | |||
| 0abc9b787b | |||
| ce46be110d | |||
| c22f10bf87 | |||
| 97d9c23855 | |||
| 477d8bde6b | |||
| da12c94f27 | |||
| 52afa0627e | |||
| 9b8b8c2d82 | |||
| db0fc36a54 | |||
| cb00ee1503 | |||
| ae23c5db4d | |||
| d0b4274c2b | |||
| ecd132b60c | |||
| 58d5db7494 | |||
| 3c0a34cc66 | |||
| de70e86eae | |||
| 9d5bd5daff | |||
| c314ee77e1 | |||
| 2d262d940b | |||
| 425ce17d9c | |||
| 38a266ea6c | |||
| 55af1c3b3c | |||
| 7f1dce45c5 | |||
| 62bfaa9d92 | |||
| 088fd398e2 | |||
| 519104166d | |||
| 25f64eb78c | |||
| 3c0f6b7f2a | |||
| 94a976d974 | |||
| e377e9889a | |||
| cc897b926d | |||
| 6c04ca3685 |
@@ -8,39 +8,8 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
|
||||
## Follow these steps:
|
||||
1. Use 'gh pr view' to get the PR details and description.
|
||||
2. Use 'gh pr diff' to see all the changes in the PR.
|
||||
3. Analyze the code changes for:
|
||||
- Code quality and style consistency
|
||||
- Potential bugs or issues
|
||||
- Performance implications
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
4. Ensure any existing review comments have been addressed.
|
||||
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
|
||||
|
||||
## Verification:
|
||||
|
||||
- After the review, run parallel subagents for each finding to double check it.
|
||||
- Spawn up to a maximum of 10 parallel subagents at a time.
|
||||
- Gather the results from the subagents and summarize them in the final review comments.
|
||||
|
||||
3. Review the changes following the `review` skill. It is VERY IMPORTANT to follow the `review` skill instructions.
|
||||
4. Check if all existing review comments have been addressed.
|
||||
|
||||
## IMPORTANT:
|
||||
- Just review. DO NOT make any changes
|
||||
- Be constructive and specific in your comments
|
||||
- Suggest improvements where appropriate
|
||||
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
|
||||
- No need to run tests or linters, just review the code changes.
|
||||
- No need to highlight things that are already good.
|
||||
|
||||
## Output format:
|
||||
- List specific comments for each file/line that needs attention.
|
||||
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
|
||||
- Example output:
|
||||
```
|
||||
Overall assessment: request changes.
|
||||
- [CRITICAL] sensor.py:143 - Memory leak
|
||||
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
|
||||
- [SUGGESTION] test_init.py:45 - Improve x variable name
|
||||
```
|
||||
- Make sure to include the file and line number when possible in the bullet points.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: review
|
||||
description: Reviews code changes and provides constructive feedback. Should be used when a review is requested to provide a consistent review behavior and output format. This skill can be used for code reviews in general, not just for GitHub pull requests.
|
||||
---
|
||||
|
||||
# Review Code Changes
|
||||
|
||||
## Analyze the code changes for:
|
||||
- Code quality and style consistency
|
||||
- Potential bugs or issues
|
||||
- Performance implications
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
|
||||
## Verification:
|
||||
- After the review, run parallel subagents for each finding to double-check it.
|
||||
- Spawn up to a maximum of 10 parallel subagents at a time.
|
||||
- Gather the results from the subagents and summarize them in the final review comments.
|
||||
|
||||
## IMPORTANT:
|
||||
- Just review. DO NOT make any changes.
|
||||
- Be constructive and specific in your comments.
|
||||
- Suggest improvements where appropriate.
|
||||
- No need to run tests or linters, just review the code changes.
|
||||
- No need to highlight things that are already good.
|
||||
|
||||
## Output format:
|
||||
- List specific comments for each file/line that needs attention.
|
||||
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
|
||||
- Example output:
|
||||
```
|
||||
Overall assessment: request changes.
|
||||
- [CRITICAL] sensor.py:143 - Memory leak
|
||||
- [PROBLEM] data_processing.py:87 - Inefficient algorithm
|
||||
- [SUGGESTION] test_init.py:45 - Improve x variable name
|
||||
```
|
||||
- Make sure to include the file and line number when possible in the bullet points.
|
||||
+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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
# - github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
#
|
||||
# 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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
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@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@851cffe46851ddd2051ea7147ebdc995113241c3 # v6.0.1
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
+25
-67
@@ -20,22 +20,36 @@ 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
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
# 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
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
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
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
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: >
|
||||
@@ -48,65 +62,9 @@ jobs:
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
# 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
|
||||
# issue policy
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted"
|
||||
stale-issue-message: >
|
||||
|
||||
@@ -286,6 +286,7 @@ 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,6 +501,8 @@ 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
|
||||
@@ -718,6 +720,8 @@ 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
|
||||
@@ -836,6 +840,8 @@ 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
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
PERCENTAGE,
|
||||
UV_INDEX,
|
||||
UnitOfIrradiance,
|
||||
@@ -47,6 +46,8 @@ from .coordinator import (
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
PARTS_PER_CUBIC_METER = "p/m³"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AccuWeatherSensorDescription(SensorEntityDescription):
|
||||
@@ -81,7 +82,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Grass",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=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]]
|
||||
@@ -107,7 +108,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Mold",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=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]]
|
||||
@@ -116,7 +117,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Ragweed",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
@@ -184,7 +185,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Tree",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
|
||||
@@ -8,6 +8,7 @@ from bleak.backends.device import BLEDevice
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -63,7 +64,16 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
||||
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Airthings device with address {address}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
self.hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
self.ble_device = ble_device
|
||||
|
||||
|
||||
@@ -54,5 +54,10 @@
|
||||
"name": "Radon longterm level"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Airthings device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
|
||||
async def sync_media_state(self) -> None:
|
||||
"""Sync media state."""
|
||||
await self.api.sync_media_state()
|
||||
try:
|
||||
await self.api.sync_media_state()
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotConnect, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotRetrieveData, ValueError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def media_state_event_handler(
|
||||
self, media_state: dict[str, AmazonMediaState]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.8.1"]
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
@@ -38,18 +37,6 @@ 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,
|
||||
@@ -69,9 +56,10 @@ async def async_setup_entry(
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.extend(
|
||||
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||
for description in MEDIA_PLAYERS
|
||||
new_entities.append(
|
||||
AlexaDevicesMediaPlayer(
|
||||
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
|
||||
)
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
@@ -85,8 +73,6 @@ 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
|
||||
@@ -95,7 +81,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||
description: MediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
@@ -156,9 +142,11 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return True if the volume is muted."""
|
||||
if not self.volume_state:
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return None
|
||||
return self.volume_state.volume == 0
|
||||
# is_muted is True when Alexa has muted the device
|
||||
# volume == 0 is where we have muted by setting volume to 0
|
||||
return self.volume_state.is_muted or self.volume_state.volume == 0
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
@@ -212,7 +200,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
|
||||
|
||||
@@ -225,7 +213,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
await self.async_call_alexa_music(media_id, media_type)
|
||||
provider = media_type.value if isinstance(media_type, MediaType) else media_type
|
||||
await self.async_call_alexa_music(media_id, provider)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
@@ -259,12 +248,20 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
return
|
||||
if mute:
|
||||
self._prev_volume = self.volume_state.volume
|
||||
target_volume = 0
|
||||
else:
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(0)
|
||||
return
|
||||
|
||||
if self.volume_state.is_muted and self._prev_volume is None:
|
||||
# is muted by Alexa which we can see but not control
|
||||
# when muted this way, volume is still set
|
||||
# changing volume will unmute
|
||||
# if HA set volume to 0 then Alexa muted we just default to 30%
|
||||
self._prev_volume = self.volume_state.volume or 30
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(target_volume / 100)
|
||||
self._prev_volume = None
|
||||
|
||||
@alexa_api_call
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
|
||||
@@ -125,6 +125,9 @@
|
||||
},
|
||||
"invalid_sound_value": {
|
||||
"message": "Invalid sound {sound} specified"
|
||||
},
|
||||
"unknown_exception": {
|
||||
"message": "Unknown error occurred: {error}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -114,7 +113,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 slugify
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
from homeassistant.util.json import JsonArrayType, JsonObjectType
|
||||
|
||||
from .const import (
|
||||
@@ -372,7 +371,7 @@ def _convert_content( # noqa: C901
|
||||
)
|
||||
if (
|
||||
content.native.container is not None
|
||||
and content.native.container.expires_at > datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
and content.native.container.expires_at > dt_util.utcnow()
|
||||
):
|
||||
container_id = content.native.container.id
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["apprise"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["apprise==1.9.1"]
|
||||
"requirements": ["apprise==1.11.0"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": [],
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/arwn",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy"
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["arwn-client==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -3,113 +3,26 @@
|
||||
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,
|
||||
@@ -118,28 +31,25 @@ 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 events as sensors.
|
||||
"""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
|
||||
|
||||
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:
|
||||
if device is None:
|
||||
return
|
||||
|
||||
if (store := hass.data.get(DATA_ARWN)) is None:
|
||||
@@ -148,22 +58,71 @@ async def async_setup_platform(
|
||||
if "timestamp" in event:
|
||||
del event["timestamp"]
|
||||
|
||||
for sensor in sensors:
|
||||
if sensor.name not in store:
|
||||
sensor.hass = hass
|
||||
sensor.set_event(event)
|
||||
store[sensor.name] = sensor
|
||||
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
|
||||
_LOGGER.debug(
|
||||
"Registering sensor %(name)s => %(event)s",
|
||||
{"name": sensor.name, "event": event},
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
)
|
||||
async_add_entities((sensor,), True)
|
||||
new_sensors.append(sensor)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Recording sensor %(name)s => %(event)s",
|
||||
{"name": sensor.name, "event": event},
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
)
|
||||
store[sensor.name].set_event(event)
|
||||
store[unique_id].set_event(event)
|
||||
|
||||
if new_sensors:
|
||||
async_add_entities(new_sensors, True)
|
||||
|
||||
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
|
||||
|
||||
@@ -175,29 +134,29 @@ class ArwnSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
topic: str,
|
||||
unique_id: 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
|
||||
# This mqtt topic for the sensor which is its uid
|
||||
self._attr_unique_id = topic
|
||||
self._attr_unique_id = unique_id
|
||||
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."""
|
||||
ev: dict[str, Any] = {}
|
||||
ev.update(event)
|
||||
self._attr_extra_state_attributes = ev
|
||||
self._attr_native_value = ev.get(self._state_key)
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(self._state_key)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
import avea
|
||||
|
||||
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothReachabilityIntent,
|
||||
async_address_reachability_diagnostics,
|
||||
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]
|
||||
@@ -15,12 +21,20 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Set up Avea from a config entry."""
|
||||
ble_device = async_ble_device_from_address(
|
||||
hass, entry.data[CONF_ADDRESS], connectable=True
|
||||
)
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
ble_device = async_ble_device_from_address(hass, address, connectable=True)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
entry.runtime_data = avea.Bulb(ble_device)
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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,7 +6,6 @@ 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,
|
||||
@@ -18,10 +17,9 @@ 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 = [
|
||||
@@ -35,8 +33,6 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bool:
|
||||
"""Set up BleBox devices from a config entry."""
|
||||
@@ -58,7 +54,10 @@ 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
|
||||
|
||||
entry.runtime_data = product
|
||||
coordinator = BleBoxCoordinator(hass, entry, product)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@ 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",
|
||||
@@ -27,23 +30,27 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxBinarySensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("binary_sensors", [])
|
||||
BleBoxBinarySensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("binary_sensors", [])
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity):
|
||||
"""Representation of a BleBox binary sensor feature."""
|
||||
|
||||
def __init__(
|
||||
self, feature: BinarySensorFeature, description: BinarySensorEntityDescription
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: BinarySensorFeature,
|
||||
description: BinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a BleBox binary sensor feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,7 +7,11 @@ 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(
|
||||
@@ -16,19 +20,22 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox button entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxButtonEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("buttons", [])
|
||||
BleBoxButtonEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("buttons", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
|
||||
"""Representation of BleBox buttons."""
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.button.Button) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
"""Initialize a BleBox button feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self._attr_icon = self.get_icon()
|
||||
|
||||
def get_icon(self) -> str | None:
|
||||
@@ -45,6 +52,7 @@ 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,6 +1,5 @@
|
||||
"""BleBox climate entity."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.climate
|
||||
@@ -17,8 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BLEBOX_TO_HVACMODE = {
|
||||
0: HVACMode.OFF,
|
||||
@@ -40,11 +40,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox climate entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxClimateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("climates", [])
|
||||
BleBoxClimateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("climates", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
|
||||
@@ -108,6 +109,7 @@ 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]:
|
||||
@@ -116,6 +118,7 @@ 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,23 +33,14 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LOG_MSG = {
|
||||
@@ -69,18 +60,44 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.device_config: dict[str, Any] = {}
|
||||
|
||||
def handle_step_exception(
|
||||
self, step, exception, schema, host, port, message_id, log_fn
|
||||
self, exception, schema, host, port, message_id, log_fn, step_id
|
||||
):
|
||||
"""Handle step exceptions."""
|
||||
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
step_id=step_id,
|
||||
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:
|
||||
@@ -145,12 +162,11 @@ 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=schema,
|
||||
data_schema=STEP_SCHEMA,
|
||||
errors={},
|
||||
description_placeholders={},
|
||||
)
|
||||
@@ -173,36 +189,60 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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,7 +17,11 @@ 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,
|
||||
@@ -59,19 +63,22 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxCoverEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("covers", [])
|
||||
BleBoxCoverEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("covers", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Representation of a BleBox cover feature."""
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
|
||||
) -> None:
|
||||
"""Initialize a BleBox cover feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
@@ -135,33 +142,40 @@ 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,23 +1,20 @@
|
||||
"""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.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .coordinator import BleBoxCoordinator
|
||||
|
||||
|
||||
class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
"""Implements a common class for entities representing a BleBox feature."""
|
||||
|
||||
def __init__(self, feature: _FeatureT) -> None:
|
||||
def __init__(self, coordinator: BleBoxCoordinator, 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
|
||||
@@ -30,10 +27,3 @@ class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
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,6 +1,5 @@
|
||||
"""BleBox light entities implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -24,11 +23,13 @@ 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__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -37,11 +38,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxLightEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("lights", [])
|
||||
BleBoxLightEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("lights", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
COLOR_MODE_MAP = {
|
||||
@@ -61,9 +63,11 @@ 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, feature: blebox_uniapi.light.Light) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light
|
||||
) -> None:
|
||||
"""Initialize a BleBox light."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
if feature.effect_list:
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
@@ -165,6 +169,7 @@ 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."""
|
||||
|
||||
@@ -224,6 +229,7 @@ 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, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import blebox_uniapi.sensor
|
||||
|
||||
@@ -28,9 +28,10 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
SENSOR_TYPES = (
|
||||
@@ -124,13 +125,14 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("sensors", [])
|
||||
BleBoxSensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("sensors", [])
|
||||
for description in SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
|
||||
@@ -138,11 +140,12 @@ 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__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"address_already_configured": "A BleBox device is already configured at {address}.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -11,6 +13,16 @@
|
||||
},
|
||||
"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%]",
|
||||
@@ -22,5 +34,10 @@
|
||||
"title": "Set up your BleBox device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"message": "An error occurred while communicating with the BleBox device: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""BleBox switch implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.switch
|
||||
@@ -11,8 +10,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,11 +21,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox switch entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSwitchEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("switches", [])
|
||||
BleBoxSwitchEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("switches", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity):
|
||||
@@ -38,10 +39,12 @@ 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,8 +18,10 @@ 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)
|
||||
|
||||
|
||||
@@ -33,11 +35,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox update entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxUpdateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
||||
BleBoxUpdateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("updates", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
||||
@@ -48,9 +51,16 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
||||
@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:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self._in_progress_old_version: str | None = None
|
||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
||||
self._poll_attempts: int = 0
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""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
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.8.0"
|
||||
"habluetooth==6.8.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -508,6 +509,15 @@ 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,
|
||||
@@ -569,6 +579,25 @@ 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.5.5"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ from denonavr.const import (
|
||||
from denonavr.exceptions import (
|
||||
AvrCommandError,
|
||||
AvrForbiddenError,
|
||||
AvrIncompleteResponseError,
|
||||
AvrInvalidResponseError,
|
||||
AvrNetworkError,
|
||||
AvrProcessingError,
|
||||
AvrTimoutError,
|
||||
@@ -191,6 +193,17 @@ def async_log_errors[_DenonDeviceT: DenonDevice, **_P, _R](
|
||||
self._receiver.host,
|
||||
)
|
||||
self._attr_available = False
|
||||
except AvrInvalidResponseError, AvrIncompleteResponseError:
|
||||
available = False
|
||||
if self.available:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Denon AVR receiver at host %s returned malformed response. "
|
||||
"Device is unavailable"
|
||||
),
|
||||
self._receiver.host,
|
||||
)
|
||||
self._attr_available = False
|
||||
except AvrCommandError as err:
|
||||
available = False
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""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
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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
|
||||
@@ -0,0 +1,29 @@
|
||||
"""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
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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
|
||||
@@ -0,0 +1,185 @@
|
||||
"""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
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,14 @@ from eq3btsmart import Thermostat
|
||||
from eq3btsmart.exceptions import Eq3Exception
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
|
||||
from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
|
||||
from .models import Eq3Config, Eq3ConfigEntryData
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -49,7 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
|
||||
|
||||
if device is None:
|
||||
raise ConfigEntryNotReady(
|
||||
f"[{eq3_config.mac_address}] Device could not be found"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"mac_address": eq3_config.mac_address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
mac_address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
thermostat = Thermostat(device)
|
||||
|
||||
@@ -61,5 +61,10 @@
|
||||
"name": "Lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "[{mac_address}] Device could not be found: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for entities of the Evohome integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -14,6 +13,7 @@ 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 < datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
and until < dt_util.utcnow()
|
||||
)
|
||||
): # 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 UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from evohomeasync.auth import (
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
|
||||
@@ -91,8 +92,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
|
||||
|
||||
session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
|
||||
if session_id_expires is None:
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
|
||||
self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15)
|
||||
else:
|
||||
self._session_id_expires = datetime.fromisoformat(session_id_expires)
|
||||
|
||||
|
||||
@@ -23,14 +23,18 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN, USER_AGENT
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_fetch_feed(hass: HomeAssistant, url: str) -> feedparser.FeedParserDict:
|
||||
"""Fetch the feed."""
|
||||
return await hass.async_add_executor_job(feedparser.parse, url)
|
||||
|
||||
def _parse_feed() -> feedparser.FeedParserDict:
|
||||
return feedparser.parse(url, agent=USER_AGENT)
|
||||
|
||||
return await hass.async_add_executor_job(_parse_feed)
|
||||
|
||||
|
||||
class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import APPLICATION_NAME, __version__ as ha_version
|
||||
|
||||
DOMAIN: Final[str] = "feedreader"
|
||||
|
||||
CONF_MAX_ENTRIES: Final[str] = "max_entries"
|
||||
@@ -10,3 +12,5 @@ DEFAULT_MAX_ENTRIES: Final[int] = 20
|
||||
DEFAULT_SCAN_INTERVAL: Final[timedelta] = timedelta(hours=1)
|
||||
|
||||
EVENT_FEEDREADER: Final[str] = "feedreader"
|
||||
|
||||
USER_AGENT: Final[str] = f"{APPLICATION_NAME}/{ha_version}"
|
||||
|
||||
@@ -18,7 +18,13 @@ from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER
|
||||
from .const import (
|
||||
CONF_MAX_ENTRIES,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
EVENT_FEEDREADER,
|
||||
USER_AGENT,
|
||||
)
|
||||
|
||||
DELAY_SAVE = 30
|
||||
STORAGE_VERSION = 1
|
||||
@@ -74,6 +80,7 @@ class FeedReaderCoordinator(
|
||||
self.url,
|
||||
etag=None if not self._feed else self._feed.get("etag"),
|
||||
modified=None if not self._feed else self._feed.get("modified"),
|
||||
agent=USER_AGENT,
|
||||
)
|
||||
|
||||
feed = await self.hass.async_add_executor_job(_parse_feed)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from flexit_bacnet import (
|
||||
OPERATION_MODE_AWAY,
|
||||
OPERATION_MODE_COOKER_HOOD,
|
||||
OPERATION_MODE_FIREPLACE,
|
||||
OPERATION_MODE_HIGH,
|
||||
OPERATION_MODE_HOME,
|
||||
OPERATION_MODE_OFF,
|
||||
OPERATION_MODE_TEMPORARY_HIGH,
|
||||
VENTILATION_MODE_AWAY,
|
||||
VENTILATION_MODE_HIGH,
|
||||
VENTILATION_MODE_HOME,
|
||||
@@ -28,7 +30,9 @@ OPERATION_TO_PRESET_MODE_MAP = {
|
||||
OPERATION_MODE_AWAY: PRESET_AWAY,
|
||||
OPERATION_MODE_HOME: PRESET_HOME,
|
||||
OPERATION_MODE_HIGH: PRESET_HIGH,
|
||||
OPERATION_MODE_COOKER_HOOD: PRESET_HIGH,
|
||||
OPERATION_MODE_FIREPLACE: PRESET_FIREPLACE,
|
||||
OPERATION_MODE_TEMPORARY_HIGH: PRESET_HIGH,
|
||||
}
|
||||
|
||||
# Map preset to ventilation mode (for setting standard modes)
|
||||
|
||||
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FlussConfigEntry, FlussDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -6,3 +6,4 @@ import logging
|
||||
DOMAIN = "fluss"
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_INTERVAL = timedelta(minutes=30)
|
||||
COMMAND_REFRESH_COOLDOWN = 10
|
||||
|
||||
@@ -13,10 +13,11 @@ 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 LOGGER, UPDATE_INTERVAL
|
||||
from .const import COMMAND_REFRESH_COOLDOWN, LOGGER, UPDATE_INTERVAL
|
||||
|
||||
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
|
||||
|
||||
@@ -35,18 +36,24 @@ 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_connectivity(self, device_id: str) -> bool:
|
||||
"""Return connectivity for a device; False if the status call fails."""
|
||||
async def _async_get_status(self, device_id: str) -> dict[str, Any]:
|
||||
"""Return per-device status."""
|
||||
try:
|
||||
status = await self.api.async_get_device_status(device_id)
|
||||
except FlussApiClientError:
|
||||
return False
|
||||
return status["status"]["internetConnected"]
|
||||
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"]
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||
"""Fetch Fluss+ devices and merge per-device connectivity status."""
|
||||
"""Fetch Fluss+ devices and merge per-device status."""
|
||||
try:
|
||||
devices = await self.api.async_get_devices()
|
||||
except FlussApiClientAuthenticationError as err:
|
||||
@@ -59,10 +66,11 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
for device in devices["devices"]
|
||||
if device["userPermissions"]["canUseWiFi"]
|
||||
]
|
||||
connectivity = await asyncio.gather(
|
||||
*(self._async_get_connectivity(d["deviceId"]) for d in device_list)
|
||||
|
||||
statuses = await asyncio.gather(
|
||||
*(self._async_get_status(d["deviceId"]) for d in device_list)
|
||||
)
|
||||
return {
|
||||
device["deviceId"]: {**device, "internetConnected": connected}
|
||||
for device, connected in zip(device_list, connectivity, strict=False)
|
||||
device["deviceId"]: {**device, **status}
|
||||
for device, status in zip(device_list, statuses, strict=False)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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,5 +19,10 @@
|
||||
"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.1"]
|
||||
"requirements": ["home-assistant-frontend==20260527.4"]
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
@fs_command_exception_wrap
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
if (await self.fs_device.get_play_state()) == PlayState.STOPPED:
|
||||
if (await self.fs_device.get_play_status()) == PlayState.STOPPED:
|
||||
# The 'play' command only seems to work when the current stream is paused.
|
||||
# We need to send a 'stop' command instead to resume a stopped stream.
|
||||
await self.fs_device.stop()
|
||||
|
||||
@@ -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=10011
|
||||
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
|
||||
- Auth failure is signalled by raising GrowattV1ApiError with
|
||||
error_code=GrowattV1ApiErrorCode.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 == V1_API_ERROR_NO_PRIVILEGE
|
||||
- V1 API: catch GrowattV1ApiError with error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE
|
||||
→ raise ConfigEntryAuthFailed
|
||||
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
|
||||
"""
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
@@ -58,8 +59,6 @@ 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
|
||||
@@ -239,15 +238,24 @@ def _login_classic_api(
|
||||
login_response = api.login(username, password)
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
f"Error communicating with Growatt API during login: {ex}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
|
||||
if not login_response.get("success"):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
_LOGGER.debug("Growatt login failed: %s", msg)
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
|
||||
raise ConfigEntryError(f"Growatt login failed: {msg}")
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
)
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
translation_placeholders={"message": msg},
|
||||
)
|
||||
|
||||
return login_response
|
||||
|
||||
@@ -265,17 +273,25 @@ def get_device_list_v1(
|
||||
try:
|
||||
devices_dict = api.device_list(plant_id)
|
||||
except growattServer.GrowattV1ApiError as e:
|
||||
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
if e.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": e.error_msg or str(e)},
|
||||
) from e
|
||||
if e.error_code == V1_API_ERROR_RATE_LIMITED:
|
||||
if e.error_code == GrowattV1ApiErrorCode.RATE_LIMITED:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Growatt API rate limited, will retry: {e.error_msg or str(e)}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="rate_limited",
|
||||
translation_placeholders={"error": e.error_msg or str(e)},
|
||||
) from e
|
||||
raise ConfigEntryError(
|
||||
f"API error during device list: {e.error_msg or str(e)}"
|
||||
f" (Code: {e.error_code})"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error_with_code",
|
||||
translation_placeholders={
|
||||
"error": e.error_msg or str(e),
|
||||
"code": str(e.error_code),
|
||||
},
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
supported_devices = [
|
||||
@@ -349,10 +365,15 @@ async def async_setup_entry(
|
||||
devices = await hass.async_add_executor_job(api.device_list, plant_id)
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
f"Error communicating with Growatt API during device list: {ex}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
else:
|
||||
raise ConfigEntryError("Unknown authentication type in config entry.")
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_auth_type",
|
||||
)
|
||||
|
||||
# Create a coordinator for the total sensors
|
||||
total_coordinator = GrowattCoordinator(
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import growattServer
|
||||
from growattServer import GrowattV1ApiErrorCode
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -32,7 +33,6 @@ 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 == V1_API_ERROR_NO_PRIVILEGE:
|
||||
if err.error_code == GrowattV1ApiErrorCode.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 == V1_API_ERROR_NO_PRIVILEGE:
|
||||
if e.error_code == GrowattV1ApiErrorCode.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,13 +42,6 @@ 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,6 +6,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import growattServer
|
||||
from growattServer import GrowattV1ApiErrorCode
|
||||
from requests import RequestException
|
||||
|
||||
from homeassistant.components.sensor import SensorStateClass
|
||||
@@ -27,7 +28,6 @@ from .const import (
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
V1_API_ERROR_NO_PRIVILEGE,
|
||||
V1_DEVICE_TYPES,
|
||||
)
|
||||
from .models import GrowattRuntimeData
|
||||
@@ -113,9 +113,11 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if device.get("type") in V1_DEVICE_TYPES
|
||||
]
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
) from err
|
||||
_LOGGER.debug("Failed to fetch V1 device list during scan: %s", err)
|
||||
self.device_list = None
|
||||
@@ -157,9 +159,14 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
msg = login_response.get("msg", "Unknown error")
|
||||
if msg == LOGIN_INVALID_AUTH_CODE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Username, password, or URL may be incorrect"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_credentials",
|
||||
)
|
||||
raise UpdateFailed(f"Growatt login failed: {msg}")
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="login_failed",
|
||||
translation_placeholders={"message": msg},
|
||||
)
|
||||
|
||||
if self.device_type == "total":
|
||||
if self.api_version == "v1":
|
||||
@@ -179,13 +186,18 @@ 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 == V1_API_ERROR_NO_PRIVILEGE:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={
|
||||
"error": err.error_msg or str(err)
|
||||
},
|
||||
) from err
|
||||
raise UpdateFailed(
|
||||
f"Error fetching plant energy overview: {err}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
total_info["todayEnergy"] = total_info["today_energy"]
|
||||
total_info["totalEnergy"] = total_info["total_energy"]
|
||||
@@ -212,12 +224,17 @@ 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 == V1_API_ERROR_NO_PRIVILEGE:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching min device data: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
min_info = {**min_details, **min_settings, **min_energy}
|
||||
self.data = min_info
|
||||
@@ -240,12 +257,17 @@ 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 == V1_API_ERROR_NO_PRIVILEGE:
|
||||
if err.error_code == GrowattV1ApiErrorCode.NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication failed for Growatt API:"
|
||||
f" {err.error_msg or str(err)}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
translation_placeholders={"error": err.error_msg or str(err)},
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
combined = {**sph_detail, **sph_energy}
|
||||
|
||||
@@ -313,7 +335,11 @@ 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(f"Error fetching data: {err}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_data_failed",
|
||||
translation_placeholders={"error": str(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": "silver",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["growattServer==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
|
||||
@@ -595,6 +595,15 @@
|
||||
"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."
|
||||
},
|
||||
@@ -604,6 +613,9 @@
|
||||
"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}."
|
||||
},
|
||||
@@ -613,6 +625,9 @@
|
||||
"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}."
|
||||
},
|
||||
@@ -634,11 +649,20 @@
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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",
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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 Any, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from pyHomee.const import AttributeType, NodeProfile
|
||||
from pyHomee.model import HomeeAttribute, HomeeNode
|
||||
@@ -35,6 +35,12 @@ 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:
|
||||
@@ -83,9 +89,23 @@ async def add_cover_entities(
|
||||
nodes: list[HomeeNode],
|
||||
) -> None:
|
||||
"""Add homee cover entities."""
|
||||
async_add_entities(
|
||||
HomeeCover(node, config_entry) for node in nodes if is_cover_node(node)
|
||||
)
|
||||
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 def async_setup_entry(
|
||||
@@ -187,7 +207,7 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
def is_closed(self) -> bool:
|
||||
"""Return if the cover is closed."""
|
||||
if (
|
||||
attribute := self._node.get_attribute_by_type(AttributeType.POSITION)
|
||||
@@ -200,15 +220,16 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
|
||||
return self._open_close_attribute.get_value() == 0
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
return None
|
||||
return attribute.get_value() == attribute.minimum
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
|
||||
@@ -6,6 +6,7 @@ from bleak import BleakError
|
||||
from bleak_retry_connector import close_stale_connections_by_address, get_device
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -56,7 +57,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) ->
|
||||
)
|
||||
except (TimeoutError, BleakError) as exception:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to device {address} due to {exception}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"error": str(exception) or type(exception).__name__,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
) from exception
|
||||
|
||||
LOGGER.debug("connected and paired")
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_failed": {
|
||||
"message": "Unable to connect to device {address} due to {error}: {reason}"
|
||||
},
|
||||
"pin_required": {
|
||||
"message": "PIN is required for {domain_name}"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,14 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from pygti.exceptions import InvalidAuth
|
||||
from pygti.exceptions import GTIError
|
||||
from pygti.models import (
|
||||
ElevatorState,
|
||||
SDName,
|
||||
SDNameType,
|
||||
StationInformationRequest,
|
||||
StationInformationResponse,
|
||||
)
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -38,20 +45,21 @@ async def async_setup_entry(
|
||||
station = entry.data[CONF_STATION]
|
||||
|
||||
def get_elevator_entities_from_station_information(
|
||||
station_name, station_information
|
||||
):
|
||||
station_name: str,
|
||||
station_information: StationInformationResponse | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert station information into a list of elevators."""
|
||||
elevators = {}
|
||||
|
||||
if station_information is None:
|
||||
return {}
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
if label is not None:
|
||||
name = f"Elevator {label}"
|
||||
@@ -61,7 +69,7 @@ async def async_setup_entry(
|
||||
if description is not None:
|
||||
name += f" ({description})"
|
||||
|
||||
lines = elevator.get("lines")
|
||||
lines = elevator.lines
|
||||
|
||||
idx = f"{station_name}-{label}-{lines}"
|
||||
|
||||
@@ -70,33 +78,35 @@ async def async_setup_entry(
|
||||
"name": name,
|
||||
"available": available,
|
||||
"attributes": {
|
||||
"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"),
|
||||
"cabin_width": elevator.cabinWidth,
|
||||
"cabin_length": elevator.cabinLength,
|
||||
"door_width": elevator.doorWidth,
|
||||
"elevator_type": elevator.elevatorType,
|
||||
"button_type": elevator.buttonType,
|
||||
"cause": elevator.cause,
|
||||
"lines": lines,
|
||||
},
|
||||
}
|
||||
return elevators
|
||||
|
||||
async def async_update_data():
|
||||
async def async_update_data() -> dict[str, Any]:
|
||||
"""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 = {"station": {"id": station["id"], "type": station["type"]}}
|
||||
payload = StationInformationRequest(
|
||||
station=SDName(id=station["id"], type=SDNameType(station["type"]))
|
||||
)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
return get_elevator_entities_from_station_information(
|
||||
station_name, await hub.gti.stationInformation(payload)
|
||||
station_name, await hub.gti.getStationInformation(payload)
|
||||
)
|
||||
except InvalidAuth as err:
|
||||
raise UpdateFailed(f"Authentication failed: {err}") from err
|
||||
except GTIError as err:
|
||||
raise UpdateFailed(f"GTI API error: {err}") from err
|
||||
except ClientConnectorError as err:
|
||||
raise UpdateFailed(f"Network not available: {err}") from err
|
||||
except Exception as err:
|
||||
@@ -129,7 +139,12 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
def __init__(self, coordinator, idx, config_entry):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataUpdateCoordinator[dict[str, Any]],
|
||||
idx: str,
|
||||
config_entry: HVVConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
self.coordinator = coordinator
|
||||
@@ -140,7 +155,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"],
|
||||
@@ -154,7 +169,7 @@ class HvvDepartureBinarySensor(CoordinatorEntity, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return entity state."""
|
||||
return self.coordinator.data[self.idx]["state"]
|
||||
return bool(self.coordinator.data[self.idx]["state"])
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -3,8 +3,17 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from pygti.auth import GTI_DEFAULT_HOST
|
||||
from pygti.exceptions import CannotConnect, InvalidAuth
|
||||
from pygti.exceptions import GTIError, GTIUnauthorizedError
|
||||
from pygti.models import (
|
||||
CNRequest,
|
||||
DLRequest,
|
||||
GTITime,
|
||||
RegionalSDNameType,
|
||||
SDName,
|
||||
SDNameType,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
@@ -66,10 +75,10 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
response = await self.hub.authenticate()
|
||||
_LOGGER.debug("Init gti: %r", response)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
except GTIUnauthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except GTIError, ClientConnectorError:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
self.data = user_input
|
||||
@@ -87,15 +96,14 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
check_name = await self.hub.gti.checkName(
|
||||
{"theName": {"name": user_input[CONF_STATION]}, "maxList": 20}
|
||||
CNRequest(theName=SDName(name=user_input[CONF_STATION]), maxList=20)
|
||||
)
|
||||
|
||||
stations = check_name.get("results")
|
||||
|
||||
self.stations = {
|
||||
f"{station.get('name')}": station
|
||||
for station in stations
|
||||
if station.get("type") == "STATION"
|
||||
station.name: station
|
||||
for station in (check_name.results or [])
|
||||
if station.type == RegionalSDNameType.STATION
|
||||
and station.name is not None
|
||||
}
|
||||
|
||||
if not self.stations:
|
||||
@@ -121,7 +129,13 @@ 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]]})
|
||||
self.data.update(
|
||||
{
|
||||
"station": self.stations[user_input[CONF_STATION]].model_dump(
|
||||
mode="json", exclude_none=True
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
title = self.data[CONF_STATION]["name"]
|
||||
|
||||
@@ -151,32 +165,30 @@ 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(
|
||||
{
|
||||
"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,
|
||||
}
|
||||
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,
|
||||
)
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
except GTIUnauthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
if not errors:
|
||||
except GTIError, ClientConnectorError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
self.departure_filters = {
|
||||
str(i): departure_filter
|
||||
for i, departure_filter in enumerate(departure_list["filter"])
|
||||
str(i): f.model_dump(mode="json", exclude_none=True)
|
||||
for i, f in enumerate(departure_list.filter or [])
|
||||
}
|
||||
|
||||
if user_input is not None and not errors:
|
||||
@@ -206,8 +218,8 @@ class OptionsFlowHandler(OptionsFlow):
|
||||
vol.Optional(CONF_FILTER, default=old_filter): cv.multi_select(
|
||||
{
|
||||
key: (
|
||||
f"{departure_filter['serviceName']},"
|
||||
f" {departure_filter['label']}"
|
||||
f"{departure_filter.get('serviceName', '')},"
|
||||
f" {departure_filter.get('label', '')}"
|
||||
)
|
||||
for key, departure_filter in self.departure_filters.items()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Hub."""
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from pygti.gti import GTI, Auth
|
||||
from pygti.models import InitRequest, InitResponse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
@@ -10,7 +12,9 @@ type HVVConfigEntry = ConfigEntry[GTIHub]
|
||||
class GTIHub:
|
||||
"""GTI Hub."""
|
||||
|
||||
def __init__(self, host, username, password, session):
|
||||
def __init__(
|
||||
self, host: str, username: str, password: str, session: ClientSession
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.host = host
|
||||
self.username = username
|
||||
@@ -18,7 +22,7 @@ class GTIHub:
|
||||
|
||||
self.gti = GTI(Auth(session, self.username, self.password, self.host))
|
||||
|
||||
async def authenticate(self):
|
||||
async def authenticate(self) -> InitResponse:
|
||||
"""Test if we can authenticate with the host."""
|
||||
|
||||
return await self.gti.init()
|
||||
return await self.gti.init(InitRequest())
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pygti"],
|
||||
"requirements": ["pygti==0.9.4"]
|
||||
"requirements": ["pygti==1.1.1"]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from pygti.exceptions import InvalidAuth
|
||||
from aiohttp import ClientConnectorError, ClientSession
|
||||
from pygti.exceptions import GTIError, GTIUnauthorizedError
|
||||
from pygti.models import DLRequest, GTITime, SDName, SDNameType
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.const import ATTR_ID, CONF_OFFSET
|
||||
@@ -16,8 +17,15 @@ 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_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTURER
|
||||
from .hub import HVVConfigEntry
|
||||
from .const import (
|
||||
ATTRIBUTION,
|
||||
CONF_FILTER,
|
||||
CONF_REAL_TIME,
|
||||
CONF_STATION,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
)
|
||||
from .hub import GTIHub, HVVConfigEntry
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
|
||||
MAX_LIST = 20
|
||||
@@ -62,11 +70,17 @@ class HVVDepartureSensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_available = False
|
||||
|
||||
def __init__(self, hass, config_entry, session, hub):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HVVConfigEntry,
|
||||
session: ClientSession,
|
||||
hub: GTIHub,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.config_entry = config_entry
|
||||
self.station_name = self.config_entry.data[CONF_STATION]["name"]
|
||||
self._last_error = None
|
||||
self._last_error: type[Exception] | Exception | None = None
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
self.gti = hub.gti
|
||||
@@ -77,7 +91,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"],
|
||||
@@ -99,39 +113,46 @@ class HVVDepartureSensor(SensorEntity):
|
||||
|
||||
station = self.config_entry.data[CONF_STATION]
|
||||
|
||||
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"]})
|
||||
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),
|
||||
)
|
||||
|
||||
try:
|
||||
data = await self.gti.departureList(payload)
|
||||
except InvalidAuth as error:
|
||||
if self._last_error != InvalidAuth:
|
||||
data = await self.gti.departureList(request)
|
||||
except GTIUnauthorizedError as error:
|
||||
if self._last_error != GTIUnauthorizedError:
|
||||
_LOGGER.error("Authentication failed: %r", error)
|
||||
self._last_error = InvalidAuth
|
||||
self._last_error = GTIUnauthorizedError
|
||||
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["returnCode"] == "OK" and data.get("departures")):
|
||||
if not data.departures:
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
@@ -140,25 +161,27 @@ class HVVDepartureSensor(SensorEntity):
|
||||
|
||||
self._last_error = None
|
||||
|
||||
departure = data["departures"][0]
|
||||
line = departure["line"]
|
||||
delay = departure.get("delay", 0)
|
||||
cancelled = departure.get("cancelled", False)
|
||||
extra = departure.get("extra", False)
|
||||
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
|
||||
self._attr_available = True
|
||||
self._attr_native_value = (
|
||||
departure_time
|
||||
+ timedelta(minutes=departure["timeOffset"])
|
||||
+ timedelta(
|
||||
minutes=departure.timeOffset if departure.timeOffset is not None else 0
|
||||
)
|
||||
+ 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,
|
||||
@@ -166,21 +189,27 @@ class HVVDepartureSensor(SensorEntity):
|
||||
)
|
||||
|
||||
departures = []
|
||||
for departure in data["departures"]:
|
||||
line = departure["line"]
|
||||
delay = departure.get("delay", 0)
|
||||
cancelled = departure.get("cancelled", False)
|
||||
extra = departure.get("extra", False)
|
||||
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
|
||||
departures.append(
|
||||
{
|
||||
ATTR_DEPARTURE: departure_time
|
||||
+ timedelta(minutes=departure["timeOffset"])
|
||||
+ timedelta(
|
||||
minutes=departure.timeOffset
|
||||
if departure.timeOffset is not None
|
||||
else 0
|
||||
)
|
||||
+ 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,
|
||||
|
||||
@@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
await hass.async_add_executor_job(account.setup)
|
||||
|
||||
entry.runtime_data = account
|
||||
entry.async_on_unload(account.cancel_fetch)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ class IcloudAccount:
|
||||
self._retried_fetch = False
|
||||
self._config_entry = config_entry
|
||||
|
||||
self._unsub_fetch: CALLBACK_TYPE | None = None
|
||||
self.listeners: list[CALLBACK_TYPE] = []
|
||||
|
||||
def setup(self) -> None:
|
||||
@@ -293,9 +294,16 @@ class IcloudAccount:
|
||||
self._max_interval,
|
||||
)
|
||||
|
||||
def cancel_fetch(self) -> None:
|
||||
"""Cancel the scheduled fetch timer."""
|
||||
if self._unsub_fetch is not None:
|
||||
self._unsub_fetch()
|
||||
self._unsub_fetch = None
|
||||
|
||||
def _schedule_next_fetch(self) -> None:
|
||||
self.cancel_fetch()
|
||||
if not self._config_entry.pref_disable_polling:
|
||||
track_point_in_utc_time(
|
||||
self._unsub_fetch = track_point_in_utc_time(
|
||||
self.hass,
|
||||
self.keep_alive,
|
||||
utcnow() + timedelta(minutes=self._fetch_interval),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""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
|
||||
@@ -0,0 +1,80 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""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]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""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
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities do not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Cloud service integration, does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: >-
|
||||
Devices are reached via Imou Open Platform cloud APIs (App ID / secret). No
|
||||
supported local discovery flow today; example cues if investigated later:
|
||||
hostname `IPC-ABCD.imou.local`, MAC `aa:bb:cc:dd:ee:ff`.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: done
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user