Compare commits
407 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 | |||
| 261914c592 | |||
| 09637c1a3a | |||
| 0816385185 | |||
| 4b64b26870 | |||
| b20f9ad40a | |||
| 99d279bdd8 | |||
| 69e0e11077 | |||
| 9e3c143bd0 | |||
| 45c55543e9 | |||
| fb02e93a0c | |||
| a54b97eeca | |||
| 61c196405b | |||
| 9a047ad115 | |||
| 07a584057c | |||
| 5873dff1d9 | |||
| 30a2bd9b92 | |||
| 1065dce882 | |||
| 878a39194a | |||
| 2e2f4a7dcb | |||
| 46627984f8 | |||
| 5445f9e42b | |||
| 8ce2a5257d | |||
| 787828d7de | |||
| 9e96912a1e | |||
| fd578cfd4c | |||
| 94de8646c6 | |||
| 2d19e84d15 | |||
| a17cfbc2a5 | |||
| c552b0a067 | |||
| 80241a44d9 | |||
| d8b02ea6d6 | |||
| 36d2e85351 | |||
| 174ac9eafe | |||
| 772c426d5d | |||
| a32d028e3d | |||
| bc66c2610e | |||
| c22823ff8d | |||
| a21212ab7e | |||
| 71eefdc716 | |||
| af08e5e7d0 | |||
| b03d87dc21 | |||
| d8a9ea1d9d | |||
| 5ff07fcc49 | |||
| 6f59bb0661 | |||
| c82d32bbae | |||
| 4fbc363965 | |||
| 8622f0f4de | |||
| b49a6b89b6 | |||
| 0bfd4c44bb | |||
| c09216650f | |||
| 6057d32636 | |||
| 51c9d0c6e5 | |||
| 323304664e | |||
| 3dda7d9848 | |||
| 5e56d74257 | |||
| e5f9c7892a | |||
| a0d713a4a7 | |||
| 84f4f876b1 | |||
| f5819d400e | |||
| 31fcbe7bce | |||
| 3664eb4942 | |||
| 2f03b7c427 | |||
| 2d8cebf99d | |||
| 8ca4471418 | |||
| 02720605ae | |||
| fb28825f1f | |||
| 25ce81732b | |||
| 9c1cc55482 | |||
| 477756da5b | |||
| ec995a3472 | |||
| a19f3045e7 | |||
| 6a836bd1d9 | |||
| 01d390293b | |||
| b069bc2f03 | |||
| 7e36d265ed | |||
| 155cb38090 | |||
| 0f01148207 | |||
| a65503f203 | |||
| 063fa8df7e | |||
| 1865c16041 | |||
| cad177cdff | |||
| be2aaf926b | |||
| d7219aa025 | |||
| 7fb475aad1 | |||
| 25f18c6082 | |||
| c901160fb3 | |||
| 018e42e670 | |||
| 04442bb73e | |||
| eb3fd52619 | |||
| 8e19fd280e | |||
| e45f64b880 | |||
| 480a8d536f | |||
| 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 | |||
| 0abc9b787b | |||
| ce46be110d | |||
| 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 | |||
| c22f10bf87 | |||
| 97d9c23855 | |||
| 477d8bde6b | |||
| da12c94f27 | |||
| 52afa0627e | |||
| 9b8b8c2d82 | |||
| db0fc36a54 | |||
| cb00ee1503 | |||
| ae23c5db4d | |||
| d0b4274c2b | |||
| ecd132b60c | |||
| 58d5db7494 | |||
| 3c0a34cc66 | |||
| de70e86eae | |||
| 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@f889c9c3c06adeaabccefc06e29c42733ee05dff # v0.75.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@f889c9c3c06adeaabccefc06e29c42733ee05dff # v0.75.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@f889c9c3c06adeaabccefc06e29c42733ee05dff # v0.75.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@f889c9c3c06adeaabccefc06e29c42733ee05dff # v0.75.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@f889c9c3c06adeaabccefc06e29c42733ee05dff # v0.75.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@f889c9c3c06adeaabccefc06e29c42733ee05dff # v0.75.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@f889c9c3c06adeaabccefc06e29c42733ee05dff # v0.75.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: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@851cffe46851ddd2051ea7147ebdc995113241c3 # v6.0.1
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
+25
-67
@@ -20,22 +20,36 @@ jobs:
|
||||
permissions:
|
||||
issues: write # To label and close stale issues
|
||||
pull-requests: write # To label and close stale PRs
|
||||
actions: write # To delete stalebot state
|
||||
steps:
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 60 day stale policy for PRs
|
||||
# Used for:
|
||||
# - PRs
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
- name: 60 days stale PRs policy and 90 days stale issue policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
operations-per-run: 150
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
remove-stale-when-updated: true
|
||||
operations-per-run: 350
|
||||
# pr policy
|
||||
days-before-pr-stale: 60
|
||||
days-before-pr-close: 7
|
||||
stale-pr-label: "stale"
|
||||
exempt-pr-labels: "no-stale"
|
||||
stale-pr-message: >
|
||||
@@ -48,65 +62,9 @@ jobs:
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
# Generate a token for the GitHub App, we use this method to avoid
|
||||
# hitting API limits for our GitHub actions + have a higher rate limit.
|
||||
# This is only used for issues.
|
||||
- name: Generate app token
|
||||
id: token
|
||||
# Pinned to a specific version of the action for security reasons
|
||||
# v3.2.0
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted,needs-more-information"
|
||||
stale-issue-message: >
|
||||
There hasn't been any activity on this issue recently. Due to the
|
||||
high number of incoming GitHub notifications, we have to clean some
|
||||
of the old issues, as many of them have already been resolved with
|
||||
the latest updates.
|
||||
|
||||
Please make sure to update to the latest Home Assistant version and
|
||||
check if that solves the issue. Let us know if that works for you by
|
||||
adding a comment 👍
|
||||
|
||||
This issue has now been marked as stale and will be closed if no
|
||||
further activity occurs. Thank you for your contributions.
|
||||
|
||||
# The 30 day stale policy for issues
|
||||
# Used for:
|
||||
# - Issues that are pending more information (incomplete issues)
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
days-before-stale: 14
|
||||
days-before-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 250
|
||||
remove-stale-when-updated: true
|
||||
# issue policy
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "no-stale,help-wanted"
|
||||
stale-issue-message: >
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -286,6 +286,7 @@ homeassistant.components.huawei_lte.*
|
||||
homeassistant.components.humidifier.*
|
||||
homeassistant.components.husqvarna_automower.*
|
||||
homeassistant.components.huum.*
|
||||
homeassistant.components.hvv_departures.*
|
||||
homeassistant.components.hydrawise.*
|
||||
homeassistant.components.hyperion.*
|
||||
homeassistant.components.hypontech.*
|
||||
|
||||
Generated
+10
-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
|
||||
@@ -501,6 +503,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
|
||||
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom
|
||||
/homeassistant/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/tests/components/envertech_evt800/ @daniel-bergmann-00
|
||||
/homeassistant/components/environment_canada/ @gwww @michaeldavie
|
||||
/tests/components/environment_canada/ @gwww @michaeldavie
|
||||
/homeassistant/components/ephember/ @ttroy50 @roberty99
|
||||
@@ -623,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
|
||||
@@ -718,6 +722,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
/homeassistant/components/hegel/ @boazca
|
||||
/tests/components/hegel/ @boazca
|
||||
/homeassistant/components/helty/ @ebaschiera
|
||||
/tests/components/helty/ @ebaschiera
|
||||
/homeassistant/components/heos/ @andrewsayre
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
@@ -836,6 +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
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
PERCENTAGE,
|
||||
UV_INDEX,
|
||||
UnitOfIrradiance,
|
||||
@@ -47,6 +46,8 @@ from .coordinator import (
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
PARTS_PER_CUBIC_METER = "p/m³"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AccuWeatherSensorDescription(SensorEntityDescription):
|
||||
@@ -81,7 +82,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Grass",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -107,7 +108,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
AccuWeatherSensorDescription(
|
||||
key="Mold",
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
|
||||
@@ -116,7 +117,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Ragweed",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
@@ -184,7 +185,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
|
||||
),
|
||||
AccuWeatherSensorDescription(
|
||||
key="Tree",
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
|
||||
native_unit_of_measurement=PARTS_PER_CUBIC_METER,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
|
||||
attr_fn=lambda data: {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from bleak.backends.device import BLEDevice
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -63,7 +64,16 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
||||
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Airthings device with address {address}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
self.hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
self.ble_device = ble_device
|
||||
|
||||
|
||||
@@ -54,5 +54,10 @@
|
||||
"name": "Radon longterm level"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "Could not find Airthings device with address {address}: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -204,7 +204,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
|
||||
async def sync_media_state(self) -> None:
|
||||
"""Sync media state."""
|
||||
await self.api.sync_media_state()
|
||||
try:
|
||||
await self.api.sync_media_state()
|
||||
except CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotConnect, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except (CannotRetrieveData, ValueError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data_with_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
async def media_state_event_handler(
|
||||
self, media_state: dict[str, AmazonMediaState]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.8.1"]
|
||||
"requirements": ["aioamazondevices==14.0.0"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Media player platform for Alexa Devices."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.structures import (
|
||||
AmazonMediaControls,
|
||||
@@ -38,18 +37,6 @@ STANDARD_SUPPORTED_FEATURES = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
|
||||
"""Describes an Alexa Devices media player entity."""
|
||||
|
||||
|
||||
MEDIA_PLAYERS: Final = (
|
||||
AmazonDevicesMediaPlayerEntityDescription(
|
||||
key="media",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AmazonConfigEntry,
|
||||
@@ -69,9 +56,10 @@ async def async_setup_entry(
|
||||
continue
|
||||
|
||||
known_devices.add(serial_num)
|
||||
new_entities.extend(
|
||||
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
|
||||
for description in MEDIA_PLAYERS
|
||||
new_entities.append(
|
||||
AlexaDevicesMediaPlayer(
|
||||
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
|
||||
)
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
@@ -85,8 +73,6 @@ async def async_setup_entry(
|
||||
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
"""Representation of an Alexa device media player."""
|
||||
|
||||
entity_description: AmazonDevicesMediaPlayerEntityDescription
|
||||
|
||||
_attr_name = None # Uses the device name
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_volume_step = 0.05
|
||||
@@ -95,7 +81,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
self,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
serial_num: str,
|
||||
description: AmazonDevicesMediaPlayerEntityDescription,
|
||||
description: MediaPlayerEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self._prev_volume: int | None = None
|
||||
@@ -156,9 +142,11 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return True if the volume is muted."""
|
||||
if not self.volume_state:
|
||||
if not self.volume_state or self.volume_state.volume is None:
|
||||
return None
|
||||
return self.volume_state.volume == 0
|
||||
# is_muted is True when Alexa has muted the device
|
||||
# volume == 0 is where we have muted by setting volume to 0
|
||||
return self.volume_state.is_muted or self.volume_state.volume == 0
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
@@ -212,7 +200,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | None:
|
||||
"""Content type — tells HA what kind of media is playing."""
|
||||
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
|
||||
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
|
||||
return MediaType.MUSIC
|
||||
return None
|
||||
|
||||
@@ -225,7 +213,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Play a piece of media."""
|
||||
await self.async_call_alexa_music(media_id, media_type)
|
||||
provider = media_type.value if isinstance(media_type, MediaType) else media_type
|
||||
await self.async_call_alexa_music(media_id, provider)
|
||||
|
||||
@alexa_api_call
|
||||
async def async_call_alexa_music(
|
||||
@@ -259,12 +248,20 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
|
||||
return
|
||||
if mute:
|
||||
self._prev_volume = self.volume_state.volume
|
||||
target_volume = 0
|
||||
else:
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(0)
|
||||
return
|
||||
|
||||
if self.volume_state.is_muted and self._prev_volume is None:
|
||||
# is muted by Alexa which we can see but not control
|
||||
# when muted this way, volume is still set
|
||||
# changing volume will unmute
|
||||
# if HA set volume to 0 then Alexa muted we just default to 30%
|
||||
self._prev_volume = self.volume_state.volume or 30
|
||||
if self._prev_volume is None:
|
||||
return
|
||||
target_volume = self._prev_volume
|
||||
await self.async_set_volume_level(target_volume / 100)
|
||||
self._prev_volume = None
|
||||
|
||||
@alexa_api_call
|
||||
async def _send_media_command(self, command: AmazonMediaControls) -> None:
|
||||
|
||||
@@ -125,6 +125,9 @@
|
||||
},
|
||||
"invalid_sound_value": {
|
||||
"message": "Invalid sound {sound} specified"
|
||||
},
|
||||
"unknown_exception": {
|
||||
"message": "Unknown error occurred: {error}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -4,7 +4,6 @@ import base64
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
@@ -114,7 +113,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
from homeassistant.util.json import JsonArrayType, JsonObjectType
|
||||
|
||||
from .const import (
|
||||
@@ -372,7 +371,7 @@ def _convert_content( # noqa: C901
|
||||
)
|
||||
if (
|
||||
content.native.container is not None
|
||||
and content.native.container.expires_at > datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
and content.native.container.expires_at > dt_util.utcnow()
|
||||
):
|
||||
container_id = content.native.container.id
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["apprise"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["apprise==1.9.1"]
|
||||
"requirements": ["apprise==1.11.0"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": [],
|
||||
"dependencies": ["mqtt"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/arwn",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy"
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["arwn-client==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -3,113 +3,26 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arwn_client import parse_message
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "arwn"
|
||||
|
||||
DATA_ARWN = "arwn"
|
||||
TOPIC = "arwn/#"
|
||||
|
||||
|
||||
def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | None:
|
||||
"""Given a topic, dynamically create the right sensor type.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
parts = topic.split("/")
|
||||
unit = payload.get("units", "")
|
||||
domain = parts[1]
|
||||
if domain == "temperature":
|
||||
name = parts[2]
|
||||
if unit == "F":
|
||||
unit = UnitOfTemperature.FAHRENHEIT
|
||||
else:
|
||||
unit = UnitOfTemperature.CELSIUS
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic, name, "temp", unit, device_class=SensorDeviceClass.TEMPERATURE
|
||||
)
|
||||
]
|
||||
if domain == "moisture":
|
||||
name = f"{parts[2]} Moisture"
|
||||
return [ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent")]
|
||||
if domain == "rain":
|
||||
if len(parts) >= 3 and parts[2] == "today":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic,
|
||||
"Rain Since Midnight",
|
||||
"since_midnight",
|
||||
UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
)
|
||||
]
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/total",
|
||||
"Total Rainfall",
|
||||
"total",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/rate",
|
||||
"Rainfall Rate",
|
||||
"rate",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
),
|
||||
]
|
||||
if domain == "barometer":
|
||||
return [
|
||||
ArwnSensor(topic, "Barometer", "pressure", unit, "mdi:thermometer-lines")
|
||||
]
|
||||
if domain == "wind":
|
||||
return [
|
||||
ArwnSensor(
|
||||
topic + "/speed",
|
||||
"Wind Speed",
|
||||
"speed",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/gust",
|
||||
"Wind Gust",
|
||||
"gust",
|
||||
unit,
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/dir",
|
||||
"Wind Direction",
|
||||
"direction",
|
||||
DEGREE,
|
||||
"mdi:compass",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
]
|
||||
return None
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return f"sensor.arwn_{slugify(name)}"
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -118,28 +31,25 @@ async def async_setup_platform(
|
||||
) -> None:
|
||||
"""Set up the ARWN platform."""
|
||||
|
||||
# Make sure MQTT integration is enabled and the client is available
|
||||
if not await mqtt.async_wait_for_mqtt_client(hass):
|
||||
_LOGGER.error("MQTT integration is not available")
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None:
|
||||
"""Process events as sensors.
|
||||
"""Process MQTT events as sensors."""
|
||||
try:
|
||||
event = json_loads_object(msg.payload)
|
||||
device = parse_message(msg.topic, event)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.debug(
|
||||
"Failed to parse ARWN message on topic %s",
|
||||
msg.topic,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
When a new event on our topic (arwn/#) is received we map it
|
||||
into a known kind of sensor based on topic name. If we've
|
||||
never seen this before, we keep this sensor around in a global
|
||||
cache. If we have seen it before, we update the values of the
|
||||
existing sensor. Either way, we push an ha state update at the
|
||||
end for the new event we've seen.
|
||||
|
||||
This lets us dynamically incorporate sensors without any
|
||||
configuration on our side.
|
||||
"""
|
||||
event = json_loads_object(msg.payload)
|
||||
sensors = discover_sensors(msg.topic, event)
|
||||
if not sensors:
|
||||
if device is None:
|
||||
return
|
||||
|
||||
if (store := hass.data.get(DATA_ARWN)) is None:
|
||||
@@ -148,22 +58,71 @@ async def async_setup_platform(
|
||||
if "timestamp" in event:
|
||||
del event["timestamp"]
|
||||
|
||||
for sensor in sensors:
|
||||
if sensor.name not in store:
|
||||
sensor.hass = hass
|
||||
sensor.set_event(event)
|
||||
store[sensor.name] = sensor
|
||||
new_sensors: list[ArwnSensor] = []
|
||||
for reading in device.readings:
|
||||
if not reading.expose:
|
||||
continue
|
||||
|
||||
unique_id = (
|
||||
f"{msg.topic}/{reading.sensor_key}"
|
||||
if len(device.readings) > 1
|
||||
else msg.topic
|
||||
)
|
||||
|
||||
try:
|
||||
device_class = (
|
||||
SensorDeviceClass(reading.device_class)
|
||||
if reading.device_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown device_class=%s for sensor %s",
|
||||
reading.device_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
device_class = None
|
||||
|
||||
try:
|
||||
state_class = (
|
||||
SensorStateClass(reading.state_class)
|
||||
if reading.state_class
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unknown state_class=%s for sensor %s",
|
||||
reading.state_class,
|
||||
reading.sensor_name,
|
||||
)
|
||||
state_class = None
|
||||
|
||||
if unique_id not in store:
|
||||
sensor = ArwnSensor(
|
||||
unique_id=unique_id,
|
||||
name=reading.sensor_name,
|
||||
state_key=reading.sensor_key,
|
||||
units=reading.unit,
|
||||
icon=reading.icon,
|
||||
device_class=device_class,
|
||||
state_class=state_class,
|
||||
event=event,
|
||||
)
|
||||
store[unique_id] = sensor
|
||||
_LOGGER.debug(
|
||||
"Registering sensor %(name)s => %(event)s",
|
||||
{"name": sensor.name, "event": event},
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
)
|
||||
async_add_entities((sensor,), True)
|
||||
new_sensors.append(sensor)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Recording sensor %(name)s => %(event)s",
|
||||
{"name": sensor.name, "event": event},
|
||||
{"name": reading.sensor_name, "event": event},
|
||||
)
|
||||
store[sensor.name].set_event(event)
|
||||
store[unique_id].set_event(event)
|
||||
|
||||
if new_sensors:
|
||||
async_add_entities(new_sensors, True)
|
||||
|
||||
await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0)
|
||||
|
||||
@@ -175,29 +134,29 @@ class ArwnSensor(SensorEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
topic: str,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
state_key: str,
|
||||
units: str,
|
||||
icon: str | None = None,
|
||||
device_class: SensorDeviceClass | None = None,
|
||||
state_class: SensorStateClass | None = None,
|
||||
event: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_id = _slug(name)
|
||||
self._attr_name = name
|
||||
# This mqtt topic for the sensor which is its uid
|
||||
self._attr_unique_id = topic
|
||||
self._attr_unique_id = unique_id
|
||||
self._state_key = state_key
|
||||
self._attr_native_unit_of_measurement = units
|
||||
self._attr_icon = icon
|
||||
self._attr_device_class = device_class
|
||||
self._attr_state_class = state_class
|
||||
if event is not None:
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(state_key)
|
||||
|
||||
def set_event(self, event: dict[str, Any]) -> None:
|
||||
"""Update the sensor with the most recent event."""
|
||||
ev: dict[str, Any] = {}
|
||||
ev.update(event)
|
||||
self._attr_extra_state_attributes = ev
|
||||
self._attr_native_value = ev.get(self._state_key)
|
||||
self._attr_extra_state_attributes = dict(event)
|
||||
self._attr_native_value = event.get(self._state_key)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -6,7 +6,6 @@ from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.session import ApiHost
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
@@ -18,10 +17,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DEFAULT_SETUP_TIMEOUT
|
||||
from .coordinator import BleBoxConfigEntry, BleBoxCoordinator
|
||||
from .helpers import get_maybe_authenticated_session
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[Box]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -35,8 +33,6 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bool:
|
||||
"""Set up BleBox devices from a config entry."""
|
||||
@@ -58,7 +54,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo
|
||||
_LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex)
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
entry.runtime_data = product
|
||||
coordinator = BleBoxCoordinator(hass, entry, product)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -11,13 +11,20 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
BINARY_SENSOR_TYPES = (
|
||||
BinarySensorEntityDescription(
|
||||
key="moisture",
|
||||
device_class=BinarySensorDeviceClass.MOISTURE,
|
||||
),
|
||||
BinarySensorEntityDescription(
|
||||
key="open",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -27,23 +34,27 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxBinarySensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("binary_sensors", [])
|
||||
BleBoxBinarySensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("binary_sensors", [])
|
||||
for description in BINARY_SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxBinarySensorEntity(BleBoxEntity[BinarySensorFeature], BinarySensorEntity):
|
||||
"""Representation of a BleBox binary sensor feature."""
|
||||
|
||||
def __init__(
|
||||
self, feature: BinarySensorFeature, description: BinarySensorEntityDescription
|
||||
self,
|
||||
coordinator: BleBoxCoordinator,
|
||||
feature: BinarySensorFeature,
|
||||
description: BinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a BleBox binary sensor feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,12 +2,26 @@
|
||||
|
||||
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
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
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(
|
||||
@@ -16,35 +30,33 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox button entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxButtonEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("buttons", [])
|
||||
BleBoxButtonEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("buttons", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
|
||||
"""Representation of BleBox buttons."""
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.button.Button) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
|
||||
) -> None:
|
||||
"""Initialize a BleBox button feature."""
|
||||
super().__init__(feature)
|
||||
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:
|
||||
"""Handle the button press."""
|
||||
await self._feature.set()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""BleBox climate entity."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.climate
|
||||
@@ -17,8 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BLEBOX_TO_HVACMODE = {
|
||||
0: HVACMode.OFF,
|
||||
@@ -40,11 +40,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox climate entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxClimateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("climates", [])
|
||||
BleBoxClimateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("climates", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEntity):
|
||||
@@ -108,6 +109,7 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
"""Return the desired thermostat temperature."""
|
||||
return self._feature.desired
|
||||
|
||||
@blebox_command
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set the climate entity mode."""
|
||||
if hvac_mode in [HVACMode.HEAT, HVACMode.COOL]:
|
||||
@@ -116,6 +118,7 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn
|
||||
|
||||
await self._feature.async_off()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the thermostat temperature."""
|
||||
value = kwargs[ATTR_TEMPERATURE]
|
||||
|
||||
@@ -33,23 +33,14 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_schema(previous_input=None):
|
||||
"""Create a schema with given values as default."""
|
||||
if previous_input is not None:
|
||||
host = previous_input[CONF_HOST]
|
||||
port = previous_input[CONF_PORT]
|
||||
else:
|
||||
host = DEFAULT_HOST
|
||||
port = DEFAULT_PORT
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=host): str,
|
||||
vol.Required(CONF_PORT, default=port): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
STEP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Inclusive(CONF_USERNAME, "auth"): str,
|
||||
vol.Inclusive(CONF_PASSWORD, "auth"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LOG_MSG = {
|
||||
@@ -69,18 +60,44 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.device_config: dict[str, Any] = {}
|
||||
|
||||
def handle_step_exception(
|
||||
self, step, exception, schema, host, port, message_id, log_fn
|
||||
self, exception, schema, host, port, message_id, log_fn, step_id
|
||||
):
|
||||
"""Handle step exceptions."""
|
||||
log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
step_id=step_id,
|
||||
data_schema=schema,
|
||||
errors={"base": message_id},
|
||||
description_placeholders={"address": f"{host}:{port}"},
|
||||
)
|
||||
|
||||
async def _async_from_host_or_form(
|
||||
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
|
||||
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
|
||||
"""Try to connect to the device; return product or an error form."""
|
||||
schema = self.add_suggested_values_to_schema(STEP_SCHEMA, user_input)
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
try:
|
||||
return await Box.async_from_host(api_host), None
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNSUPPORTED_VERSION, _LOGGER.debug, step_id
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id
|
||||
)
|
||||
except Error as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning, step_id
|
||||
)
|
||||
except RuntimeError as ex:
|
||||
return None, self.handle_step_exception(
|
||||
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -145,12 +162,11 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle initial user-triggered config step."""
|
||||
hass = self.hass
|
||||
schema = create_schema(user_input)
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=schema,
|
||||
data_schema=STEP_SCHEMA,
|
||||
errors={},
|
||||
description_placeholders={},
|
||||
)
|
||||
@@ -173,36 +189,60 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
|
||||
)
|
||||
try:
|
||||
product = await Box.async_from_host(api_host)
|
||||
|
||||
except UnsupportedBoxVersion as ex:
|
||||
return self.handle_step_exception(
|
||||
"user",
|
||||
ex,
|
||||
schema,
|
||||
host,
|
||||
port,
|
||||
UNSUPPORTED_VERSION,
|
||||
_LOGGER.debug,
|
||||
)
|
||||
except UnauthorizedRequest as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error
|
||||
)
|
||||
|
||||
except Error as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, CANNOT_CONNECT, _LOGGER.warning
|
||||
)
|
||||
|
||||
except RuntimeError as ex:
|
||||
return self.handle_step_exception(
|
||||
"user", ex, schema, host, port, UNKNOWN, _LOGGER.error
|
||||
)
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="user"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
|
||||
# Check if configured but IP changed since
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=product.name, data=user_input)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of a BleBox device."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_SCHEMA, reconfigure_entry.data
|
||||
),
|
||||
)
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
websession = get_maybe_authenticated_session(self.hass, password, username)
|
||||
api_host = ApiHost(
|
||||
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
|
||||
)
|
||||
|
||||
product, error = await self._async_from_host_or_form(
|
||||
api_host, user_input, step_id="reconfigure"
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
assert product is not None
|
||||
|
||||
await self.async_set_unique_id(product.unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
data_updates: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port}
|
||||
if username is not None:
|
||||
data_updates[CONF_USERNAME] = username
|
||||
if password is not None:
|
||||
data_updates[CONF_PASSWORD] = password
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates=data_updates,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""DataUpdateCoordinator for BleBox devices."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.box import Box
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type BleBoxConfigEntry = ConfigEntry[BleBoxCoordinator]
|
||||
|
||||
|
||||
class BleBoxCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator for a single BleBox device."""
|
||||
|
||||
config_entry: BleBoxConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: BleBoxConfigEntry, box: Box
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=5),
|
||||
)
|
||||
self.box = box
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from the BleBox device."""
|
||||
try:
|
||||
await self.box.async_update_data()
|
||||
except Error as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="data_update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
@@ -17,7 +17,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .coordinator import BleBoxCoordinator
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BLEBOX_TO_COVER_DEVICE_CLASSES = {
|
||||
"gate": CoverDeviceClass.GATE,
|
||||
@@ -59,19 +63,22 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxCoverEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("covers", [])
|
||||
BleBoxCoverEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("covers", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Representation of a BleBox cover feature."""
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.cover.Cover
|
||||
) -> None:
|
||||
"""Initialize a BleBox cover feature."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self._attr_supported_features = (
|
||||
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
)
|
||||
@@ -135,33 +142,40 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
"""Return whether cover is closed."""
|
||||
return self._is_state(CoverState.CLOSED)
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover position."""
|
||||
await self._feature.async_open()
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover position."""
|
||||
await self._feature.async_close()
|
||||
|
||||
@blebox_command
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover tilt."""
|
||||
position = 50 if self._feature.is_tilt_180 else 0
|
||||
await self._feature.async_set_tilt_position(position)
|
||||
|
||||
@blebox_command
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover tilt."""
|
||||
# note: values are reversed
|
||||
await self._feature.async_set_tilt_position(100)
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Set the cover position."""
|
||||
position = kwargs[ATTR_POSITION]
|
||||
await self._feature.async_set_position(100 - position)
|
||||
|
||||
@blebox_command
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self._feature.async_stop()
|
||||
|
||||
@blebox_command
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Set the tilt position."""
|
||||
position = kwargs[ATTR_TILT_POSITION]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -1,23 +1,20 @@
|
||||
"""Base entity for the BleBox devices integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
from blebox_uniapi.feature import Feature
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .coordinator import BleBoxCoordinator
|
||||
|
||||
|
||||
class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
class BleBoxEntity[_FeatureT: Feature](CoordinatorEntity[BleBoxCoordinator]):
|
||||
"""Implements a common class for entities representing a BleBox feature."""
|
||||
|
||||
def __init__(self, feature: _FeatureT) -> None:
|
||||
def __init__(self, coordinator: BleBoxCoordinator, feature: _FeatureT) -> None:
|
||||
"""Initialize a BleBox entity."""
|
||||
super().__init__(coordinator)
|
||||
self._feature = feature
|
||||
self._attr_name = feature.full_name
|
||||
self._attr_unique_id = feature.unique_id
|
||||
@@ -30,10 +27,3 @@ class BleBoxEntity[_FeatureT: Feature](Entity):
|
||||
sw_version=product.firmware_version,
|
||||
configuration_url=f"http://{product.address}",
|
||||
)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity state."""
|
||||
try:
|
||||
await self._feature.async_update()
|
||||
except Error as ex:
|
||||
_LOGGER.error("Updating '%s' failed: %s", self.name, ex)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"""BleBox light entities implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -20,15 +19,18 @@ 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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -37,11 +39,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxLightEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("lights", [])
|
||||
BleBoxLightEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("lights", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
COLOR_MODE_MAP = {
|
||||
@@ -61,9 +64,11 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
_attr_min_color_temp_kelvin = LIGHT_MIN_KELVINS
|
||||
_attr_max_color_temp_kelvin = LIGHT_MAX_KELVINS
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.light.Light) -> None:
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.light.Light
|
||||
) -> None:
|
||||
"""Initialize a BleBox light."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
if feature.effect_list:
|
||||
self._attr_supported_features = LightEntityFeature.EFFECT
|
||||
|
||||
@@ -165,6 +170,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
return None
|
||||
return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex))
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
|
||||
@@ -210,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:
|
||||
@@ -219,11 +227,13 @@ 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
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self._feature.async_off()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
import blebox_uniapi.sensor
|
||||
|
||||
@@ -26,95 +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
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -124,31 +144,35 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSensorEntity(feature, description)
|
||||
for feature in config_entry.runtime_data.features.get("sensors", [])
|
||||
BleBoxSensorEntity(coordinator, feature, description)
|
||||
for feature in coordinator.box.features.get("sensors", [])
|
||||
for description in SENSOR_TYPES
|
||||
if description.key == feature.device_class
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEntity):
|
||||
"""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__(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:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"address_already_configured": "A BleBox device is already configured at {address}.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device identifier does not match the previously configured device."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -11,6 +13,16 @@
|
||||
},
|
||||
"flow_title": "{name} ({host})",
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Update the connection settings for your BleBox device.",
|
||||
"title": "Reconfigure BleBox device"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
@@ -22,5 +34,38 @@
|
||||
"title": "Set up your BleBox device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""BleBox switch implementation."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import blebox_uniapi.switch
|
||||
@@ -11,8 +10,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BleBoxConfigEntry
|
||||
from .entity import BleBoxEntity
|
||||
from .util import blebox_command
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,11 +21,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox switch entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxSwitchEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("switches", [])
|
||||
BleBoxSwitchEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("switches", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity):
|
||||
@@ -38,10 +39,12 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
|
||||
"""Return whether switch is on."""
|
||||
return self._feature.is_on
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self._feature.async_turn_on()
|
||||
|
||||
@blebox_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self._feature.async_turn_off()
|
||||
|
||||
@@ -18,8 +18,11 @@ 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
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(hours=1)
|
||||
|
||||
|
||||
@@ -33,11 +36,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a BleBox update entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entities = [
|
||||
BleBoxUpdateEntity(feature)
|
||||
for feature in config_entry.runtime_data.features.get("updates", [])
|
||||
BleBoxUpdateEntity(coordinator, feature)
|
||||
for feature in coordinator.box.features.get("updates", [])
|
||||
]
|
||||
async_add_entities(entities, True)
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
|
||||
@@ -48,9 +52,16 @@ class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Return True because firmware versions cannot be fetched via coordinator."""
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.update.Update
|
||||
) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(feature)
|
||||
super().__init__(coordinator, feature)
|
||||
self._in_progress_old_version: str | None = None
|
||||
self._poll_cancel: CALLBACK_TYPE | None = None
|
||||
self._poll_attempts: int = 0
|
||||
@@ -76,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
|
||||
@@ -111,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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Utilities for BleBox."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from blebox_uniapi.error import Error
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import BleBoxEntity
|
||||
|
||||
|
||||
def blebox_command[_BleBoxEntityT: BleBoxEntity, **_P, _R](
|
||||
func: Callable[Concatenate[_BleBoxEntityT, _P], Awaitable[_R]],
|
||||
) -> Callable[Concatenate[_BleBoxEntityT, _P], Coroutine[Any, Any, _R]]:
|
||||
"""Decorate BleBox calls that send commands to the device.
|
||||
|
||||
Catches BleBox errors and refreshes the coordinator after the command.
|
||||
"""
|
||||
|
||||
async def handler(self: _BleBoxEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except Error as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
return handler
|
||||
@@ -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.0"
|
||||
"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
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.components.alexa import (
|
||||
entities as alexa_entities,
|
||||
errors as alexa_errors,
|
||||
)
|
||||
from homeassistant.components.frontend import DATA_THEMES
|
||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
@@ -508,6 +509,15 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"custom_integrations": custom_integrations,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _get_themes_info(self, hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Collect information about user-installed custom themes."""
|
||||
themes: dict[str, Any] = hass.data.get(DATA_THEMES, {})
|
||||
return {
|
||||
"count": len(themes),
|
||||
"themes": sorted(themes),
|
||||
}
|
||||
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -569,6 +579,25 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
)
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Add custom themes information
|
||||
try:
|
||||
themes_info = self._get_themes_info(hass)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package generation
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += "Unable to collect themes information\n\n"
|
||||
else:
|
||||
markdown += "## Custom Themes\n\n"
|
||||
markdown += f"Custom themes: {themes_info['count']}\n\n"
|
||||
|
||||
if themes_info["themes"]:
|
||||
markdown += "<details><summary>Custom themes</summary>\n\n"
|
||||
markdown += "Name\n"
|
||||
markdown += "---\n"
|
||||
for theme in themes_info["themes"]:
|
||||
markdown += f"{theme}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
|
||||
"requirements": ["hassil==3.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(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Envertech EVT800 integration."""
|
||||
|
||||
from pyenvertechevt800 import EnvertechEVT800
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
|
||||
type EnvertechEVT800ConfigEntry = ConfigEntry[EnvertechEVT800Coordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Envertech EVT800 from a config entry."""
|
||||
evt800 = EnvertechEVT800(entry.data[CONF_IP_ADDRESS], entry.data[CONF_PORT])
|
||||
evt800.start()
|
||||
|
||||
coordinator = EnvertechEVT800Coordinator(hass, evt800, entry)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: EnvertechEVT800ConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Config flow for the ENVERTECH EVT800 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyenvertechevt800 import EnvertechEVT800
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_TYPE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DEFAULT_PORT, DOMAIN, TYPE_TCP_SERVER_MODE
|
||||
|
||||
SCHEMA_DEVICE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IP_ADDRESS): cv.string,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EnvertechFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Envertech EVT800."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""First step in config flow."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
ip_address = user_input[CONF_IP_ADDRESS]
|
||||
port = user_input[CONF_PORT]
|
||||
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_IP_ADDRESS: ip_address,
|
||||
CONF_PORT: port,
|
||||
}
|
||||
)
|
||||
evt800 = EnvertechEVT800(ip_address, port)
|
||||
|
||||
can_connect = await evt800.test_connection()
|
||||
|
||||
if not can_connect:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title="Envertech EVT800",
|
||||
data={CONF_TYPE: TYPE_TCP_SERVER_MODE, **user_input},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=SCHEMA_DEVICE,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Constants for the ENVERTECH EVT800 integration."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "envertech_evt800"
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
DEFAULT_PORT = 14889
|
||||
TYPE_TCP_SERVER_MODE = ["TCP_SERVER"]
|
||||
DEFAULT_SCAN_INTERVAL = 60
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Coordinator for Envertech EVT800 integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pyenvertechevt800
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import EnvertechEVT800ConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnvertechEVT800Coordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Data update coordinator for Envertech EVT800."""
|
||||
|
||||
config_entry: EnvertechEVT800ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
client: pyenvertechevt800.EnvertechEVT800,
|
||||
config_entry: EnvertechEVT800ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.client = client
|
||||
client.set_data_listener(self.async_set_updated_data)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Fetch data from the device."""
|
||||
return self.client.data
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Envertech EVT800 entity."""
|
||||
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
|
||||
|
||||
class EnvertechEVT800Entity(CoordinatorEntity[EnvertechEVT800Coordinator]):
|
||||
"""Envertech EVT800 entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: EnvertechEVT800Coordinator) -> None:
|
||||
"""Initialize Envertech EVT800 entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
configuration_url=f"http://{coordinator.config_entry.data[CONF_IP_ADDRESS]}/",
|
||||
manufacturer="Envertech",
|
||||
model_id="EVT800",
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.coordinator.client.online
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "envertech_evt800",
|
||||
"name": "ENVERTECH EVT800",
|
||||
"codeowners": ["@daniel-bergmann-00"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/envertech_evt800",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenvertechevt800"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyenvertechevt800==0.2.4"]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not provide any actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
The integration does not have any authentication.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration connects to a single device
|
||||
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not have any own exceptions.
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
The integration does not support repairing issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single device per configuration entry.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
No websession is used
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Envertech EVT800 sensor."""
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import EnvertechEVT800ConfigEntry
|
||||
from .coordinator import EnvertechEVT800Coordinator
|
||||
from .entity import EnvertechEVT800Entity
|
||||
|
||||
SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="id_1",
|
||||
entity_registry_enabled_default=False,
|
||||
translation_key="mppt_id_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="id_2",
|
||||
entity_registry_enabled_default=False,
|
||||
translation_key="mppt_id_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="input_voltage_1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_key="input_voltage_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="input_voltage_2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=2,
|
||||
translation_key="input_voltage_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power_1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_display_precision=0,
|
||||
translation_key="power_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power_2",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
suggested_display_precision=0,
|
||||
translation_key="power_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_1",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_display_precision=2,
|
||||
translation_key="current_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="current_2",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
suggested_display_precision=2,
|
||||
translation_key="current_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_frequency_1",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
translation_key="ac_frequency_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_frequency_2",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=1,
|
||||
translation_key="ac_frequency_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_voltage_1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=0,
|
||||
translation_key="ac_voltage_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="ac_voltage_2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
suggested_display_precision=0,
|
||||
translation_key="ac_voltage_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temperature_1",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
suggested_display_precision=1,
|
||||
translation_key="temperature_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="temperature_2",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
suggested_display_precision=1,
|
||||
translation_key="temperature_2",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="total_energy_1",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_display_precision=2,
|
||||
translation_key="total_energy_1",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="total_energy_2",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
suggested_display_precision=2,
|
||||
translation_key="total_energy_2",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: EnvertechEVT800ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Envertech EVT800 sensors."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
EnvertechEVT800Sensor(coordinator, description) for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class EnvertechEVT800Sensor(EnvertechEVT800Entity, SensorEntity):
|
||||
"""Representation of an Envertech EVT800 sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EnvertechEVT800Coordinator,
|
||||
description: SensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
return self.coordinator.client.data.get(self.entity_description.key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Unavailable if evt800 isn't connected."""
|
||||
return super().available and self.native_value is not None
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ip_address": "The IP address of your Envertech EVT800 device.",
|
||||
"port": "The Port of your Envertech EVT800 device."
|
||||
},
|
||||
"description": "Enter your EVT800 device information.",
|
||||
"title": "Setup EVT800 device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ac_frequency_1": {
|
||||
"name": "AC Frequency MPPT 1"
|
||||
},
|
||||
"ac_frequency_2": {
|
||||
"name": "AC Frequency MPPT 2"
|
||||
},
|
||||
"ac_voltage_1": {
|
||||
"name": "AC Voltage MPPT 1"
|
||||
},
|
||||
"ac_voltage_2": {
|
||||
"name": "AC Voltage MPPT 2"
|
||||
},
|
||||
"current_1": {
|
||||
"name": "DC Current MPPT 1"
|
||||
},
|
||||
"current_2": {
|
||||
"name": "DC Current MPPT 2"
|
||||
},
|
||||
"input_voltage_1": {
|
||||
"name": "DC Voltage MPPT 1"
|
||||
},
|
||||
"input_voltage_2": {
|
||||
"name": "DC Voltage MPPT 2"
|
||||
},
|
||||
"mppt_id_1": {
|
||||
"name": "MPPT ID 1"
|
||||
},
|
||||
"mppt_id_2": {
|
||||
"name": "MPPT ID 2"
|
||||
},
|
||||
"power_1": {
|
||||
"name": "DC Power MPPT 1"
|
||||
},
|
||||
"power_2": {
|
||||
"name": "DC Power MPPT 2"
|
||||
},
|
||||
"temperature_1": {
|
||||
"name": "Temperature MPPT 1"
|
||||
},
|
||||
"temperature_2": {
|
||||
"name": "Temperature MPPT 2"
|
||||
},
|
||||
"total_energy_1": {
|
||||
"name": "Total Energy MPPT 1"
|
||||
},
|
||||
"total_energy_2": {
|
||||
"name": "Total Energy MPPT 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,14 @@ from eq3btsmart import Thermostat
|
||||
from eq3btsmart.exceptions import Eq3Exception
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
|
||||
from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
|
||||
from .models import Eq3Config, Eq3ConfigEntryData
|
||||
|
||||
PLATFORMS = [
|
||||
@@ -49,7 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
|
||||
|
||||
if device is None:
|
||||
raise ConfigEntryNotReady(
|
||||
f"[{eq3_config.mac_address}] Device could not be found"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={
|
||||
"mac_address": eq3_config.mac_address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
mac_address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
thermostat = Thermostat(device)
|
||||
|
||||
@@ -61,5 +61,10 @@
|
||||
"name": "Lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_not_found": {
|
||||
"message": "[{mac_address}] Device could not be found: {reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for entities of the Evohome integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -14,6 +13,7 @@ from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
|
||||
@@ -161,7 +161,7 @@ class EvoChild(EvoEntity):
|
||||
or self._schedule is None
|
||||
or (
|
||||
(until := self._setpoints.get("next_sp_from")) is not None
|
||||
and until < datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
and until < dt_util.utcnow()
|
||||
)
|
||||
): # must use self._setpoints, not self.setpoints
|
||||
await get_schedule()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for (EMEA/EU-based) Honeywell TCC systems."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from evohomeasync.auth import (
|
||||
@@ -12,6 +12,7 @@ from evohomeasync2.auth import AbstractTokenManager
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import STORAGE_KEY, STORAGE_VER
|
||||
|
||||
@@ -91,8 +92,7 @@ class TokenManager(AbstractTokenManager, AbstractSessionManager):
|
||||
|
||||
session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
|
||||
if session_id_expires is None:
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
|
||||
self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15)
|
||||
else:
|
||||
self._session_id_expires = datetime.fromisoformat(session_id_expires)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user