Compare commits
318 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ff21b70f2 | |||
| d01903cd59 | |||
| 0d17f3062c | |||
| c706c83337 | |||
| 20f30f76d1 | |||
| 966b89cc14 | |||
| 707742f720 | |||
| f58e0e5234 | |||
| c3d6ad029f | |||
| 630f442042 | |||
| 62419789b9 | |||
| f2f5a55165 | |||
| c6a57bc81a | |||
| 4171f566e9 | |||
| 0ac9834d93 | |||
| d7673a08c8 | |||
| 35cb7c6147 | |||
| d098622021 | |||
| f88e757e51 | |||
| 653e6a43fa | |||
| 1462e7a181 | |||
| e34d821f7d | |||
| 02b4442a6c | |||
| 809571443c | |||
| d59398e0ea | |||
| 9c9695d0ba | |||
| 3fbdbb12e2 | |||
| a29f2907f7 | |||
| 83534f286e | |||
| 4fe93f9c64 | |||
| fd8789d599 | |||
| d0b34dfe92 | |||
| 390766ba3a | |||
| 3a46d1088b | |||
| 26d56b8218 | |||
| 6ee819cdc3 | |||
| 1cf8fe4d0b | |||
| c5f93cdd72 | |||
| 42136f1464 | |||
| 34f3452280 | |||
| ba9248cc94 | |||
| 018cd1333e | |||
| c72d723e0d | |||
| b9b36d9e12 | |||
| b6f38c3cbb | |||
| a0162d2ff0 | |||
| b6f018873b | |||
| 43e21322ea | |||
| 86ccc59a5f | |||
| 2fce2547c7 | |||
| 6b40278d08 | |||
| 05bb8b94fa | |||
| 5ac3a8cdde | |||
| 266fccf0cf | |||
| a1e6a6f9a2 | |||
| 2fe406c6ff | |||
| e1249fef8f | |||
| 6f61e97f8e | |||
| b65751e8ac | |||
| ef4bf77b24 | |||
| 977a9ecdd2 | |||
| 9e79eba970 | |||
| 40073e598c | |||
| 627d5cc110 | |||
| b1dbeca9ed | |||
| 059bc8d676 | |||
| 085f794407 | |||
| 3996db289d | |||
| 291585e48e | |||
| d9a125ce9b | |||
| 786c957909 | |||
| dd6830f1c5 | |||
| 4dbe58afc6 | |||
| 6c72d4337d | |||
| fcff5229d9 | |||
| 8edd813d4b | |||
| 509866c0eb | |||
| 9db5860d6b | |||
| 6917223cb3 | |||
| cc4637a703 | |||
| 2b0d14d71e | |||
| d0d85d8844 | |||
| eea3d9d4c4 | |||
| 48a690b267 | |||
| 07dc2346de | |||
| 711830b01f | |||
| f9fea56a8c | |||
| 8aac0c5b6e | |||
| 227c43630a | |||
| e2f3a3232e | |||
| 3173e56bf0 | |||
| e22b03f942 | |||
| 467c2fdd57 | |||
| d825b6afa8 | |||
| 69fb1e142c | |||
| 80e71660e6 | |||
| 045ba4e1dd | |||
| 983501406f | |||
| 837308ba39 | |||
| 6e53787d98 | |||
| 7dbce7863a | |||
| e1d90fd244 | |||
| bbeb2ac667 | |||
| 21260bf1ab | |||
| a0d67b80ab | |||
| a6b7641d47 | |||
| ad2db2ae88 | |||
| fc2b7902a5 | |||
| 3aa4cbeeb0 | |||
| 3c2f171158 | |||
| ffc6eeadc2 | |||
| 5cc1a0a1ef | |||
| 1cbbce5b35 | |||
| 9f5cb635f0 | |||
| 50de2c070e | |||
| a7f012350f | |||
| 04d2211d1e | |||
| 5b4c2c6017 | |||
| f36a491ebd | |||
| bde3b6e59f | |||
| 53211bc37a | |||
| 863655dce5 | |||
| eb2a3d0ffd | |||
| cbc7a5ae8e | |||
| 90d45432dc | |||
| 9e1abe6dfd | |||
| 682f0ba217 | |||
| 2b499d5aca | |||
| 11535f27da | |||
| f5fa2e244d | |||
| 1a63a38234 | |||
| ccb0b6a286 | |||
| 5e8f0d1078 | |||
| af6b0879de | |||
| 6450e2790c | |||
| 8aafebc5a6 | |||
| e9c3a65e58 | |||
| a30ab26dda | |||
| aa3ae4986b | |||
| 3235e6d458 | |||
| d8975b3d0d | |||
| 4261787be1 | |||
| 2e8097cc1f | |||
| 9430bb2e32 | |||
| 1283420fc6 | |||
| 88e85e4325 | |||
| 7cb08fdabc | |||
| 0cde867f93 | |||
| 149daf4f97 | |||
| 6ffc32159b | |||
| 1c2d1013e6 | |||
| af4eaed5ed | |||
| dba09c334a | |||
| c329bb4000 | |||
| 2e041dd45f | |||
| 836740c247 | |||
| 8ba3e6c8c1 | |||
| 8db064c929 | |||
| 8124544125 | |||
| 6291179292 | |||
| fcaa11d09a | |||
| bd985a2db2 | |||
| 89a033bc2c | |||
| 6f880ac8a9 | |||
| 7babb2423b | |||
| e812cd3c3f | |||
| 7e1874ae96 | |||
| 1f50582a16 | |||
| 53211759cb | |||
| ad99929178 | |||
| d2672050cf | |||
| 74fd636aa6 | |||
| b4f8fce912 | |||
| 78a97f99dc | |||
| 4593059db2 | |||
| 593ae9eb80 | |||
| 37b4bcaa39 | |||
| 6bda3ea3a5 | |||
| f4db5fb346 | |||
| 5d0565f007 | |||
| 083af9ccc7 | |||
| 6c87284dee | |||
| 0e0b29d16e | |||
| 8e493d84f1 | |||
| 4e2bc610e3 | |||
| 82d83feda4 | |||
| 265fe6d338 | |||
| bb8036f2c8 | |||
| 387b84ec7b | |||
| 24037fcfa3 | |||
| 994b210588 | |||
| db6f1426ec | |||
| 8ce5ba2ba4 | |||
| b176fb2113 | |||
| ada8a98f87 | |||
| 763d9879bf | |||
| 7bbd0ea472 | |||
| f04b0ee2c6 | |||
| 60f458a372 | |||
| 05eada2569 | |||
| d2abd7f6ca | |||
| 52c3e17de9 | |||
| 96c286f2e0 | |||
| 3e356de4e1 | |||
| 90a874d81b | |||
| 165024c6c9 | |||
| 66e4db3c0e | |||
| 0e6128c657 | |||
| 16febb36ba | |||
| dd7bd0c8a4 | |||
| c462a1c188 | |||
| 96c5110b7e | |||
| 64e8ed2737 | |||
| 4171d092f7 | |||
| 7af867ad4d | |||
| 907fe40304 | |||
| af08e5e7d0 | |||
| b03d87dc21 | |||
| d8a9ea1d9d | |||
| 5ff07fcc49 | |||
| 6f59bb0661 | |||
| c82d32bbae | |||
| 4fbc363965 | |||
| 8622f0f4de | |||
| b49a6b89b6 | |||
| 0bfd4c44bb | |||
| c09216650f | |||
| 6057d32636 | |||
| 51c9d0c6e5 | |||
| 323304664e | |||
| 3dda7d9848 | |||
| 5e56d74257 | |||
| e5f9c7892a | |||
| a0d713a4a7 | |||
| 84f4f876b1 | |||
| 7b06228a5a | |||
| 06b2ec22f0 | |||
| 7950998083 | |||
| 86999063d7 | |||
| 9843fdad2c | |||
| e53914a0ef | |||
| f7afe22318 | |||
| acfecd7f5c | |||
| 56057a11e6 | |||
| 0d079c57e4 | |||
| 3ad3e1fafb | |||
| 0677ed824f | |||
| 4b9945e012 | |||
| 9fa0132b1c | |||
| 10a25368a0 | |||
| fbb68c26b6 | |||
| 25875de414 | |||
| 22ace88b2c | |||
| a47105d314 | |||
| b50bfda00c | |||
| 0d37319ba9 | |||
| 24a5c75cf2 | |||
| dd43b1135d | |||
| de0a202c4e | |||
| d550d1da90 | |||
| ce8875ae8c | |||
| 3364096b2b | |||
| c2b75b9634 | |||
| ae278d3c80 | |||
| 25f9cd9ab8 | |||
| 796d82d6ed | |||
| 4b517fb164 | |||
| 2d74091a36 | |||
| 504e22ee3e | |||
| c95a39c26e | |||
| 8ec3eac705 | |||
| 589d2637c9 | |||
| 26cf728165 | |||
| b61559bdbb | |||
| 57259132d9 | |||
| 2776e966ff | |||
| 5f9872886d | |||
| f728a1bf09 | |||
| df65132268 | |||
| c13822b776 | |||
| c6d696db0c | |||
| 114c9bbafa | |||
| 323ce99fda | |||
| 7a7ef85db2 | |||
| 7ab402618d | |||
| aa87295a1e | |||
| 3bd979e976 | |||
| 9dddf76548 | |||
| 1828579f03 | |||
| 47bca8d8c2 | |||
| 6f3fb5c7bd | |||
| d9b4b5b3d0 | |||
| 342b364af6 | |||
| 951cd71741 | |||
| e86a54f81c | |||
| ba8b33e1a9 | |||
| b6c40ba3fc | |||
| f2f29c07c7 | |||
| 50a3ab115d | |||
| c204054847 | |||
| 28d6eab2dd | |||
| 6b1ee57bd5 | |||
| 7247f95b05 | |||
| cdeafdfd42 | |||
| 9d60fce72e | |||
| 2e4c6c4370 | |||
| b7e36e297b | |||
| 7e178efe63 | |||
| 38f25c4b41 | |||
| 2c2e70a11c | |||
| 190350aec3 | |||
| a87083b6c1 | |||
| d5be54fd40 | |||
| 46f2ad9eb2 | |||
| add75622d6 | |||
| 2f334d657d | |||
| fd69d384be | |||
| fce17c8e6f |
@@ -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@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@029b1de3e913e0604df1ada9da1cec03e282c4db # v0.76.0
|
||||
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.14
|
||||
rev: v0.15.15
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
Generated
+6
-2
@@ -453,6 +453,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/ecovacs/ @mib1185 @edenhaus @Augar
|
||||
/homeassistant/components/ecowitt/ @pvizeli
|
||||
/tests/components/ecowitt/ @pvizeli
|
||||
/homeassistant/components/edifier_infrared/ @abmantis
|
||||
/tests/components/edifier_infrared/ @abmantis
|
||||
/homeassistant/components/efergy/ @tkdrob
|
||||
/tests/components/efergy/ @tkdrob
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
@@ -625,8 +627,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
|
||||
/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
@@ -840,6 +842,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
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.5.6"]
|
||||
"requirements": ["actron-neo-api==0.5.12"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["airos==0.6.5"]
|
||||
"requirements": ["airos==0.6.8"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -25,6 +29,12 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
|
||||
connections={
|
||||
(
|
||||
CONNECTION_NETWORK_MAC,
|
||||
format_mac(self.coordinator.data["status"]["mac_address"]),
|
||||
)
|
||||
},
|
||||
manufacturer="AirVisual",
|
||||
model=self.coordinator.data["status"]["model"],
|
||||
name=self.coordinator.data["settings"]["node_name"],
|
||||
|
||||
@@ -59,7 +59,6 @@ ATTR_EXTERNAL_URL = "external_url"
|
||||
ATTR_INTERNAL_URL = "internal_url"
|
||||
ATTR_LOCATION_NAME = "location_name"
|
||||
ATTR_INSTALLATION_TYPE = "installation_type"
|
||||
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
|
||||
ATTR_UUID = "uuid"
|
||||
ATTR_VERSION = "version"
|
||||
|
||||
@@ -222,7 +221,7 @@ class APIStatesView(HomeAssistantView):
|
||||
states = (
|
||||
state.as_dict_json
|
||||
for state in hass.states.async_all()
|
||||
if entity_perm(state.entity_id, "read")
|
||||
if entity_perm(state.entity_id, POLICY_READ)
|
||||
)
|
||||
response = web.Response(
|
||||
body=b"".join((b"[", b",".join(states), b"]")),
|
||||
@@ -294,8 +293,10 @@ class APIEntityStateView(HomeAssistantView):
|
||||
|
||||
# Read the state back for our response
|
||||
status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
if (state := hass.states.get(entity_id)) is None:
|
||||
return self.json_message(
|
||||
"Error storing state.", HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
)
|
||||
resp = self.json(state.as_dict(), status_code)
|
||||
|
||||
resp.headers.add("Location", f"/api/states/{entity_id}")
|
||||
|
||||
@@ -5,6 +5,8 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hassil.parse_expression import parse_sentence
|
||||
from hassil.parser import ParseError
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
PUNCTUATION_END_WORD,
|
||||
@@ -164,6 +166,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
is_valid_sentence,
|
||||
),
|
||||
}
|
||||
],
|
||||
@@ -212,6 +215,17 @@ def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_sentence(value: list[str]) -> list[str]:
|
||||
"""Validate result can be parsed by hassil."""
|
||||
for sentence in value:
|
||||
try:
|
||||
parse_sentence(sentence)
|
||||
except ParseError as err:
|
||||
raise vol.Invalid(f"invalid sentence: {err}") from err
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0"]
|
||||
"requirements": ["hassil==3.6.0"]
|
||||
}
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Avosdim."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "avosdim",
|
||||
"name": "Avosdim",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
}
|
||||
@@ -21,6 +21,10 @@ BINARY_SENSOR_TYPES = (
|
||||
key="moisture",
|
||||
device_class=BinarySensorDeviceClass.MOISTURE,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="open",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import blebox_uniapi.button
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -13,6 +13,16 @@ from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BUTTON_TYPES: dict[str, ButtonEntityDescription] = {
|
||||
"up": ButtonEntityDescription(key="up", translation_key="up"),
|
||||
"down": ButtonEntityDescription(key="down", translation_key="down"),
|
||||
"fav": ButtonEntityDescription(key="fav", translation_key="fav"),
|
||||
"open": ButtonEntityDescription(key="open", translation_key="open"),
|
||||
"close": ButtonEntityDescription(key="close", translation_key="close"),
|
||||
}
|
||||
|
||||
_DEFAULT_BUTTON = ButtonEntityDescription(key="button")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -35,22 +45,16 @@ class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
"""Initialize a BleBox button feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
self._attr_icon = self.get_icon()
|
||||
|
||||
def get_icon(self) -> str | None:
|
||||
"""Return icon for endpoint."""
|
||||
if "up" in self._feature.query_string:
|
||||
return "mdi:arrow-up-circle"
|
||||
if "down" in self._feature.query_string:
|
||||
return "mdi:arrow-down-circle"
|
||||
if "fav" in self._feature.query_string:
|
||||
return "mdi:heart-circle"
|
||||
if "open" in self._feature.query_string:
|
||||
return "mdi:arrow-up-circle"
|
||||
if "close" in self._feature.query_string:
|
||||
return "mdi:arrow-down-circle"
|
||||
return None
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = self._get_description()
|
||||
|
||||
def _get_description(self) -> ButtonEntityDescription:
|
||||
"""Return the description matching this button's query string."""
|
||||
for key, description in BUTTON_TYPES.items():
|
||||
if key in self._feature.query_string:
|
||||
return description
|
||||
return _DEFAULT_BUTTON
|
||||
|
||||
@blebox_command
|
||||
async def async_press(self) -> None:
|
||||
|
||||
@@ -14,6 +14,13 @@ UNKNOWN = "unknown"
|
||||
DEFAULT_HOST = "192.168.0.2"
|
||||
DEFAULT_PORT = 80
|
||||
|
||||
OPEN_STATUS: dict[int, str] = {
|
||||
0: "open",
|
||||
1: "unclosed_or_unlocked",
|
||||
2: "ajar",
|
||||
3: "closed_but_unlocked",
|
||||
4: "closed",
|
||||
}
|
||||
|
||||
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
|
||||
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
|
||||
|
||||
@@ -43,6 +43,6 @@ class BleBoxCoordinator(DataUpdateCoordinator[None]):
|
||||
except Error as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_key="data_update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Diagnostics support for BleBox devices."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: BleBoxConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
product = entry.runtime_data.box
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"device": {
|
||||
"name": product.name,
|
||||
"type": product.type,
|
||||
"model": product.model,
|
||||
"unique_id": product.unique_id,
|
||||
"firmware_version": product.firmware_version,
|
||||
"hardware_version": product.hardware_version,
|
||||
"available_firmware_version": product.available_firmware_version,
|
||||
"api_version": product.api_version,
|
||||
"last_data": product.last_data,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"close": {
|
||||
"default": "mdi:arrow-down-circle"
|
||||
},
|
||||
"down": {
|
||||
"default": "mdi:arrow-down-circle"
|
||||
},
|
||||
"fav": {
|
||||
"default": "mdi:heart-circle"
|
||||
},
|
||||
"open": {
|
||||
"default": "mdi:arrow-up-circle"
|
||||
},
|
||||
"up": {
|
||||
"default": "mdi:arrow-up-circle"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"power_consumption": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,11 @@ from homeassistant.components.light import (
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
|
||||
from .const import DOMAIN, LIGHT_MAX_KELVINS, LIGHT_MIN_KELVINS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
@@ -215,8 +216,10 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
try:
|
||||
await self._feature.async_on(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Turning on '{self.name}' failed: Bad value {value}"
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="bad_value",
|
||||
translation_placeholders={"error": str(exc)},
|
||||
) from exc
|
||||
|
||||
if effect is not None:
|
||||
@@ -224,9 +227,10 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
effect_value = self.effect_list.index(effect)
|
||||
await self._feature.async_api_command("effect", effect_value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Turning on with effect '{self.name}' failed: {effect} not in"
|
||||
" effect list."
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="effect_not_found",
|
||||
translation_placeholders={"error": str(exc)},
|
||||
) from exc
|
||||
|
||||
@blebox_command
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
import blebox_uniapi.sensor
|
||||
@@ -26,96 +28,113 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import OPEN_STATUS
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
SENSOR_TYPES = (
|
||||
SensorEntityDescription(
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BleBoxSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a BleBox sensor entity."""
|
||||
|
||||
value_fn: Callable[[StateType], StateType] = lambda v: v
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
|
||||
BleBoxSensorEntityDescription(
|
||||
key="pm1",
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="pm2_5",
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="pm10",
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="powerConsumption",
|
||||
translation_key="power_consumption",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
icon="mdi:lightning-bolt",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="wind",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="illuminance",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="forwardActiveEnergy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="reverseActiveEnergy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="reactivePower",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="activePower",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="apparentPower",
|
||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
BleBoxSensorEntityDescription(
|
||||
key="frequency",
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
),
|
||||
BleBoxSensorEntityDescription(
|
||||
key="openStatus",
|
||||
translation_key="open_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
icon="mdi:window-open",
|
||||
options=list(OPEN_STATUS.values()),
|
||||
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -138,20 +157,22 @@ async def async_setup_entry(
|
||||
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
|
||||
"""Representation of a BleBox sensor feature."""
|
||||
|
||||
entity_description: BleBoxSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: blebox_uniapi.sensor.BaseSensor,
|
||||
description: SensorEntityDescription,
|
||||
description: BleBoxSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a BleBox sensor feature."""
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state."""
|
||||
return self._feature.native_value
|
||||
return self.entity_description.value_fn(self._feature.native_value)
|
||||
|
||||
@property
|
||||
def last_reset(self) -> datetime | None:
|
||||
|
||||
@@ -35,9 +35,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"open_status": {
|
||||
"state": {
|
||||
"ajar": "Ajar",
|
||||
"closed": "[%key:common::state::closed%]",
|
||||
"closed_but_unlocked": "Closed but unlocked",
|
||||
"open": "[%key:common::state::open%]",
|
||||
"unclosed_or_unlocked": "Unclosed or unlocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"bad_value": {
|
||||
"message": "Turning on the light failed: {error}"
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Failed to execute command on the BleBox device: {error}"
|
||||
},
|
||||
"data_update_failed": {
|
||||
"message": "An error occurred while communicating with the BleBox device: {error}"
|
||||
},
|
||||
"effect_not_found": {
|
||||
"message": "The specified light effect is not available on this device: {error}"
|
||||
},
|
||||
"install_failed": {
|
||||
"message": "Failed to install firmware update on the BleBox device: {error}"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Failed to fetch firmware update information from the BleBox device: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
@@ -86,7 +87,11 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
raise HomeAssistantError(ex) from ex
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
self._sync_sw_version()
|
||||
|
||||
@property
|
||||
@@ -121,7 +126,11 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
await self._feature.async_install()
|
||||
except Error as ex:
|
||||
self._reset_progress()
|
||||
raise HomeAssistantError(ex) from ex
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="install_failed",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
self._poll_cancel = async_call_later(
|
||||
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
|
||||
@@ -22,7 +23,11 @@ def blebox_command[_BleBoxEntityT: BleBoxEntity, **_P, _R](
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except Error as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ These APIs are the only documented way to interact with the bluetooth integratio
|
||||
import asyncio
|
||||
from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import ExitStack
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from bleak import BleakScanner
|
||||
@@ -178,15 +179,20 @@ async def async_process_advertisements(
|
||||
if not done.done() and callback(service_info):
|
||||
done.set_result(service_info)
|
||||
|
||||
unload = _get_manager(hass).async_register_callback(
|
||||
_async_discovered_device, match_dict, mode, scan_duration=timeout
|
||||
)
|
||||
manager = _get_manager(hass)
|
||||
|
||||
with ExitStack() as stack:
|
||||
unload = manager.async_register_callback(
|
||||
_async_discovered_device, match_dict, mode
|
||||
)
|
||||
stack.callback(unload)
|
||||
|
||||
if mode == BluetoothScanningMode.ACTIVE:
|
||||
task = hass.async_create_task(manager.async_request_active_scan(timeout))
|
||||
stack.callback(task.cancel)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
return await done
|
||||
finally:
|
||||
unload()
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.8.1"
|
||||
"habluetooth==6.8.3"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"""The Brands integration."""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Container, Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, override
|
||||
|
||||
from aiohttp import ClientError, hdrs, web
|
||||
from aiohttp import ClientError, web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback, valid_domain
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
|
||||
class _BrandsBaseView(HomeAssistantView):
|
||||
"""Base view for serving brand images."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the view."""
|
||||
self._hass = hass
|
||||
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
|
||||
|
||||
def _authenticate(self, request: web.Request) -> None:
|
||||
"""Authenticate the request using Bearer token or query token."""
|
||||
access_tokens: deque[str] = self._hass.data[DOMAIN]
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
raise web.HTTPForbidden
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
return self._hass.data[DOMAIN]
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
self,
|
||||
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for an integration brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for a hardware brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not CATEGORY_RE.match(category):
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
# Hardware images have dynamic names like "manufacturer_model.png"
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiostreammagic==2.13.1"],
|
||||
"requirements": ["aiostreammagic==2.13.2"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -12,16 +12,16 @@ import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final, final
|
||||
from typing import Any, Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import attr
|
||||
from propcache.api import cached_property, under_cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
class CameraView(HomeAssistantView):
|
||||
"""Base CameraView."""
|
||||
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
|
||||
def __init__(self, component: EntityComponent[Camera]) -> None:
|
||||
"""Initialize a basic camera view."""
|
||||
self.component = component
|
||||
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return camera.access_tokens
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
if (camera := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in camera.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or camera access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
if not camera.is_on:
|
||||
_LOGGER.debug("Camera is off")
|
||||
raise web.HTTPServiceUnavailable
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
|
||||
"requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from hassil.parse_expression import parse_sentence
|
||||
from hassil.parser import ParseError
|
||||
from hassil.recognize import RecognizeResult
|
||||
from hassil.util import (
|
||||
PUNCTUATION_END,
|
||||
@@ -42,6 +44,17 @@ def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
return value
|
||||
|
||||
|
||||
def is_valid_sentence(value: list[str]) -> list[str]:
|
||||
"""Validate result can be parsed by hassil."""
|
||||
for sentence in value:
|
||||
try:
|
||||
parse_sentence(sentence)
|
||||
except ParseError as err:
|
||||
raise vol.Invalid(f"invalid sentence: {err}") from err
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
"""Validate result has at least one item."""
|
||||
if len(value) < 1:
|
||||
@@ -58,7 +71,11 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): DOMAIN,
|
||||
vol.Required(CONF_COMMAND): vol.All(
|
||||
cv.ensure_list, [cv.string], has_one_non_empty_item, has_no_punctuation
|
||||
cv.ensure_list,
|
||||
[cv.string],
|
||||
has_one_non_empty_item,
|
||||
has_no_punctuation,
|
||||
is_valid_sentence,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.17.2"],
|
||||
"requirements": ["pydaikin==2.18.1"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["data-grand-lyon-ha==0.7.0"]
|
||||
"requirements": ["data-grand-lyon-ha==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -27,7 +28,19 @@ async def async_setup_entry(
|
||||
BinarySensorDeviceClass.MOISTURE,
|
||||
),
|
||||
DemoBinarySensor(
|
||||
"binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION
|
||||
"binary_2",
|
||||
"Movement Backyard",
|
||||
True,
|
||||
BinarySensorDeviceClass.MOTION,
|
||||
),
|
||||
DemoBinarySensor(
|
||||
"binary_3",
|
||||
"Outside Temperature",
|
||||
False,
|
||||
BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
device_id="sensor_1",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_name="Battery Charging",
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -46,6 +59,9 @@ class DemoBinarySensor(BinarySensorEntity):
|
||||
device_name: str,
|
||||
state: bool,
|
||||
device_class: BinarySensorDeviceClass,
|
||||
device_id: str | None = None,
|
||||
entity_category: EntityCategory | None = None,
|
||||
entity_name: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the demo sensor."""
|
||||
self._unique_id = unique_id
|
||||
@@ -54,10 +70,12 @@ class DemoBinarySensor(BinarySensorEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
# Serial numbers are unique identifiers within a specific domain
|
||||
(DOMAIN, self.unique_id)
|
||||
(DOMAIN, device_id or unique_id)
|
||||
},
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_entity_category = entity_category
|
||||
self._attr_name = entity_name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
|
||||
ATTR_LOCATION_NAME,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
ATTR_TRACKING_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_NEW_DEVICE_DEFAULTS,
|
||||
@@ -36,6 +37,7 @@ from .const import ( # noqa: F401
|
||||
PLATFORM_TYPE_LEGACY,
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
TrackingType,
|
||||
)
|
||||
from .entity import ( # noqa: F401
|
||||
BaseScannerEntity,
|
||||
|
||||
@@ -25,6 +25,18 @@ class SourceType(StrEnum):
|
||||
BLUETOOTH_LE = "bluetooth_le"
|
||||
|
||||
|
||||
class TrackingType(StrEnum):
|
||||
"""Tracking type for device trackers.
|
||||
|
||||
Describes how the tracker determines presence: by the device's geographic
|
||||
position (e.g. GPS) or by its connection to a known endpoint (e.g. a router
|
||||
or beacon associated with a zone).
|
||||
"""
|
||||
|
||||
CONNECTION = "connection"
|
||||
POSITION = "position"
|
||||
|
||||
|
||||
CONF_SCAN_INTERVAL: Final = "interval_seconds"
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=12)
|
||||
|
||||
@@ -47,6 +59,7 @@ ATTR_IN_ZONES: Final = "in_zones"
|
||||
ATTR_LOCATION_NAME: Final = "location_name"
|
||||
ATTR_MAC: Final = "mac"
|
||||
ATTR_SOURCE_TYPE: Final = "source_type"
|
||||
ATTR_TRACKING_TYPE: Final = "tracking_type"
|
||||
ATTR_CONSIDER_HOME: Final = "consider_home"
|
||||
ATTR_IP: Final = "ip"
|
||||
|
||||
|
||||
@@ -48,11 +48,13 @@ from .const import (
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
ATTR_TRACKING_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
SourceType,
|
||||
TrackingType,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -238,6 +240,9 @@ class TrackerEntity(
|
||||
"""Base class for a tracked device."""
|
||||
|
||||
entity_description: TrackerEntityDescription
|
||||
_attr_capability_attributes: dict[str, Any] = {
|
||||
ATTR_TRACKING_TYPE: TrackingType.POSITION
|
||||
}
|
||||
_attr_in_zones: list[str] | None = None
|
||||
_attr_latitude: float | None = None
|
||||
_attr_location_accuracy: float = 0
|
||||
@@ -411,6 +416,9 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
addresses being used to identify the device.
|
||||
"""
|
||||
|
||||
_attr_capability_attributes: dict[str, Any] = {
|
||||
ATTR_TRACKING_TYPE: TrackingType.CONNECTION
|
||||
}
|
||||
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
|
||||
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
"gps": "GPS",
|
||||
"router": "Router"
|
||||
}
|
||||
},
|
||||
"tracking_type": {
|
||||
"name": "Tracking type",
|
||||
"state": {
|
||||
"connection": "Connection",
|
||||
"position": "Position"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.2.4",
|
||||
"cached-ipaddress==1.1.1"
|
||||
"aiodiscover==3.3.1",
|
||||
"cached-ipaddress==1.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ from dsmr_parser.clients.rfxtrx_protocol import (
|
||||
from dsmr_parser.objects import DSMRObject
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
@@ -23,6 +22,7 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SerialPortSelector
|
||||
|
||||
from .const import (
|
||||
CONF_DSMR_VERSION,
|
||||
@@ -37,8 +37,6 @@ from .const import (
|
||||
RFXTRX_DSMR_PROTOCOL,
|
||||
)
|
||||
|
||||
CONF_MANUAL_PATH = "Enter Manually"
|
||||
|
||||
|
||||
class DSMRConnection:
|
||||
"""Test the connection to DSMR and receive telegram to read serial ids."""
|
||||
@@ -165,8 +163,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_dsmr_version: str | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@@ -222,34 +218,13 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Step when setting up serial configuration."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
user_selection = user_input[CONF_PORT]
|
||||
if user_selection == CONF_MANUAL_PATH:
|
||||
self._dsmr_version = user_input[CONF_DSMR_VERSION]
|
||||
return await self.async_step_setup_serial_manual_path()
|
||||
|
||||
dev_path = user_selection
|
||||
|
||||
validate_data = {
|
||||
CONF_PORT: dev_path,
|
||||
CONF_DSMR_VERSION: user_input[CONF_DSMR_VERSION],
|
||||
}
|
||||
|
||||
data = await self.async_validate_dsmr(validate_data, errors)
|
||||
data = await self.async_validate_dsmr(user_input, errors)
|
||||
if not errors:
|
||||
return self.async_create_entry(title=data[CONF_PORT], data=data)
|
||||
|
||||
ports = await usb.async_scan_serial_ports(self.hass)
|
||||
list_of_ports = {
|
||||
port.device: f"{port.device} - {port.description or 'n/a'}"
|
||||
f", s/n: {port.serial_number or 'n/a'}"
|
||||
+ (f" - {port.manufacturer}" if port.manufacturer else "")
|
||||
for port in ports
|
||||
}
|
||||
list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PORT): vol.In(list_of_ports),
|
||||
vol.Required(CONF_PORT): SerialPortSelector(),
|
||||
vol.Required(CONF_DSMR_VERSION): vol.In(DSMR_VERSIONS),
|
||||
}
|
||||
)
|
||||
@@ -259,27 +234,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_setup_serial_manual_path(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select path manually."""
|
||||
if user_input is not None:
|
||||
validate_data = {
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_DSMR_VERSION: self._dsmr_version,
|
||||
}
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
data = await self.async_validate_dsmr(validate_data, errors)
|
||||
if not errors:
|
||||
return self.async_create_entry(title=data[CONF_PORT], data=data)
|
||||
|
||||
schema = vol.Schema({vol.Required(CONF_PORT): str})
|
||||
return self.async_show_form(
|
||||
step_id="setup_serial_manual_path",
|
||||
data_schema=schema,
|
||||
)
|
||||
|
||||
async def async_validate_dsmr(
|
||||
self, input_data: dict[str, Any], errors: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -26,12 +26,6 @@
|
||||
},
|
||||
"title": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"setup_serial_manual_path": {
|
||||
"data": {
|
||||
"port": "[%key:common::config_flow::data::usb_path%]"
|
||||
},
|
||||
"title": "[%key:common::config_flow::data::path%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"type": "Connection type"
|
||||
|
||||
@@ -7,3 +7,4 @@ from homeassistant.const import Platform
|
||||
DOMAIN = "duco"
|
||||
PLATFORMS = [Platform.FAN, Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
BOX_NODE_ID = 1
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
@@ -52,6 +52,12 @@ async def async_get_config_entry_diagnostics(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
|
||||
if api_info_obj.reported_api_version is not None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Base entity for the Duco integration."""
|
||||
|
||||
from duco_connectivity.models import Node
|
||||
from duco_connectivity.models import Node, NodeType
|
||||
|
||||
from homeassistant.const import ATTR_VIA_DEVICE
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
@@ -25,7 +25,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
|
||||
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
|
||||
manufacturer="Duco",
|
||||
model=coordinator.board_info.box_name
|
||||
if node.general.node_type == "BOX"
|
||||
if node.general.node_type == NodeType.BOX
|
||||
else node.general.node_type,
|
||||
name=node.general.name or f"Node {node.node_id}",
|
||||
)
|
||||
@@ -34,7 +34,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
|
||||
"connections": {(CONNECTION_NETWORK_MAC, mac)},
|
||||
"serial_number": coordinator.board_info.serial_board_box,
|
||||
}
|
||||
if node.general.node_type == "BOX"
|
||||
if node.general.node_type == NodeType.BOX
|
||||
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
|
||||
)
|
||||
self._attr_device_info = device_info
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco_connectivity"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-connectivity==0.5.0"],
|
||||
"requirements": ["python-duco-connectivity==0.6.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import BOX_NODE_ID, DOMAIN
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
from .entity import DucoEntity
|
||||
|
||||
@@ -158,7 +158,13 @@ async def async_setup_entry(
|
||||
# The firmware removes deregistered RF/wired nodes automatically.
|
||||
# BSRH box sensors that are physically unplugged from the PCB are
|
||||
# not deregistered by the firmware and will never appear here as stale.
|
||||
stale_node_ids = known_nodes - coordinator.data.nodes.keys()
|
||||
# The BOX node can transiently disappear from the API response, so keep
|
||||
# node 1 to avoid removing the main controller device.
|
||||
stale_node_ids = {
|
||||
node_id
|
||||
for node_id in known_nodes - coordinator.data.nodes.keys()
|
||||
if node_id != BOX_NODE_ID
|
||||
}
|
||||
if stale_node_ids:
|
||||
device_reg = dr.async_get(hass)
|
||||
mac = entry.unique_id
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"name": "Target flow level"
|
||||
},
|
||||
"time_state_end": {
|
||||
"name": "Mode end time"
|
||||
"name": "State end time"
|
||||
},
|
||||
"ventilation_state": {
|
||||
"name": "Ventilation state",
|
||||
|
||||
@@ -9,7 +9,6 @@ Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
|
||||
Wetterwarnungen (Stufe 1)
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
@@ -17,6 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ADVANCE_WARNING_SENSOR,
|
||||
@@ -100,7 +100,7 @@ class DwdWeatherWarningsSensor(
|
||||
if warnings is None:
|
||||
return []
|
||||
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
now = dt_util.utcnow()
|
||||
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
|
||||
|
||||
@property
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Edifier infrared integration for Home Assistant."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Edifier IR from a config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload an Edifier IR config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Config flow for Edifier infrared integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from infrared_protocols.codes.edifier.models import MODEL_TO_COMMAND_SET, EdifierModel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
DOMAIN as INFRARED_DOMAIN,
|
||||
async_get_emitters,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.helpers.selector import (
|
||||
EntitySelector,
|
||||
EntitySelectorConfig,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, DOMAIN
|
||||
|
||||
|
||||
class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow for Edifier IR."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step - select IR entity and speaker model."""
|
||||
emitter_entity_ids = async_get_emitters(self.hass)
|
||||
if not emitter_entity_ids:
|
||||
return self.async_abort(reason="no_emitters")
|
||||
|
||||
if user_input is not None:
|
||||
infrared_entity_id = user_input[CONF_INFRARED_ENTITY_ID]
|
||||
model = EdifierModel(user_input[CONF_MODEL])
|
||||
command_set = MODEL_TO_COMMAND_SET[model]
|
||||
|
||||
await self.async_set_unique_id(f"{command_set}_{infrared_entity_id}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
entity_name = infrared_entity_id
|
||||
if state := self.hass.states.get(infrared_entity_id):
|
||||
entity_name = state.name or infrared_entity_id
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Edifier {model.value} via {entity_name}",
|
||||
data={
|
||||
CONF_INFRARED_ENTITY_ID: infrared_entity_id,
|
||||
CONF_MODEL: model.value,
|
||||
CONF_COMMAND_SET: command_set.value,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
|
||||
EntitySelectorConfig(
|
||||
domain=INFRARED_DOMAIN, include_entities=emitter_entity_ids
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_MODEL): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[model.value for model in EdifierModel],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Constants for the Edifier infrared integration."""
|
||||
|
||||
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
|
||||
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
|
||||
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
|
||||
|
||||
DOMAIN = "edifier_infrared"
|
||||
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
|
||||
CONF_COMMAND_SET = "command_set"
|
||||
|
||||
type EdifierCode = (
|
||||
EdifierR1700BTCode
|
||||
| EdifierR1280DBCode
|
||||
| EdifierR1280TCode
|
||||
| EdifierS360DBCode
|
||||
| EdifierRC20GCode
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Common entity for Edifier infrared integration."""
|
||||
|
||||
from infrared_protocols.codes.edifier.models import EdifierModel
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class EdifierIrEntity(Entity):
|
||||
"""Edifier IR base entity providing common device info."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, entry: ConfigEntry, model: EdifierModel, unique_id_suffix: str
|
||||
) -> None:
|
||||
"""Initialize Edifier IR entity."""
|
||||
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=f"Edifier {model.value}",
|
||||
manufacturer="Edifier",
|
||||
model=model.value,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "edifier_infrared",
|
||||
"name": "Edifier Infrared",
|
||||
"codeowners": ["@abmantis"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["infrared"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/edifier_infrared",
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze"
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Media player platform for Edifier infrared integration."""
|
||||
|
||||
from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel
|
||||
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
|
||||
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
|
||||
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
|
||||
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
|
||||
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
|
||||
|
||||
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode
|
||||
from .entity import EdifierIrEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
COMMAND_SET_COMMANDS: dict[
|
||||
EdifierCommandSet,
|
||||
dict[
|
||||
MediaPlayerEntityFeature,
|
||||
tuple[EdifierCode | tuple[EdifierCode, ...], ...],
|
||||
],
|
||||
] = {
|
||||
EdifierCommandSet.R1700BT: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierR1700BTCode.VOLUME_UP,),
|
||||
(EdifierR1700BTCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,),
|
||||
},
|
||||
EdifierCommandSet.R1280DB: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1280DBCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierR1280DBCode.VOLUME_UP,),
|
||||
(EdifierR1280DBCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280DBCode.MUTE,),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierR1280DBCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierR1280DBCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1280DBCode.FORWARD,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1280DBCode.BACK,),
|
||||
},
|
||||
EdifierCommandSet.R1280T: {
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierR1280TCode.VOLUME_UP,),
|
||||
(EdifierR1280TCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,),
|
||||
},
|
||||
EdifierCommandSet.S360DB: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierS360DBCode.VOLUME_UP,),
|
||||
(EdifierS360DBCode.VOLUME_DOWN,),
|
||||
),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierS360DBCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierS360DBCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS360DBCode.NEXT,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS360DBCode.PREVIOUS,),
|
||||
},
|
||||
EdifierCommandSet.RC20G: {
|
||||
MediaPlayerEntityFeature.TURN_ON: (EdifierRC20GCode.POWER,),
|
||||
MediaPlayerEntityFeature.TURN_OFF: (EdifierRC20GCode.POWER,),
|
||||
MediaPlayerEntityFeature.VOLUME_STEP: (
|
||||
(EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT),
|
||||
(EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT),
|
||||
),
|
||||
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierRC20GCode.MUTE,),
|
||||
MediaPlayerEntityFeature.PLAY: (EdifierRC20GCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.PAUSE: (EdifierRC20GCode.PLAY_PAUSE,),
|
||||
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,),
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Edifier IR media player."""
|
||||
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
|
||||
command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET])
|
||||
model = EdifierModel(entry.data[CONF_MODEL])
|
||||
async_add_entities(
|
||||
[EdifierIrMediaPlayer(entry, model, infrared_entity_id, command_set)]
|
||||
)
|
||||
|
||||
|
||||
class EdifierIrMediaPlayer(
|
||||
EdifierIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity
|
||||
):
|
||||
"""Edifier IR media player entity."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_assumed_state = True
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
model: EdifierModel,
|
||||
infrared_entity_id: str,
|
||||
command_set: EdifierCommandSet,
|
||||
) -> None:
|
||||
"""Initialize Edifier IR media player."""
|
||||
super().__init__(entry, model, unique_id_suffix="media_player")
|
||||
self._infrared_emitter_entity_id = infrared_entity_id
|
||||
self._commands = COMMAND_SET_COMMANDS[command_set]
|
||||
self._attr_state = MediaPlayerState.ON
|
||||
self._attr_supported_features = MediaPlayerEntityFeature(0)
|
||||
for feature in self._commands:
|
||||
self._attr_supported_features |= feature
|
||||
|
||||
async def _send_codes(self, *codes: EdifierCode) -> None:
|
||||
"""Send one or more IR commands."""
|
||||
for code in codes:
|
||||
await self._send_command(code.to_command())
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the speaker."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_ON])
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the speaker."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_OFF])
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Send volume up command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][0])
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Send volume down command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][1])
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_MUTE])
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PLAY])
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PAUSE])
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.NEXT_TRACK])
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PREVIOUS_TRACK])
|
||||
@@ -0,0 +1,114 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not store runtime data.
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration only proxies commands through an existing infrared
|
||||
entity, so there is no separate connection to validate during setup.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
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: |
|
||||
This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
Discovery is not supported for infrared integrations.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not fetch data from devices.
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry creates a single device.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
The media player entity is the primary entity and does not need a category.
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
The media player entity is the primary entity and should be enabled by default.
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not raise exceptions.
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have repairable issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry manages exactly one device.
|
||||
|
||||
# Platinum
|
||||
async-dependency:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration depends on infrared_protocols which provides only code
|
||||
definitions with no I/O, so async dependency does not apply.
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not make HTTP requests.
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This Edifier device has already been configured with this transmitter.",
|
||||
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"infrared_entity_id": "IR transmitter",
|
||||
"model": "Speaker model"
|
||||
},
|
||||
"data_description": {
|
||||
"infrared_entity_id": "Select the infrared transmitter entity to use.",
|
||||
"model": "Choose your Edifier speaker model from the list."
|
||||
},
|
||||
"description": "Configure your Edifier speaker for IR control.",
|
||||
"title": "Set up Edifier IR speaker"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ from homeassistant.helpers.event import (
|
||||
async_track_state_change_event,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_NAME,
|
||||
@@ -221,8 +222,7 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
|
||||
):
|
||||
try:
|
||||
value = float(current_state.state)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
|
||||
timestamp = current_state.last_updated or dt_util.utcnow()
|
||||
client.get_or_create_sensor(energyid_key).update(value, timestamp)
|
||||
except ValueError, TypeError:
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -166,6 +166,8 @@ class RuntimeEntryData:
|
||||
)
|
||||
loaded_platforms: set[Platform] = field(default_factory=set)
|
||||
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
# Set once the first connection has finished scanner setup or teardown.
|
||||
first_connect_done: asyncio.Event = field(default_factory=asyncio.Event)
|
||||
_storage_contents: StoreData | None = None
|
||||
_pending_storage: Callable[[], StoreData] | None = None
|
||||
assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Manager for esphome devices."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from functools import partial
|
||||
import logging
|
||||
import secrets
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
@@ -106,6 +107,9 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Max time to wait at startup for a BLE proxy to register its scanner.
|
||||
STARTUP_SCANNER_WAIT: Final = 3.0
|
||||
|
||||
LOG_LEVEL_TO_LOGGER = {
|
||||
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
|
||||
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
|
||||
@@ -677,6 +681,8 @@ class ESPHomeManager:
|
||||
hass, device_info.bluetooth_mac_address or device_info.mac_address
|
||||
)
|
||||
|
||||
entry_data.first_connect_done.set()
|
||||
|
||||
if device_info.voice_assistant_feature_flags_compat(api_version) and (
|
||||
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
|
||||
):
|
||||
@@ -988,6 +994,21 @@ class ESPHomeManager:
|
||||
|
||||
await reconnect_logic.start()
|
||||
|
||||
# Wait for a cached BLE proxy to register its scanner before finishing setup.
|
||||
if (
|
||||
device_info := entry_data.device_info
|
||||
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
|
||||
entry_data.api_version
|
||||
):
|
||||
try:
|
||||
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
|
||||
await entry_data.first_connect_done.wait()
|
||||
except TimeoutError:
|
||||
_LOGGER.debug(
|
||||
"%s: Timed out waiting for Bluetooth scanner to register",
|
||||
self.host,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_setup_device_registry(
|
||||
|
||||
@@ -24,6 +24,8 @@ ATTR_DURATION: Final = "duration" # number of minutes, <24h
|
||||
ATTR_PERIOD: Final = "period" # number of days
|
||||
ATTR_SETPOINT: Final = "setpoint"
|
||||
|
||||
# Support for the refresh_system service is being deprecated
|
||||
REFRESH_BREAKS_IN_HA_VERSION: Final = "2027.1.0"
|
||||
# Support for the reset service calls/presets is being deprecated
|
||||
RESET_BREAKS_IN_HA_VERSION: Final = "2026.11.0"
|
||||
# Support for untargeted service calls to controllers is being deprecated
|
||||
|
||||
@@ -29,6 +29,7 @@ from .const import (
|
||||
ATTR_PERIOD,
|
||||
ATTR_SETPOINT,
|
||||
DOMAIN,
|
||||
REFRESH_BREAKS_IN_HA_VERSION,
|
||||
RESET_BREAKS_IN_HA_VERSION,
|
||||
SERVICE_BREAKS_IN_HA_VERSION,
|
||||
EvoService,
|
||||
@@ -204,6 +205,11 @@ def setup_service_functions(
|
||||
@verify_domain_control(DOMAIN)
|
||||
async def force_refresh(call: ServiceCall) -> None:
|
||||
"""Obtain the latest state data via the vendor's RESTful API."""
|
||||
async_create_deprecation_issue_once(
|
||||
hass,
|
||||
"deprecated_refresh_system_service",
|
||||
REFRESH_BREAKS_IN_HA_VERSION,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
|
||||
@@ -31,13 +31,17 @@
|
||||
"title": "Evohome 'Clear zone override' action is deprecated"
|
||||
},
|
||||
"deprecated_controller_service": {
|
||||
"description": "The `{service}` action without `entity_id` is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Update any automation or script to include the Evohome controller climate entity `entity_id`.",
|
||||
"description": "The `{service}` action without `entity_id` is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Update any automation or script to include `entity_id`, targeting the controller's climate entity.",
|
||||
"title": "Untargeted Evohome controller action is deprecated"
|
||||
},
|
||||
"deprecated_preset_reset": {
|
||||
"description": "Using the `Reset` preset on an Evohome controller is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Use the system's Reset button instead.",
|
||||
"title": "Evohome Reset preset is deprecated"
|
||||
},
|
||||
"deprecated_refresh_system_service": {
|
||||
"description": "The `refresh_system` action is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Instead, use the `homeassistant.update_entity` action, targeting the controller's climate entity.",
|
||||
"title": "Evohome 'Refresh system' action is deprecated"
|
||||
},
|
||||
"deprecated_reset_system_service": {
|
||||
"description": "The `reset_system` action is deprecated and will stop working in Home Assistant {breaks_in_ha_version}. Use the system's Reset button instead.",
|
||||
"title": "Evohome 'Reset system' action is deprecated"
|
||||
@@ -49,7 +53,7 @@
|
||||
"name": "Clear zone override"
|
||||
},
|
||||
"refresh_system": {
|
||||
"description": "Pulls the latest data from the vendor's servers now, rather than waiting for the next scheduled update.",
|
||||
"description": "Pulls the latest data from the vendor's servers now, rather than waiting for the next scheduled update (deprecated).",
|
||||
"name": "Refresh system"
|
||||
},
|
||||
"reset_system": {
|
||||
|
||||
@@ -7,6 +7,7 @@ from fluss_api import (
|
||||
FlussApiClient,
|
||||
FlussApiClientAuthenticationError,
|
||||
FlussApiClientError,
|
||||
FlussDeviceOfflineError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -45,11 +46,13 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
|
||||
)
|
||||
|
||||
async def _async_get_status(self, device_id: str) -> dict[str, Any]:
|
||||
"""Return per-device status."""
|
||||
"""Return per-device status, treating an offline device as disconnected."""
|
||||
try:
|
||||
response = await self.api.async_get_device_status(device_id)
|
||||
except FlussDeviceOfflineError:
|
||||
return {"internetConnected": False}
|
||||
except FlussApiClientError as err:
|
||||
raise UpdateFailed(f"Error fetching status for {device_id}: {err}") from err
|
||||
raise UpdateFailed(f"Error fetching Fluss device status: {err}") from err
|
||||
return response["status"]
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["fluss-api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fluss-api==0.2.4"]
|
||||
"requirements": ["fluss-api==0.2.5"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["forecast-solar==5.0.0"]
|
||||
"requirements": ["forecast-solar==5.0.1"]
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.3"]
|
||||
"requirements": ["home-assistant-frontend==20260527.5"]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""The Gardena Bluetooth integration."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
from gardena_bluetooth.const import ScanService
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
from habluetooth import BluetoothServiceInfoBleak
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_PRODUCT_TYPE
|
||||
from .coordinator import (
|
||||
DeviceUnavailable,
|
||||
GardenaBluetoothConfigEntry,
|
||||
@@ -30,6 +33,79 @@ PLATFORMS: list[Platform] = [
|
||||
]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
DISCONNECT_DELAY = 5
|
||||
PRODUCTS_SCAN_TIMEOUT = 10
|
||||
PRODUCT_TYPE_TIMEOUT = 30
|
||||
|
||||
|
||||
async def async_get_product(hass: HomeAssistant, address: str) -> ManufacturerData:
|
||||
"""Get manufacturer data for the given address via active scan."""
|
||||
|
||||
data = ManufacturerData()
|
||||
|
||||
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
|
||||
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
|
||||
if info.device.address != address:
|
||||
return False
|
||||
|
||||
data.update(info.manufacturer_data.get(ManufacturerData.company, b""))
|
||||
return data.product_type is not ProductType.UNKNOWN
|
||||
|
||||
with suppress(TimeoutError):
|
||||
await bluetooth.async_process_advertisements(
|
||||
hass,
|
||||
_data_callback,
|
||||
bluetooth.BluetoothCallbackMatcher(
|
||||
address=address, manufacturer_id=ManufacturerData.company
|
||||
),
|
||||
mode=bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
timeout=PRODUCT_TYPE_TIMEOUT,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
|
||||
"""Get all products that are currently advertising."""
|
||||
products: dict[str, ManufacturerData] = {}
|
||||
|
||||
def _data_callback(info: BluetoothServiceInfoBleak) -> bool:
|
||||
LOGGER.debug("Processing advertisement from %s: %s", info.address, info)
|
||||
if ScanService not in info.service_uuids:
|
||||
return False
|
||||
|
||||
raw = info.manufacturer_data.get(ManufacturerData.company, b"")
|
||||
if (data := products.get(info.device.address)) is None:
|
||||
data = ManufacturerData()
|
||||
products[info.device.address] = data
|
||||
|
||||
data.update(raw)
|
||||
return False
|
||||
|
||||
with suppress(TimeoutError):
|
||||
await bluetooth.async_process_advertisements(
|
||||
hass,
|
||||
_data_callback,
|
||||
bluetooth.BluetoothCallbackMatcher(
|
||||
manufacturer_id=ManufacturerData.company
|
||||
),
|
||||
mode=bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
timeout=PRODUCTS_SCAN_TIMEOUT,
|
||||
)
|
||||
return products
|
||||
|
||||
|
||||
async def async_migrate_product_type(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> GardenaBluetoothConfigEntry:
|
||||
"""Discover product type for old entries and upgrade them to minor version 2."""
|
||||
mfg = await async_get_product(hass, entry.data[CONF_ADDRESS])
|
||||
if mfg.product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, CONF_PRODUCT_TYPE: mfg.product_type.name},
|
||||
minor_version=2,
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
@@ -51,16 +127,11 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Set up Gardena Bluetooth from a config entry."""
|
||||
|
||||
if entry.minor_version < 2:
|
||||
entry = await async_migrate_product_type(hass, entry)
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
|
||||
try:
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
except TimeoutError as exc:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exc
|
||||
|
||||
product_type = mfg_data[address].product_type
|
||||
if product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
product_type = ProductType[entry.data[CONF_PRODUCT_TYPE]]
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
|
||||
|
||||
@@ -4,22 +4,18 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService
|
||||
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
|
||||
from gardena_bluetooth.parse import ManufacturerData, ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfo,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.components.bluetooth import BluetoothServiceInfo
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
|
||||
from . import get_connection
|
||||
from .const import DOMAIN
|
||||
from . import async_get_product, async_get_products, get_connection
|
||||
from .const import CONF_PRODUCT_TYPE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,26 +29,16 @@ _SUPPORTED_PRODUCT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
"""Check if device is supported."""
|
||||
if ScanService not in discovery_info.service_uuids:
|
||||
return False
|
||||
|
||||
if discovery_info.manufacturer_data.get(ManufacturerData.company) is None:
|
||||
_LOGGER.debug("Missing manufacturer data: %s", discovery_info)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Gardena Bluetooth."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.devices: dict[str, str] = {}
|
||||
self.address: str | None
|
||||
self.devices: dict[str, ManufacturerData] = {}
|
||||
|
||||
async def async_read_data(self):
|
||||
"""Try to connect to device and extract information."""
|
||||
@@ -68,20 +54,23 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
return {CONF_ADDRESS: self.address}
|
||||
assert self.address in self.devices
|
||||
return {
|
||||
CONF_ADDRESS: self.address,
|
||||
CONF_PRODUCT_TYPE: self.devices[self.address].product_type.name,
|
||||
}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
_LOGGER.debug("Discovered device: %s", discovery_info)
|
||||
data = await async_get_manufacturer_data({discovery_info.address})
|
||||
product_type = data[discovery_info.address].product_type
|
||||
if product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
mfg = await async_get_product(self.hass, discovery_info.address)
|
||||
self.devices[discovery_info.address] = mfg
|
||||
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
self.address = discovery_info.address
|
||||
self.devices = {discovery_info.address: PRODUCT_NAMES[product_type]}
|
||||
await self.async_set_unique_id(self.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
@@ -91,7 +80,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
assert self.address
|
||||
title = self.devices[self.address]
|
||||
title = PRODUCT_NAMES[self.devices[self.address].product_type]
|
||||
|
||||
if user_input is not None:
|
||||
data = await self.async_read_data()
|
||||
@@ -117,31 +106,25 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
candidates = set()
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or not _is_supported(discovery_info):
|
||||
continue
|
||||
candidates.add(address)
|
||||
|
||||
data = await async_get_manufacturer_data(candidates)
|
||||
for address, mfg_data in data.items():
|
||||
if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES:
|
||||
continue
|
||||
self.devices[address] = PRODUCT_NAMES[mfg_data.product_type]
|
||||
current = self._async_current_ids(include_ignore=False)
|
||||
self.devices = await async_get_products(self.hass)
|
||||
|
||||
# Keep selection sorted by address to ensure stable tests
|
||||
self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0]))
|
||||
devices = {
|
||||
address: PRODUCT_NAMES[data.product_type]
|
||||
for address in sorted(self.devices)
|
||||
if address not in current
|
||||
and (data := self.devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
|
||||
}
|
||||
|
||||
if not self.devices:
|
||||
if not devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(self.devices),
|
||||
vol.Required(CONF_ADDRESS): vol.In(devices),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"""Constants for the Gardena Bluetooth integration."""
|
||||
|
||||
DOMAIN = "gardena_bluetooth"
|
||||
CONF_PRODUCT_TYPE = "product_type"
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/generic",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["av==16.0.1", "Pillow==12.2.0"]
|
||||
"requirements": ["av==17.0.1", "Pillow==12.2.0"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"domain": "gentex_homelink",
|
||||
"name": "HomeLink",
|
||||
"codeowners": ["@niaexa", "@ryanjones-gentex"],
|
||||
"codeowners": ["@Gentex-Corporation/Homelink", "@rjones-gentex"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["homelink-integration-api==0.0.1"]
|
||||
"requirements": ["homelink-integration-api==0.0.5"]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Goodwe config flow."""
|
||||
|
||||
MINOR_VERSION = 2
|
||||
VERSION = 2
|
||||
|
||||
async def async_handle_successful_connection(
|
||||
self,
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.4.3"],
|
||||
"requirements": ["aiohasupervisor==0.5.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.FAN, Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.FAN, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HeltyConfigEntry) -> bool:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Button platform for the Helty Flow integration."""
|
||||
|
||||
from pyhelty import HeltyError
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
from .entity import HeltyEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HeltyConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Helty buttons."""
|
||||
async_add_entities([HeltyResetFilterButton(entry.runtime_data)])
|
||||
|
||||
|
||||
class HeltyResetFilterButton(HeltyEntity, ButtonEntity):
|
||||
"""Resets the filter-life counter after the filter has been replaced."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "reset_filter"
|
||||
|
||||
def __init__(self, coordinator: HeltyDataUpdateCoordinator) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{self._device_id}_reset_filter"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Reset the filter-life counter."""
|
||||
try:
|
||||
await self.coordinator.client.async_reset_filter()
|
||||
except HeltyError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="reset_filter_failed",
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyhelty import FanMode
|
||||
from pyhelty import FanMode, HeltyError
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
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 .const import DOMAIN, PRESET_BOOST, PRESET_FREE_COOLING, PRESET_NIGHT
|
||||
from .coordinator import HeltyConfigEntry, HeltyDataUpdateCoordinator
|
||||
from .entity import HeltyEntity
|
||||
|
||||
@@ -116,5 +117,11 @@ class HeltyFan(HeltyEntity, FanEntity):
|
||||
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)
|
||||
try:
|
||||
await self.coordinator.client.async_set_fan_mode(mode)
|
||||
except HeltyError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_fan_mode_failed",
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -26,9 +26,7 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide additional actions.
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"reset_filter": {
|
||||
"name": "Reset filter"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"indoor_humidity": {
|
||||
"name": "Indoor humidity"
|
||||
@@ -31,5 +36,13 @@
|
||||
"name": "Outdoor temperature"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"reset_filter_failed": {
|
||||
"message": "Failed to reset the filter."
|
||||
},
|
||||
"set_fan_mode_failed": {
|
||||
"message": "Failed to set the ventilation mode."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.97", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.98", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -108,27 +108,32 @@ def _handle_paired_or_connected_appliance(
|
||||
)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
)
|
||||
for event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
if not (
|
||||
callbacks_for_appliance := changed_options_listener_remove_callbacks[
|
||||
appliance_ha_id
|
||||
]
|
||||
):
|
||||
changed_options_listener_remove_callback = (
|
||||
appliance_coordinator.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entity_registry,
|
||||
appliance_coordinator,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
event_key,
|
||||
for event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
changed_options_listener_remove_callback = (
|
||||
appliance_coordinator.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entity_registry,
|
||||
appliance_coordinator,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
event_key,
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
callbacks_for_appliance.append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
changed_options_listener_remove_callbacks[appliance_ha_id].append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
known_entity_unique_ids.update(
|
||||
{cast(str, entity.unique_id): appliance_ha_id for entity in entities_to_add}
|
||||
)
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.36.0"],
|
||||
"requirements": ["aiohomeconnect==0.36.1"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -22,141 +22,147 @@ set_program_and_options:
|
||||
custom_value: false
|
||||
translation_key: programs
|
||||
options:
|
||||
- consumer_products_cleaning_robot_program_basic_go_home
|
||||
- consumer_products_cleaning_robot_program_cleaning_clean_all
|
||||
- consumer_products_cleaning_robot_program_cleaning_clean_map
|
||||
- consumer_products_cleaning_robot_program_basic_go_home
|
||||
- consumer_products_coffee_maker_program_beverage_ristretto
|
||||
- consumer_products_coffee_maker_program_beverage_caffe_grande
|
||||
- consumer_products_coffee_maker_program_beverage_caffe_latte
|
||||
- consumer_products_coffee_maker_program_beverage_cappuccino
|
||||
- consumer_products_coffee_maker_program_beverage_coffee
|
||||
- consumer_products_coffee_maker_program_beverage_espresso
|
||||
- consumer_products_coffee_maker_program_beverage_espresso_doppio
|
||||
- consumer_products_coffee_maker_program_beverage_coffee
|
||||
- consumer_products_coffee_maker_program_beverage_x_l_coffee
|
||||
- consumer_products_coffee_maker_program_beverage_caffe_grande
|
||||
- consumer_products_coffee_maker_program_beverage_espresso_macchiato
|
||||
- consumer_products_coffee_maker_program_beverage_cappuccino
|
||||
- consumer_products_coffee_maker_program_beverage_hot_water
|
||||
- consumer_products_coffee_maker_program_beverage_latte_macchiato
|
||||
- consumer_products_coffee_maker_program_beverage_caffe_latte
|
||||
- consumer_products_coffee_maker_program_beverage_milk_froth
|
||||
- consumer_products_coffee_maker_program_beverage_ristretto
|
||||
- consumer_products_coffee_maker_program_beverage_warm_milk
|
||||
- consumer_products_coffee_maker_program_coffee_world_kleiner_brauner
|
||||
- consumer_products_coffee_maker_program_beverage_x_l_coffee
|
||||
- consumer_products_coffee_maker_program_coffee_world_americano
|
||||
- consumer_products_coffee_maker_program_coffee_world_black_eye
|
||||
- consumer_products_coffee_maker_program_coffee_world_cafe_au_lait
|
||||
- consumer_products_coffee_maker_program_coffee_world_cafe_con_leche
|
||||
- consumer_products_coffee_maker_program_coffee_world_cafe_cortado
|
||||
- consumer_products_coffee_maker_program_coffee_world_cortado
|
||||
- consumer_products_coffee_maker_program_coffee_world_dead_eye
|
||||
- consumer_products_coffee_maker_program_coffee_world_doppio
|
||||
- consumer_products_coffee_maker_program_coffee_world_flat_white
|
||||
- consumer_products_coffee_maker_program_coffee_world_galao
|
||||
- consumer_products_coffee_maker_program_coffee_world_garoto
|
||||
- consumer_products_coffee_maker_program_coffee_world_grosser_brauner
|
||||
- consumer_products_coffee_maker_program_coffee_world_kaapi
|
||||
- consumer_products_coffee_maker_program_coffee_world_kleiner_brauner
|
||||
- consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd
|
||||
- consumer_products_coffee_maker_program_coffee_world_red_eye
|
||||
- consumer_products_coffee_maker_program_coffee_world_verlaengerter
|
||||
- consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun
|
||||
- consumer_products_coffee_maker_program_coffee_world_wiener_melange
|
||||
- consumer_products_coffee_maker_program_coffee_world_flat_white
|
||||
- consumer_products_coffee_maker_program_coffee_world_cortado
|
||||
- consumer_products_coffee_maker_program_coffee_world_cafe_cortado
|
||||
- consumer_products_coffee_maker_program_coffee_world_cafe_con_leche
|
||||
- consumer_products_coffee_maker_program_coffee_world_cafe_au_lait
|
||||
- consumer_products_coffee_maker_program_coffee_world_doppio
|
||||
- consumer_products_coffee_maker_program_coffee_world_kaapi
|
||||
- consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd
|
||||
- consumer_products_coffee_maker_program_coffee_world_galao
|
||||
- consumer_products_coffee_maker_program_coffee_world_garoto
|
||||
- consumer_products_coffee_maker_program_coffee_world_americano
|
||||
- consumer_products_coffee_maker_program_coffee_world_red_eye
|
||||
- consumer_products_coffee_maker_program_coffee_world_black_eye
|
||||
- consumer_products_coffee_maker_program_coffee_world_dead_eye
|
||||
- consumer_products_coffee_maker_program_beverage_hot_water
|
||||
- dishcare_dishwasher_program_pre_rinse
|
||||
- cooking_common_program_hood_automatic
|
||||
- cooking_common_program_hood_delayed_shut_off
|
||||
- cooking_common_program_hood_venting
|
||||
- cooking_oven_program_heating_mode_3_d_hot_air
|
||||
- cooking_oven_program_heating_mode_air_fry
|
||||
- cooking_oven_program_heating_mode_bottom_heating
|
||||
- cooking_oven_program_heating_mode_bread_baking
|
||||
- cooking_oven_program_heating_mode_defrost
|
||||
- cooking_oven_program_heating_mode_desiccation
|
||||
- cooking_oven_program_heating_mode_dough_proving
|
||||
- cooking_oven_program_heating_mode_frozen_heatup_special
|
||||
- cooking_oven_program_heating_mode_grill_large_area
|
||||
- cooking_oven_program_heating_mode_grill_small_area
|
||||
- cooking_oven_program_heating_mode_hot_air
|
||||
- cooking_oven_program_heating_mode_hot_air_100_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_30_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_60_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_80_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_eco
|
||||
- cooking_oven_program_heating_mode_hot_air_gentle
|
||||
- cooking_oven_program_heating_mode_hot_air_grilling
|
||||
- cooking_oven_program_heating_mode_intensive_heat
|
||||
- cooking_oven_program_heating_mode_keep_warm
|
||||
- cooking_oven_program_heating_mode_pizza_setting
|
||||
- cooking_oven_program_heating_mode_pre_heating
|
||||
- cooking_oven_program_heating_mode_preheat_ovenware
|
||||
- cooking_oven_program_heating_mode_proof
|
||||
- cooking_oven_program_heating_mode_sabbath_programme
|
||||
- cooking_oven_program_heating_mode_slow_cook
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating_eco
|
||||
- cooking_oven_program_heating_mode_warming_drawer
|
||||
- cooking_oven_program_microwave_1000_watt
|
||||
- cooking_oven_program_microwave_180_watt
|
||||
- cooking_oven_program_microwave_360_watt
|
||||
- cooking_oven_program_microwave_450_watt
|
||||
- cooking_oven_program_microwave_600_watt
|
||||
- cooking_oven_program_microwave_900_watt
|
||||
- cooking_oven_program_microwave_90_watt
|
||||
- cooking_oven_program_microwave_max
|
||||
- cooking_oven_program_steam_modes_steam
|
||||
- dishcare_dishwasher_program_auto_1
|
||||
- dishcare_dishwasher_program_auto_2
|
||||
- dishcare_dishwasher_program_auto_3
|
||||
- dishcare_dishwasher_program_auto_half_load
|
||||
- dishcare_dishwasher_program_eco_50
|
||||
- dishcare_dishwasher_program_quick_45
|
||||
- dishcare_dishwasher_program_intensiv_70
|
||||
- dishcare_dishwasher_program_normal_65
|
||||
- dishcare_dishwasher_program_express_sparkle_65
|
||||
- dishcare_dishwasher_program_glas_40
|
||||
- dishcare_dishwasher_program_glass_care
|
||||
- dishcare_dishwasher_program_night_wash
|
||||
- dishcare_dishwasher_program_quick_65
|
||||
- dishcare_dishwasher_program_normal_45
|
||||
- dishcare_dishwasher_program_intensiv_45
|
||||
- dishcare_dishwasher_program_auto_half_load
|
||||
- dishcare_dishwasher_program_intensiv_70
|
||||
- dishcare_dishwasher_program_intensiv_power
|
||||
- dishcare_dishwasher_program_intensive_fixed_zone
|
||||
- dishcare_dishwasher_program_magic_daily
|
||||
- dishcare_dishwasher_program_super_60
|
||||
- dishcare_dishwasher_program_kurz_60
|
||||
- dishcare_dishwasher_program_express_sparkle_65
|
||||
- dishcare_dishwasher_program_learning_dishwasher
|
||||
- dishcare_dishwasher_program_machine_care
|
||||
- dishcare_dishwasher_program_steam_fresh
|
||||
- dishcare_dishwasher_program_magic_daily
|
||||
- dishcare_dishwasher_program_maximum_cleaning
|
||||
- dishcare_dishwasher_program_mixed_load
|
||||
- dishcare_dishwasher_program_learning_dishwasher
|
||||
- dishcare_dishwasher_program_night_wash
|
||||
- dishcare_dishwasher_program_normal_45
|
||||
- dishcare_dishwasher_program_normal_65
|
||||
- dishcare_dishwasher_program_pre_rinse
|
||||
- dishcare_dishwasher_program_quick_45
|
||||
- dishcare_dishwasher_program_quick_65
|
||||
- dishcare_dishwasher_program_steam_fresh
|
||||
- dishcare_dishwasher_program_super_60
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_active_clean
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_auto
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_cool
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_dry
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_fan
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_heat
|
||||
- laundry_care_dryer_program_cotton
|
||||
- laundry_care_dryer_program_synthetic
|
||||
- laundry_care_dryer_program_mix
|
||||
- laundry_care_dryer_program_anti_shrink
|
||||
- laundry_care_dryer_program_blankets
|
||||
- laundry_care_dryer_program_business_shirts
|
||||
- laundry_care_dryer_program_cotton
|
||||
- laundry_care_dryer_program_delicates
|
||||
- laundry_care_dryer_program_dessous
|
||||
- laundry_care_dryer_program_down_feathers
|
||||
- laundry_care_dryer_program_hygiene
|
||||
- laundry_care_dryer_program_jeans
|
||||
- laundry_care_dryer_program_outdoor
|
||||
- laundry_care_dryer_program_synthetic_refresh
|
||||
- laundry_care_dryer_program_towels
|
||||
- laundry_care_dryer_program_delicates
|
||||
- laundry_care_dryer_program_super_40
|
||||
- laundry_care_dryer_program_shirts_15
|
||||
- laundry_care_dryer_program_pillow
|
||||
- laundry_care_dryer_program_anti_shrink
|
||||
- laundry_care_dryer_program_my_time_my_drying_time
|
||||
- laundry_care_dryer_program_time_cold
|
||||
- laundry_care_dryer_program_time_warm
|
||||
- laundry_care_dryer_program_in_basket
|
||||
- laundry_care_dryer_program_jeans
|
||||
- laundry_care_dryer_program_mix
|
||||
- laundry_care_dryer_program_my_time_my_drying_time
|
||||
- laundry_care_dryer_program_outdoor
|
||||
- laundry_care_dryer_program_pillow
|
||||
- laundry_care_dryer_program_shirts_15
|
||||
- laundry_care_dryer_program_super_40
|
||||
- laundry_care_dryer_program_synthetic
|
||||
- laundry_care_dryer_program_synthetic_refresh
|
||||
- laundry_care_dryer_program_time_cold
|
||||
- laundry_care_dryer_program_time_cold_fix_time_cold_20
|
||||
- laundry_care_dryer_program_time_cold_fix_time_cold_30
|
||||
- laundry_care_dryer_program_time_cold_fix_time_cold_60
|
||||
- laundry_care_dryer_program_time_warm
|
||||
- laundry_care_dryer_program_time_warm_fix_time_warm_30
|
||||
- laundry_care_dryer_program_time_warm_fix_time_warm_40
|
||||
- laundry_care_dryer_program_time_warm_fix_time_warm_60
|
||||
- laundry_care_dryer_program_dessous
|
||||
- cooking_common_program_hood_automatic
|
||||
- cooking_common_program_hood_venting
|
||||
- cooking_common_program_hood_delayed_shut_off
|
||||
- cooking_oven_program_heating_mode_3_d_hot_air
|
||||
- cooking_oven_program_heating_mode_air_fry
|
||||
- cooking_oven_program_heating_mode_grill_large_area
|
||||
- cooking_oven_program_heating_mode_grill_small_area
|
||||
- cooking_oven_program_heating_mode_pre_heating
|
||||
- cooking_oven_program_heating_mode_hot_air
|
||||
- cooking_oven_program_heating_mode_hot_air_eco
|
||||
- cooking_oven_program_heating_mode_hot_air_gentle
|
||||
- cooking_oven_program_heating_mode_hot_air_grilling
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating
|
||||
- cooking_oven_program_heating_mode_top_bottom_heating_eco
|
||||
- cooking_oven_program_heating_mode_bottom_heating
|
||||
- cooking_oven_program_heating_mode_bread_baking
|
||||
- cooking_oven_program_heating_mode_pizza_setting
|
||||
- cooking_oven_program_heating_mode_slow_cook
|
||||
- cooking_oven_program_heating_mode_intensive_heat
|
||||
- cooking_oven_program_heating_mode_keep_warm
|
||||
- cooking_oven_program_heating_mode_preheat_ovenware
|
||||
- cooking_oven_program_heating_mode_frozen_heatup_special
|
||||
- cooking_oven_program_heating_mode_desiccation
|
||||
- cooking_oven_program_heating_mode_defrost
|
||||
- cooking_oven_program_heating_mode_dough_proving
|
||||
- cooking_oven_program_heating_mode_proof
|
||||
- cooking_oven_program_heating_mode_hot_air_30_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_60_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_80_steam
|
||||
- cooking_oven_program_heating_mode_hot_air_100_steam
|
||||
- cooking_oven_program_heating_mode_sabbath_programme
|
||||
- cooking_oven_program_microwave_90_watt
|
||||
- cooking_oven_program_microwave_180_watt
|
||||
- cooking_oven_program_microwave_360_watt
|
||||
- cooking_oven_program_microwave_450_watt
|
||||
- cooking_oven_program_microwave_600_watt
|
||||
- cooking_oven_program_microwave_900_watt
|
||||
- cooking_oven_program_microwave_1000_watt
|
||||
- cooking_oven_program_microwave_max
|
||||
- cooking_oven_program_steam_modes_steam
|
||||
- cooking_oven_program_heating_mode_warming_drawer
|
||||
- laundry_care_dryer_program_towels
|
||||
- laundry_care_washer_dryer_program_cotton
|
||||
- laundry_care_washer_dryer_program_cotton_eco_4060
|
||||
- laundry_care_washer_dryer_program_easy_care
|
||||
- laundry_care_washer_dryer_program_mix
|
||||
- laundry_care_washer_dryer_program_wash_and_dry_60
|
||||
- laundry_care_washer_dryer_program_wash_and_dry_90
|
||||
- laundry_care_washer_program_auto_30
|
||||
- laundry_care_washer_program_auto_40
|
||||
- laundry_care_washer_program_auto_60
|
||||
@@ -190,12 +196,6 @@ set_program_and_options:
|
||||
- laundry_care_washer_program_towels
|
||||
- laundry_care_washer_program_water_proof
|
||||
- laundry_care_washer_program_wool
|
||||
- laundry_care_washer_dryer_program_cotton
|
||||
- laundry_care_washer_dryer_program_cotton_eco_4060
|
||||
- laundry_care_washer_dryer_program_mix
|
||||
- laundry_care_washer_dryer_program_easy_care
|
||||
- laundry_care_washer_dryer_program_wash_and_dry_60
|
||||
- laundry_care_washer_dryer_program_wash_and_dry_90
|
||||
air_conditioner_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The homee cover platform."""
|
||||
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -24,12 +25,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
OPEN_CLOSE_ATTRIBUTES = [
|
||||
AttributeType.OPEN_CLOSE,
|
||||
AttributeType.SLAT_ROTATION_IMPULSE,
|
||||
AttributeType.UP_DOWN,
|
||||
]
|
||||
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
|
||||
COVER_DEVICE_PROFILES = {
|
||||
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
||||
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||
@@ -43,6 +38,24 @@ IS_CLOSED_ATTRIBUTES = [
|
||||
]
|
||||
|
||||
|
||||
class HomeeCoverState(float, Enum):
|
||||
"""Open/closed states for covers in homee."""
|
||||
|
||||
OPEN = 0.0
|
||||
CLOSED = 1.0
|
||||
STOPPED = 2.0
|
||||
OPENING = 3.0
|
||||
CLOSING = 4.0
|
||||
|
||||
|
||||
class HomeeSlatState(float, Enum):
|
||||
"""Slat states for covers in homee."""
|
||||
|
||||
STOPPED = 0.0
|
||||
CLOSED = 1.0
|
||||
OPEN = 2.0
|
||||
|
||||
|
||||
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute | None:
|
||||
"""Return the attribute used for opening/closing the cover."""
|
||||
# We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them.
|
||||
@@ -70,7 +83,11 @@ def get_cover_features(
|
||||
features |= CoverEntityFeature.SET_POSITION
|
||||
|
||||
if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None:
|
||||
features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
|
||||
features |= (
|
||||
CoverEntityFeature.OPEN_TILT
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
| CoverEntityFeature.STOP_TILT
|
||||
)
|
||||
|
||||
if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None:
|
||||
features |= CoverEntityFeature.SET_TILT_POSITION
|
||||
@@ -187,9 +204,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Return the opening status of the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
return (
|
||||
self._open_close_attribute.get_value() == 3
|
||||
self._open_close_attribute.get_value() == HomeeCoverState.OPENING
|
||||
if not self._open_close_attribute.is_reversed
|
||||
else self._open_close_attribute.get_value() == 4
|
||||
else self._open_close_attribute.get_value() == HomeeCoverState.CLOSING
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -199,9 +216,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Return the closing status of the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
return (
|
||||
self._open_close_attribute.get_value() == 4
|
||||
self._open_close_attribute.get_value() == HomeeCoverState.CLOSING
|
||||
if not self._open_close_attribute.is_reversed
|
||||
else self._open_close_attribute.get_value() == 3
|
||||
else self._open_close_attribute.get_value() == HomeeCoverState.OPENING
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -216,9 +233,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
|
||||
if self._open_close_attribute is not None:
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
return self._open_close_attribute.get_value() == 1
|
||||
return self._open_close_attribute.get_value() == HomeeCoverState.CLOSED
|
||||
|
||||
return self._open_close_attribute.get_value() == 0
|
||||
return self._open_close_attribute.get_value() == HomeeCoverState.OPEN
|
||||
|
||||
# If none of the above is present, it will be a slat only cover.
|
||||
attribute = self._node.get_attribute_by_type(
|
||||
@@ -235,17 +252,25 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
"""Open the cover."""
|
||||
assert self._open_close_attribute is not None
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 0)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.OPEN
|
||||
)
|
||||
else:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 1)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.CLOSED
|
||||
)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
assert self._open_close_attribute is not None
|
||||
if not self._open_close_attribute.is_reversed:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 1)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.CLOSED
|
||||
)
|
||||
else:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 0)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.OPEN
|
||||
)
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
@@ -265,7 +290,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
if self._open_close_attribute is not None:
|
||||
await self.async_set_homee_value(self._open_close_attribute, 2)
|
||||
await self.async_set_homee_value(
|
||||
self._open_close_attribute, HomeeCoverState.STOPPED
|
||||
)
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
@@ -275,9 +302,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
)
|
||||
) is not None:
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_homee_value(slat_attribute, 2)
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN)
|
||||
else:
|
||||
await self.async_set_homee_value(slat_attribute, 1)
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Close the cover tilt."""
|
||||
@@ -287,9 +314,18 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
|
||||
)
|
||||
) is not None:
|
||||
if not slat_attribute.is_reversed:
|
||||
await self.async_set_homee_value(slat_attribute, 1)
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.CLOSED)
|
||||
else:
|
||||
await self.async_set_homee_value(slat_attribute, 2)
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.OPEN)
|
||||
|
||||
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover tilt."""
|
||||
if (
|
||||
slat_attribute := self._node.get_attribute_by_type(
|
||||
AttributeType.SLAT_ROTATION_IMPULSE
|
||||
)
|
||||
) is not None:
|
||||
await self.async_set_homee_value(slat_attribute, HomeeSlatState.STOPPED)
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.5"]
|
||||
"requirements": ["aioautomower==2.7.6"]
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["idasen-ha==2.6.5"]
|
||||
"requirements": ["idasen-ha==2.7.0"]
|
||||
}
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Container, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
from typing import Final, final
|
||||
from typing import Final, final, override
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
from aiohttp import web
|
||||
import httpx
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import (
|
||||
@@ -314,33 +315,28 @@ class ImageView(HomeAssistantView):
|
||||
"""View to serve an image."""
|
||||
|
||||
name = "api:image:image"
|
||||
requires_auth = False
|
||||
use_query_token_for_auth = True
|
||||
url = "/api/image_proxy/{entity_id}"
|
||||
|
||||
def __init__(self, component: EntityComponent[ImageEntity]) -> None:
|
||||
"""Initialize an image view."""
|
||||
self.component = component
|
||||
|
||||
async def _authenticate_request(
|
||||
self, request: web.Request, entity_id: str
|
||||
) -> ImageEntity:
|
||||
"""Authenticate request and return image entity."""
|
||||
@callback
|
||||
@override
|
||||
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
|
||||
"""Return valid auth tokens, which can be used for query token authentication."""
|
||||
if (image_entity := self.component.get_entity(match_info["entity_id"])) is None:
|
||||
return ()
|
||||
|
||||
return image_entity.access_tokens
|
||||
|
||||
@callback
|
||||
def _get_image_entity(self, entity_id: str) -> ImageEntity:
|
||||
"""Get image entity from request."""
|
||||
if (image_entity := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED]
|
||||
or request.query.get("token") in image_entity.access_tokens
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
# Attempt with invalid bearer token, raise unauthorized
|
||||
# so ban middleware can handle it.
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
# Invalid sigAuth or image entity access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
return image_entity
|
||||
|
||||
async def head(self, request: web.Request, entity_id: str) -> web.Response:
|
||||
@@ -349,7 +345,7 @@ class ImageView(HomeAssistantView):
|
||||
This is sent by some DLNA renderers, like Samsung ones, prior to sending
|
||||
the GET request.
|
||||
"""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
image_entity = self._get_image_entity(entity_id)
|
||||
|
||||
# Don't use `handle` as we don't care about the stream case, we only want
|
||||
# to verify that the image exists.
|
||||
@@ -365,7 +361,7 @@ class ImageView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
image_entity = self._get_image_entity(entity_id)
|
||||
return await self.handle(request, image_entity)
|
||||
|
||||
async def handle(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["imgw_pib==2.2.0"]
|
||||
"requirements": ["imgw_pib==2.2.2"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user