mirror of
https://github.com/home-assistant/core.git
synced 2026-04-09 00:45:19 +00:00
Compare commits
330 Commits
rc
...
remove_dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e702d077f4 | ||
|
|
35314fb9ed | ||
|
|
1b4286381d | ||
|
|
524c2129eb | ||
|
|
8fe09e1837 | ||
|
|
99a186fad7 | ||
|
|
c1a9f293a7 | ||
|
|
783e2f0a00 | ||
|
|
36045c4bd3 | ||
|
|
18cd488622 | ||
|
|
ef66446a0d | ||
|
|
4870bb749c | ||
|
|
2e2ad0aaec | ||
|
|
fa7576dc5a | ||
|
|
c6ec90c871 | ||
|
|
c2065f1f14 | ||
|
|
5197722733 | ||
|
|
49d63892d1 | ||
|
|
713054f9f8 | ||
|
|
0b67644b97 | ||
|
|
f2001db68c | ||
|
|
df08d989f2 | ||
|
|
d5c7a04751 | ||
|
|
3369dfece1 | ||
|
|
7268571587 | ||
|
|
2341f8dd5a | ||
|
|
dd1722b5d6 | ||
|
|
c8667addd8 | ||
|
|
3b9a9ca6cb | ||
|
|
52050711a3 | ||
|
|
17abdd02d3 | ||
|
|
996f9fdca2 | ||
|
|
a434a0ab90 | ||
|
|
7bff0e2f3f | ||
|
|
9cf6911b7f | ||
|
|
b0201e893e | ||
|
|
df74d76ff2 | ||
|
|
6dc391e169 | ||
|
|
c7cf78952e | ||
|
|
2591cf2b3d | ||
|
|
2b1c93724f | ||
|
|
899b776e54 | ||
|
|
423b694a0d | ||
|
|
01324a84a8 | ||
|
|
b63ea35959 | ||
|
|
bb345dfd09 | ||
|
|
c05c2b7f70 | ||
|
|
3d07ec8696 | ||
|
|
3b396814ae | ||
|
|
b2047c1aca | ||
|
|
2b0cff2c93 | ||
|
|
fa7af34678 | ||
|
|
7563ea6217 | ||
|
|
08726af215 | ||
|
|
4fa1d6b0a1 | ||
|
|
3c86f1eee8 | ||
|
|
3a63f9fbb1 | ||
|
|
7b5408d20c | ||
|
|
058e8ba455 | ||
|
|
bba3c0e6bb | ||
|
|
a266976c33 | ||
|
|
f29c051c73 | ||
|
|
8842b4840e | ||
|
|
586d2ceff6 | ||
|
|
69a2284a00 | ||
|
|
19761a25da | ||
|
|
e4328fe34d | ||
|
|
e91b49e7cd | ||
|
|
7d145cd3b8 | ||
|
|
962d5386c7 | ||
|
|
3ba985f771 | ||
|
|
ef6718c242 | ||
|
|
02bcae00cf | ||
|
|
d6cd1dffa4 | ||
|
|
fc32f0dbd3 | ||
|
|
cda1974e40 | ||
|
|
5425e82fb4 | ||
|
|
84f36b0d4d | ||
|
|
0807525e1b | ||
|
|
73a86b8606 | ||
|
|
b8652e70e5 | ||
|
|
a3f3b0bed4 | ||
|
|
daaa68ce22 | ||
|
|
9ada10e0cf | ||
|
|
35287c381b | ||
|
|
2ff84b633c | ||
|
|
c09d91765f | ||
|
|
ac6ddf32c8 | ||
|
|
f15d9e5956 | ||
|
|
f95601a2e7 | ||
|
|
0aef0cc121 | ||
|
|
d1bfd94d33 | ||
|
|
8a9c0f4fde | ||
|
|
3596771af1 | ||
|
|
7b9b457f15 | ||
|
|
cb8597d62f | ||
|
|
c82cfaf633 | ||
|
|
80802c9997 | ||
|
|
971579f021 | ||
|
|
af6b8d4f66 | ||
|
|
e9a61963f2 | ||
|
|
b350712f9e | ||
|
|
51785f10c1 | ||
|
|
24e0627b41 | ||
|
|
6c453c8b49 | ||
|
|
904a2d1b4d | ||
|
|
f3b64dcbe0 | ||
|
|
0edc2cbbab | ||
|
|
751f06eb58 | ||
|
|
9bfac71bd7 | ||
|
|
9499476940 | ||
|
|
eda1eb2e35 | ||
|
|
075e179972 | ||
|
|
99e8066607 | ||
|
|
7ce32f0668 | ||
|
|
dc5547d7b6 | ||
|
|
de98bc7dcf | ||
|
|
a71d48085a | ||
|
|
9e20a13936 | ||
|
|
e164e65217 | ||
|
|
07998de35e | ||
|
|
5253dc11dc | ||
|
|
3f9022cd53 | ||
|
|
073f498c75 | ||
|
|
c5b24e9470 | ||
|
|
c12b7bfd18 | ||
|
|
1c2f583587 | ||
|
|
58a376e68b | ||
|
|
78b251e7cb | ||
|
|
a2c65b9126 | ||
|
|
5e443681c3 | ||
|
|
13756863f1 | ||
|
|
fd54e45aeb | ||
|
|
52af74c3b6 | ||
|
|
dc111a475e | ||
|
|
14cb42349a | ||
|
|
c42b50418e | ||
|
|
501b4e6efb | ||
|
|
ca2099b165 | ||
|
|
69b55c295d | ||
|
|
13709b1c90 | ||
|
|
2c013777db | ||
|
|
91099ea489 | ||
|
|
70cea66e5b | ||
|
|
e78bb97e84 | ||
|
|
732b170190 | ||
|
|
0a05993a4e | ||
|
|
42c3610685 | ||
|
|
4ad73da7ec | ||
|
|
0d14bdab24 | ||
|
|
157362f225 | ||
|
|
1aa380fdfa | ||
|
|
9348948afa | ||
|
|
14b9915914 | ||
|
|
607462028b | ||
|
|
8c07348a3d | ||
|
|
cda52af178 | ||
|
|
d1ccda18f7 | ||
|
|
9fb0b69f0a | ||
|
|
f0848edea9 | ||
|
|
5be12a213d | ||
|
|
20b284d0e9 | ||
|
|
49c3376c95 | ||
|
|
174b5f5593 | ||
|
|
b38e41a34a | ||
|
|
b6350478a5 | ||
|
|
b75af6d84a | ||
|
|
194485d863 | ||
|
|
d6458bc574 | ||
|
|
434f1dca2c | ||
|
|
c6ad6da6ae | ||
|
|
be3d65538d | ||
|
|
297e9e265a | ||
|
|
119dfbddea | ||
|
|
970925141e | ||
|
|
51131beaec | ||
|
|
c509226d17 | ||
|
|
067a9a0c25 | ||
|
|
d10197d535 | ||
|
|
8978d197ca | ||
|
|
afc73fdcfd | ||
|
|
31a24446a8 | ||
|
|
e80caaa7cd | ||
|
|
2b3a504a05 | ||
|
|
a93229bd32 | ||
|
|
99306a75d3 | ||
|
|
3a761116e4 | ||
|
|
a6ec59d6a5 | ||
|
|
ca51123115 | ||
|
|
cfc58bd415 | ||
|
|
a18f3cba32 | ||
|
|
6218741602 | ||
|
|
2285db5bb1 | ||
|
|
738b85c17d | ||
|
|
b7bb185d50 | ||
|
|
f4544cf952 | ||
|
|
beab473dcc | ||
|
|
96891228c9 | ||
|
|
a4a36b5cbd | ||
|
|
4a0a400e22 | ||
|
|
fbe4195ae0 | ||
|
|
116fa57903 | ||
|
|
2399da93db | ||
|
|
3850bb0e57 | ||
|
|
f45c84b2a8 | ||
|
|
a2e60f84da | ||
|
|
3757289c73 | ||
|
|
09067a18b7 | ||
|
|
6eb834946b | ||
|
|
0e1663f259 | ||
|
|
0ba3a94a3b | ||
|
|
3562a3800f | ||
|
|
de0efa1639 | ||
|
|
818cf41c22 | ||
|
|
25bfb16936 | ||
|
|
75782e6f17 | ||
|
|
3e5c291338 | ||
|
|
30163fa2e7 | ||
|
|
16231d8d36 | ||
|
|
0c0d6595d6 | ||
|
|
a443060faa | ||
|
|
9807722077 | ||
|
|
12b485b17e | ||
|
|
45def46a45 | ||
|
|
685b921fe7 | ||
|
|
b813aa213f | ||
|
|
79ec3ff484 | ||
|
|
63ba49ce4c | ||
|
|
85c7bf1dff | ||
|
|
894e9bab0a | ||
|
|
b39c83efd2 | ||
|
|
e855b92b82 | ||
|
|
30ee28a0d3 | ||
|
|
78f6b934bb | ||
|
|
fbef3b27bd | ||
|
|
646f56d015 | ||
|
|
f82d21886a | ||
|
|
f5054d41e1 | ||
|
|
53f64bff49 | ||
|
|
65cb9b8528 | ||
|
|
ecd16d759a | ||
|
|
8498e2a715 | ||
|
|
4fa4ba5ad0 | ||
|
|
a953b697ce | ||
|
|
c543743245 | ||
|
|
5b76fab646 | ||
|
|
6153705b61 | ||
|
|
8632420b8f | ||
|
|
4f89715453 | ||
|
|
8ca8c2191f | ||
|
|
cb43950ccf | ||
|
|
ddfef18183 | ||
|
|
ac65ba7d20 | ||
|
|
d76272d74a | ||
|
|
8e5daeb7dd | ||
|
|
5d7abae490 | ||
|
|
f875c77af0 | ||
|
|
c00a68383c | ||
|
|
5544157d5e | ||
|
|
70aa58913d | ||
|
|
cc363e4ebd | ||
|
|
8d28b399b0 | ||
|
|
fe76fe5408 | ||
|
|
a7de418213 | ||
|
|
e359a8952b | ||
|
|
0a9d4ef138 | ||
|
|
5620cfbfd8 | ||
|
|
fb65cf48c9 | ||
|
|
7fd7b2c203 | ||
|
|
69e691f042 | ||
|
|
f690e6de6a | ||
|
|
ee3c2e6f80 | ||
|
|
5ffe301384 | ||
|
|
e5ad6092d1 | ||
|
|
bd79958d10 | ||
|
|
fe485f853f | ||
|
|
3c67c6087a | ||
|
|
cb7f9b5f49 | ||
|
|
2547563e8c | ||
|
|
213b370693 | ||
|
|
2c9ecb394d | ||
|
|
51a5f5793f | ||
|
|
33f11f2263 | ||
|
|
45069b623c | ||
|
|
5defb4dbff | ||
|
|
bc7c3f0617 | ||
|
|
704c0d1eb0 | ||
|
|
6c864a1725 | ||
|
|
299c6556bb | ||
|
|
f0fc98cb66 | ||
|
|
cd63d14e6f | ||
|
|
30dfd23da8 | ||
|
|
d39ef523b8 | ||
|
|
b6c2fbb8c0 | ||
|
|
758d5469aa | ||
|
|
ea99f88d10 | ||
|
|
0a8f76864c | ||
|
|
ad522d723c | ||
|
|
0f41a311c8 | ||
|
|
412a9a050e | ||
|
|
d5efc3abd5 | ||
|
|
a205623d52 | ||
|
|
8208eecf8c | ||
|
|
f84398eb9c | ||
|
|
aca5adb673 | ||
|
|
f361d01b8b | ||
|
|
d2cef2d26e | ||
|
|
90524e53ec | ||
|
|
668d220400 | ||
|
|
9e28db0535 | ||
|
|
c5807463fd | ||
|
|
f72a9e52f5 | ||
|
|
619582bd03 | ||
|
|
bcc02d7adc | ||
|
|
a9083d5362 | ||
|
|
dd89fa0f5b | ||
|
|
88d0bd5a1d | ||
|
|
a045c2907f | ||
|
|
bcca7655f8 | ||
|
|
269ef5f824 | ||
|
|
c80a9aab71 | ||
|
|
33180a658a | ||
|
|
c5955ada1a | ||
|
|
fd7d936a0d | ||
|
|
84cd137bae | ||
|
|
3a77a638d5 | ||
|
|
599f4f01d0 | ||
|
|
bd298e92d0 | ||
|
|
fabbfd93df | ||
|
|
1ecbc44368 |
228
.claude/agents/raise-pull-request.md
Normal file
228
.claude/agents/raise-pull-request.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
name: raise-pull-request
|
||||
description: |
|
||||
Use this agent when creating a pull request for the Home Assistant core repository after completing implementation work. This agent automates the PR creation process including running tests, formatting checks, and proper checkbox handling.
|
||||
model: inherit
|
||||
color: green
|
||||
tools: Read, Bash, Grep, Glob
|
||||
---
|
||||
|
||||
You are an expert at creating pull requests for the Home Assistant core repository. You will automate the PR creation process with proper verification, formatting, testing, and checkbox handling.
|
||||
|
||||
**Execute each step in order. Do not skip steps.**
|
||||
|
||||
## Step 1: Gather Information
|
||||
|
||||
Run these commands in parallel to analyze the changes:
|
||||
|
||||
```bash
|
||||
# Get current branch and remote
|
||||
git branch --show-current
|
||||
git remote -v | grep push
|
||||
|
||||
# Determine the best available dev reference
|
||||
if git rev-parse --verify --quiet upstream/dev >/dev/null; then
|
||||
BASE_REF="upstream/dev"
|
||||
elif git rev-parse --verify --quiet origin/dev >/dev/null; then
|
||||
BASE_REF="origin/dev"
|
||||
elif git rev-parse --verify --quiet dev >/dev/null; then
|
||||
BASE_REF="dev"
|
||||
else
|
||||
echo "Could not find upstream/dev, origin/dev, or local dev"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE_SHA="$(git merge-base "$BASE_REF" HEAD)"
|
||||
echo "BASE_REF=$BASE_REF"
|
||||
echo "BASE_SHA=$BASE_SHA"
|
||||
|
||||
# Get commit info for this branch vs dev
|
||||
git log "${BASE_SHA}..HEAD" --oneline
|
||||
|
||||
# Check what files changed
|
||||
git diff "${BASE_SHA}..HEAD" --name-only
|
||||
|
||||
# Check if test files were added/modified
|
||||
git diff "${BASE_SHA}..HEAD" --name-only | grep -E "^tests/.*\.py$" || echo "NO_TESTS_CHANGED"
|
||||
|
||||
# Check if manifest.json changed
|
||||
git diff "${BASE_SHA}..HEAD" --name-only | grep "manifest.json" || echo "NO_MANIFEST_CHANGED"
|
||||
```
|
||||
|
||||
From the file paths, extract the **integration domain** from `homeassistant/components/{integration}/` or `tests/components/{integration}/`.
|
||||
|
||||
**Track results:**
|
||||
- `BASE_REF`: the dev reference used for comparison
|
||||
- `BASE_SHA`: the merge-base commit used for diff-based checks
|
||||
- `TESTS_CHANGED`: true if test files were added or modified
|
||||
- `MANIFEST_CHANGED`: true if manifest.json was modified
|
||||
|
||||
**If no suitable dev reference is available, STOP and tell the user to fetch `upstream/dev`, `origin/dev`, or a local `dev` branch before continuing.**
|
||||
|
||||
## Step 2: Run Code Quality Checks
|
||||
|
||||
Run `prek` to perform code quality checks (formatting, linting, hassfest, etc.) on the files changed since `BASE_SHA`:
|
||||
|
||||
```bash
|
||||
prek run --from-ref "$BASE_SHA" --to-ref HEAD
|
||||
```
|
||||
|
||||
**Track results:**
|
||||
- `PREK_PASSED`: true if `prek run` exits with code 0
|
||||
|
||||
**If `prek` fails or is not available, STOP and report the failure to the user. Do not proceed with PR creation. If the failure appears to be an environment setup issue (e.g., missing tools, command not found, venv not activated), also point the user to https://developers.home-assistant.io/docs/development_environment.**
|
||||
|
||||
## Step 3: Stage Any Changes from Checks
|
||||
|
||||
If `prek` made any formatting or generated file changes, stage and commit them as a separate commit:
|
||||
|
||||
```bash
|
||||
git status --porcelain
|
||||
# If changes exist:
|
||||
git add -A
|
||||
git commit -m "Apply prek formatting and generated file updates"
|
||||
```
|
||||
|
||||
## Step 4: Run Tests
|
||||
|
||||
Run pytest for the specific integration:
|
||||
|
||||
```bash
|
||||
pytest tests/components/{integration} \
|
||||
--timeout=60 \
|
||||
--durations-min=1 \
|
||||
--durations=0 \
|
||||
-q
|
||||
```
|
||||
|
||||
**Track results:**
|
||||
- `TESTS_PASSED`: true if pytest exits with code 0
|
||||
|
||||
**If tests fail, STOP and report the failures to the user. Do not proceed with PR creation.**
|
||||
|
||||
## Step 5: Identify PR Metadata
|
||||
|
||||
Write a release-note-style PR title summarizing the change. The title becomes the release notes entry, so it should be a complete sentence fragment describing what changed in imperative mood.
|
||||
|
||||
**PR Title Examples by Type:**
|
||||
| Type | Example titles |
|
||||
|------|----------------|
|
||||
| Bugfix | `Fix Hikvision NVR binary sensors not being detected` |
|
||||
| | `Fix JSON serialization of time objects in anthropic tool results` |
|
||||
| | `Fix config flow bug in Tesla Fleet` |
|
||||
| Dependency | `Bump eheimdigital to 1.5.0` |
|
||||
| | `Bump python-otbr-api to 2.7.1` |
|
||||
| New feature | `Add asyncio-level timeout to Backblaze B2 uploads` |
|
||||
| | `Add Nettleie optimization option` |
|
||||
| Code quality | `Add exception translations to Teslemetry` |
|
||||
| | `Improve test coverage of Tesla Fleet` |
|
||||
| | `Refactor adguard tests to use proper fixtures for mocking` |
|
||||
| | `Simplify entity init in Proxmox` |
|
||||
|
||||
## Step 6: Verify Development Checklist
|
||||
|
||||
Check each item from the [development checklist](https://developers.home-assistant.io/docs/development_checklist/):
|
||||
|
||||
| Item | How to verify |
|
||||
|------|---------------|
|
||||
| External libraries on PyPI | Check manifest.json requirements - all should be PyPI packages |
|
||||
| Dependencies in requirements_all.txt | Only if dependency declarations changed (the `requirements` field in `manifest.json` or `requirements_all.txt`), run `python -m script.gen_requirements_all` |
|
||||
| Codeowners updated | If this is a new integration, ensure its `manifest.json` includes a `codeowners` field with one or more GitHub usernames |
|
||||
| No commented out code | Visually scan the diff for blocks of commented-out code |
|
||||
|
||||
**Track results:**
|
||||
- `NO_COMMENTED_CODE`: true if no blocks of commented-out code found in the diff
|
||||
- `DEPENDENCIES_CHANGED`: true if the diff changes the `requirements` field in `manifest.json` or changes `requirements_all.txt`
|
||||
- `REQUIREMENTS_UPDATED`: true if `DEPENDENCIES_CHANGED` is true and requirements_all.txt was regenerated successfully; not applicable if `DEPENDENCIES_CHANGED` is false
|
||||
- `CHECKLIST_PASSED`: true if all items above pass
|
||||
|
||||
## Step 7: Determine Type of Change
|
||||
|
||||
Select exactly ONE based on the changes. Mark the selected type with `[x]` and all others with `[ ]` (space):
|
||||
|
||||
| Type | Condition |
|
||||
|------|-----------|
|
||||
| Dependency upgrade | Only manifest.json/requirements changes |
|
||||
| Bugfix | Fixes broken behavior, no new features |
|
||||
| New integration | New folder in components/ |
|
||||
| New feature | Adds capability to existing integration |
|
||||
| Deprecation | Adds deprecation warnings for future breaking change |
|
||||
| Breaking change | Removes or changes existing functionality |
|
||||
| Code quality | Only refactoring or test additions, no functional change |
|
||||
|
||||
**Track results:**
|
||||
- `CHANGE_TYPE`: the selected type (e.g., "Bugfix", "New feature", "Code quality", etc.)
|
||||
|
||||
**Important:** All seven type options must remain in the PR body. Only the selected type gets `[x]`, all others get `[ ]`.
|
||||
|
||||
## Step 8: Determine Checkbox States
|
||||
|
||||
Based on the verification steps above, determine checkbox states:
|
||||
|
||||
| Checkbox | Condition to tick |
|
||||
|----------|-------------------|
|
||||
| The code change is tested and works locally | Leave unchecked for the contributor to verify manually (this refers to manual testing, not unit tests) |
|
||||
| Local tests pass | Tick only if `TESTS_PASSED` is true |
|
||||
| I understand the code I am submitting and can explain how it works | Leave unchecked for the contributor to review and set manually |
|
||||
| There is no commented out code | Tick only if `NO_COMMENTED_CODE` is true |
|
||||
| Development checklist | Tick only if `CHECKLIST_PASSED` is true |
|
||||
| Perfect PR recommendations | Tick only if the PR affects a single integration or closely related modules, represents one primary type of change, and has a clear, self-contained scope |
|
||||
| Formatted using Ruff | Tick only if `PREK_PASSED` is true |
|
||||
| Tests have been added | Tick only if `TESTS_CHANGED` is true AND the changes exercise new or changed functionality (not only cosmetic test changes) |
|
||||
| Documentation added/updated | Tick if documentation PR created (or not applicable) |
|
||||
| Manifest file fields filled out | Tick if `PREK_PASSED` is true (or not applicable) |
|
||||
| Dependencies in requirements_all.txt | Tick only if `DEPENDENCIES_CHANGED` is false, or if `DEPENDENCIES_CHANGED` is true and `REQUIREMENTS_UPDATED` is true |
|
||||
| Dependency changelog linked | Tick if dependency changelog linked in PR description (or not applicable) |
|
||||
| Any generated code has been carefully reviewed | Leave unchecked for the contributor to review and set manually |
|
||||
|
||||
## Step 9: Breaking Change Section
|
||||
|
||||
**If `CHANGE_TYPE` is NOT "Breaking change" or "Deprecation": REMOVE the entire "## Breaking change" section from the PR body (including the heading).**
|
||||
|
||||
If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking change` section and describe:
|
||||
- What breaks
|
||||
- How users can fix it
|
||||
- Why it was necessary
|
||||
|
||||
## Step 10: Push Branch and Create PR
|
||||
|
||||
```bash
|
||||
# Get branch name and GitHub username
|
||||
BRANCH=$(git branch --show-current)
|
||||
PUSH_REMOTE=$(git config "branch.$BRANCH.remote" 2>/dev/null || git remote | head -1)
|
||||
GITHUB_USER=$(gh api user --jq .login 2>/dev/null || git remote get-url "$PUSH_REMOTE" | sed -E 's#.*[:/]([^/]+)/([^/]+)(\.git)?$#\1#')
|
||||
|
||||
# Create PR (gh pr create pushes the branch automatically)
|
||||
gh pr create --repo home-assistant/core --base dev \
|
||||
--head "$GITHUB_USER:$BRANCH" \
|
||||
--title "TITLE_HERE" \
|
||||
--body "$(cat <<'EOF'
|
||||
BODY_HERE
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### PR Body Template
|
||||
|
||||
Read the PR template from `.github/PULL_REQUEST_TEMPLATE.md` and use it as the basis for the PR body. **Do not hardcode the template — always read it from the file to stay in sync with upstream changes.**
|
||||
|
||||
Use any HTML comments (`<!-- ... -->`) in the template as guidance to understand what to fill in. For the final PR body sent to GitHub, keep the template text intact — do not delete any text from the template unless it explicitly instructs removal (e.g., the breaking change section when not applicable). Then fill in the sections:
|
||||
|
||||
1. **Breaking change section**: If the type is NOT "Breaking change" or "Deprecation", remove the entire `## Breaking change` section (heading and body). Otherwise, describe what breaks, how users can fix it, and why.
|
||||
2. **Proposed change section**: Fill in a description of the change extracted from commit messages.
|
||||
3. **Type of change**: Check exactly ONE checkbox matching the determined type from Step 7. Leave all others unchecked.
|
||||
4. **Additional information**: Fill in any related issue numbers if known.
|
||||
5. **Checklist**: Check boxes based on the conditions in Step 8. Leave manual-verification boxes unchecked for the contributor.
|
||||
|
||||
**Important:** Preserve all template structure, options, and link references exactly as they appear in the file — only modify checkbox states and fill in content sections.
|
||||
|
||||
## Step 11: Report Result
|
||||
|
||||
Provide the user with:
|
||||
1. **PR URL** - The created pull request link
|
||||
2. **Verification Summary** - Which checks passed/failed
|
||||
3. **Unchecked Items** - List any checkboxes left unchecked and why
|
||||
4. **User Action Required** - Remind user to:
|
||||
- Review and set manual-verification checkboxes ("I understand the code..." and "Any generated code...") as applicable
|
||||
- Consider reviewing two other open PRs
|
||||
- Add any related issue numbers if applicable
|
||||
@@ -5,14 +5,6 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
|
||||
|
||||
# Review GitHub Pull Request
|
||||
|
||||
## Preparation:
|
||||
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
|
||||
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
|
||||
- Do NOT attempt any workarounds.
|
||||
- Do NOT proceed with the review.
|
||||
- ALERT about the failure and WAIT for instructions.
|
||||
- This is a hard requirement - no exceptions.
|
||||
|
||||
## 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.
|
||||
|
||||
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -112,7 +112,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
|
||||
56
.github/workflows/ci.yaml
vendored
56
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.4"
|
||||
HA_SHORT_VERSION: "2026.5"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
|
||||
with:
|
||||
extra-args: --all-files zizmor
|
||||
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
key: >-
|
||||
@@ -372,7 +372,7 @@ jobs:
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore uv wheel cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: >-
|
||||
@@ -384,7 +384,7 @@ jobs:
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -484,7 +484,7 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -515,7 +515,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -552,7 +552,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -643,7 +643,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -694,7 +694,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -747,7 +747,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -804,7 +804,7 @@ jobs:
|
||||
echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -812,7 +812,7 @@ jobs:
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore mypy cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
@@ -854,7 +854,7 @@ jobs:
|
||||
- base
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -887,7 +887,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -930,7 +930,7 @@ jobs:
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -964,7 +964,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1080,7 +1080,7 @@ jobs:
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1115,7 +1115,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1238,7 +1238,7 @@ jobs:
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1275,7 +1275,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1392,7 +1392,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
@@ -1421,7 +1421,7 @@ jobs:
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
@@ -1455,7 +1455,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore full Python ${{ matrix.python-version }} virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
@@ -1563,7 +1563,7 @@ jobs:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
@@ -1591,7 +1591,7 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -578,6 +578,7 @@ homeassistant.components.trmnl.*
|
||||
homeassistant.components.tts.*
|
||||
homeassistant.components.twentemilieu.*
|
||||
homeassistant.components.unifi.*
|
||||
homeassistant.components.unifi_access.*
|
||||
homeassistant.components.unifiprotect.*
|
||||
homeassistant.components.upcloud.*
|
||||
homeassistant.components.update.*
|
||||
|
||||
16
CODEOWNERS
generated
16
CODEOWNERS
generated
@@ -222,8 +222,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/binary_sensor/ @home-assistant/core
|
||||
/tests/components/binary_sensor/ @home-assistant/core
|
||||
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
|
||||
/homeassistant/components/blebox/ @bbx-a @swistakm
|
||||
/tests/components/blebox/ @bbx-a @swistakm
|
||||
/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx
|
||||
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
|
||||
/homeassistant/components/blink/ @fronzbot
|
||||
/tests/components/blink/ @fronzbot
|
||||
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
|
||||
@@ -739,8 +739,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/honeywell/ @rdfurman @mkmer
|
||||
/homeassistant/components/hr_energy_qube/ @MattieGit
|
||||
/tests/components/hr_energy_qube/ @MattieGit
|
||||
/homeassistant/components/html5/ @alexyao2015
|
||||
/tests/components/html5/ @alexyao2015
|
||||
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
|
||||
/tests/components/html5/ @alexyao2015 @tr4nt0r
|
||||
/homeassistant/components/http/ @home-assistant/core
|
||||
/tests/components/http/ @home-assistant/core
|
||||
/homeassistant/components/huawei_lte/ @scop @fphammerle
|
||||
@@ -1226,12 +1226,12 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/onewire/ @garbled1 @epenet
|
||||
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
|
||||
/tests/components/onkyo/ @arturpragacz @eclair4151
|
||||
/homeassistant/components/onvif/ @hunterjm @jterrace
|
||||
/tests/components/onvif/ @hunterjm @jterrace
|
||||
/homeassistant/components/onvif/ @jterrace
|
||||
/tests/components/onvif/ @jterrace
|
||||
/homeassistant/components/open_meteo/ @frenck
|
||||
/tests/components/open_meteo/ @frenck
|
||||
/homeassistant/components/open_router/ @joostlek
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/open_router/ @joostlek @ab3lson
|
||||
/tests/components/open_router/ @joostlek @ab3lson
|
||||
/homeassistant/components/opendisplay/ @g4bri3lDev
|
||||
/tests/components/opendisplay/ @g4bri3lDev
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
|
||||
@@ -470,7 +470,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
|
||||
translation.async_setup(hass)
|
||||
|
||||
recovery = hass.config.recovery_mode
|
||||
device_registry.async_setup(hass)
|
||||
device_registry.async_get(hass)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
create_eager_task(get_internal_store_manager(hass).async_initialize()),
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The actiontec component."""
|
||||
"""The Actiontec integration."""
|
||||
|
||||
@@ -13,6 +13,9 @@ from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
device_registry as dr,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||
@@ -25,11 +28,17 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AladdinConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Aladdin Connect Genie from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"close_door_failed": {
|
||||
"message": "Failed to close the garage door"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"open_door_failed": {
|
||||
"message": "Failed to open the garage door"
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from adext import AdExt
|
||||
from alarmdecoder.devices import SerialDevice, SocketDevice
|
||||
from alarmdecoder.devices import Device, SerialDevice, SocketDevice
|
||||
from alarmdecoder.util import NoDeviceError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -102,16 +102,21 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._async_current_entries(), user_input, self.protocol
|
||||
):
|
||||
return self.async_abort(reason="already_configured")
|
||||
connection = {}
|
||||
connection: dict[str, Any] = {}
|
||||
baud = None
|
||||
device: Device
|
||||
if self.protocol == PROTOCOL_SOCKET:
|
||||
host = connection[CONF_HOST] = user_input[CONF_HOST]
|
||||
port = connection[CONF_PORT] = user_input[CONF_PORT]
|
||||
title = f"{host}:{port}"
|
||||
host = connection[CONF_HOST] = cast(str, user_input[CONF_HOST])
|
||||
port = connection[CONF_PORT] = cast(int, user_input[CONF_PORT])
|
||||
title: str = f"{host}:{port}"
|
||||
device = SocketDevice(interface=(host, port))
|
||||
if self.protocol == PROTOCOL_SERIAL:
|
||||
path = connection[CONF_DEVICE_PATH] = user_input[CONF_DEVICE_PATH]
|
||||
baud = connection[CONF_DEVICE_BAUD] = user_input[CONF_DEVICE_BAUD]
|
||||
path = connection[CONF_DEVICE_PATH] = cast(
|
||||
str, user_input[CONF_DEVICE_PATH]
|
||||
)
|
||||
baud = connection[CONF_DEVICE_BAUD] = cast(
|
||||
int, user_input[CONF_DEVICE_BAUD]
|
||||
)
|
||||
title = path
|
||||
device = SerialDevice(interface=path)
|
||||
|
||||
@@ -132,6 +137,7 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception during AlarmDecoder setup")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
schema: vol.Schema
|
||||
if self.protocol == PROTOCOL_SOCKET:
|
||||
schema = vol.Schema(
|
||||
{
|
||||
|
||||
@@ -45,9 +45,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
try:
|
||||
await client.models.list(timeout=10.0)
|
||||
except anthropic.AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"message": err.message
|
||||
if isinstance(err, anthropic.APIError)
|
||||
else str(err)
|
||||
},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -60,7 +61,7 @@ class AnthropicTaskEntity(
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
raise HomeAssistantError(
|
||||
"Last content in chat log is not an AssistantContent"
|
||||
translation_domain=DOMAIN, translation_key="response_not_found"
|
||||
)
|
||||
|
||||
text = chat_log.content[-1].content or ""
|
||||
@@ -78,7 +79,9 @@ class AnthropicTaskEntity(
|
||||
err,
|
||||
text,
|
||||
)
|
||||
raise HomeAssistantError("Error with Claude structured response") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="json_parse_error"
|
||||
) from err
|
||||
|
||||
return ai_task.GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
|
||||
@@ -71,6 +71,16 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-0",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
64
homeassistant/components/anthropic/diagnostics.py
Normal file
64
homeassistant/components/anthropic/diagnostics.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Diagnostics support for Anthropic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from anthropic import __title__, __version__
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_API_KEY,
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
return {
|
||||
"client": f"{__title__}=={__version__}",
|
||||
"title": entry.title,
|
||||
"entry_id": entry.entry_id,
|
||||
"entry_version": f"{entry.version}.{entry.minor_version}",
|
||||
"state": entry.state.value,
|
||||
"data": async_redact_data(entry.data, TO_REDACT),
|
||||
"options": async_redact_data(entry.options, TO_REDACT),
|
||||
"subentries": {
|
||||
subentry.subentry_id: {
|
||||
"title": subentry.title,
|
||||
"subentry_type": subentry.subentry_type,
|
||||
"data": async_redact_data(subentry.data, TO_REDACT),
|
||||
}
|
||||
for subentry in entry.subentries.values()
|
||||
},
|
||||
"entities": {
|
||||
entity_entry.entity_id: entity_entry.extended_dict
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
er.async_get(hass), entry.entry_id
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -19,6 +19,8 @@ from anthropic.types import (
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
CodeExecutionTool20250825Param,
|
||||
CodeExecutionToolResultBlock,
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
Container,
|
||||
ContentBlockParam,
|
||||
DocumentBlockParam,
|
||||
@@ -61,15 +63,16 @@ from anthropic.types import (
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchTool20260209Param,
|
||||
WebSearchToolResultBlock,
|
||||
WebSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.bash_code_execution_tool_result_block_param import (
|
||||
Content as BashCodeExecutionToolResultContentParam,
|
||||
Content as BashCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
|
||||
Content as TextEditorCodeExecutionToolResultContentParam,
|
||||
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
@@ -105,6 +108,7 @@ from .const import (
|
||||
MIN_THINKING_BUDGET,
|
||||
NON_ADAPTIVE_THINKING_MODELS,
|
||||
NON_THINKING_MODELS,
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
|
||||
)
|
||||
|
||||
@@ -224,12 +228,22 @@ def _convert_content(
|
||||
},
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "code_execution":
|
||||
tool_result_block = {
|
||||
"type": "code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "bash_code_execution":
|
||||
tool_result_block = {
|
||||
"type": "bash_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
BashCodeExecutionToolResultContentParam, content.tool_result
|
||||
BashCodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "text_editor_code_execution":
|
||||
@@ -237,7 +251,7 @@ def _convert_content(
|
||||
"type": "text_editor_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
TextEditorCodeExecutionToolResultContentParam,
|
||||
TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
@@ -368,6 +382,7 @@ def _convert_content(
|
||||
name=cast(
|
||||
Literal[
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
],
|
||||
@@ -379,6 +394,7 @@ def _convert_content(
|
||||
and tool_call.tool_name
|
||||
in [
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
]
|
||||
@@ -401,7 +417,11 @@ def _convert_content(
|
||||
messages[-1]["content"] = messages[-1]["content"][0]["text"]
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
|
||||
raise HomeAssistantError("Unexpected content type in chat log")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_chat_log_content",
|
||||
translation_placeholders={"type": type(content).__name__},
|
||||
)
|
||||
|
||||
return messages, container_id
|
||||
|
||||
@@ -443,7 +463,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if stream is None or not hasattr(stream, "__aiter__"):
|
||||
raise HomeAssistantError("Expected a stream of messages")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
|
||||
)
|
||||
|
||||
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
|
||||
current_tool_args: str
|
||||
@@ -464,7 +486,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input={},
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
@@ -526,13 +548,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input={},
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(
|
||||
response.content_block,
|
||||
(
|
||||
WebSearchToolResultBlock,
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
),
|
||||
@@ -588,13 +611,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
current_tool_block = None
|
||||
continue
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_tool_block["input"] = tool_args
|
||||
current_tool_block["input"] |= tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_block["id"],
|
||||
tool_name=current_tool_block["name"],
|
||||
tool_args=tool_args,
|
||||
tool_args=current_tool_block["input"],
|
||||
external=current_tool_block["type"] == "server_tool_use",
|
||||
)
|
||||
]
|
||||
@@ -605,7 +628,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
content_details.container = response.delta.container
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_refusal"
|
||||
)
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
@@ -664,7 +689,9 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise HomeAssistantError("First message must be a system message")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="system_message_not_found"
|
||||
)
|
||||
|
||||
# System prompt with caching enabled
|
||||
system_prompt: list[TextBlockParam] = [
|
||||
@@ -725,19 +752,34 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
]
|
||||
|
||||
if options.get(CONF_CODE_EXECUTION):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
|
||||
if model.startswith(
|
||||
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
|
||||
) or not options.get(CONF_WEB_SEARCH):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if model.startswith(
|
||||
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
|
||||
) or not options.get(CONF_CODE_EXECUTION):
|
||||
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
|
||||
WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
)
|
||||
else:
|
||||
web_search = WebSearchTool20260209Param(
|
||||
name="web_search",
|
||||
type="web_search_20260209",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = {
|
||||
"type": "approximate",
|
||||
@@ -754,7 +796,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
last_message = messages[-1]
|
||||
if last_message["role"] != "user":
|
||||
raise HomeAssistantError(
|
||||
"Last message must be a user message to add attachments"
|
||||
translation_domain=DOMAIN, translation_key="user_message_not_found"
|
||||
)
|
||||
if isinstance(last_message["content"], str):
|
||||
last_message["content"] = [
|
||||
@@ -859,11 +901,19 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
except anthropic.AuthenticationError as err:
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(
|
||||
"Authentication error with Anthropic API, reauthentication required"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={
|
||||
"message": err.message
|
||||
if isinstance(err, anthropic.APIError)
|
||||
else str(err)
|
||||
},
|
||||
) from err
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
@@ -883,15 +933,23 @@ async def async_prepare_files_for_prompt(
|
||||
|
||||
for file_path, mime_type in files:
|
||||
if not file_path.exists():
|
||||
raise HomeAssistantError(f"`{file_path}` does not exist")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="wrong_file_path",
|
||||
translation_placeholders={"file_path": file_path.as_posix()},
|
||||
)
|
||||
|
||||
if mime_type is None:
|
||||
mime_type = guess_file_type(file_path)[0]
|
||||
|
||||
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
|
||||
raise HomeAssistantError(
|
||||
"Only images and PDF are supported by the Anthropic API,"
|
||||
f"`{file_path}` is not an image file or PDF"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="wrong_file_type",
|
||||
translation_placeholders={
|
||||
"file_path": file_path.as_posix(),
|
||||
"mime_type": mime_type or "unknown",
|
||||
},
|
||||
)
|
||||
if mime_type == "image/jpg":
|
||||
mime_type = "image/jpeg"
|
||||
|
||||
@@ -46,7 +46,7 @@ rules:
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
@@ -59,17 +59,11 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
No data updates.
|
||||
docs-examples:
|
||||
status: todo
|
||||
comment: |
|
||||
To give examples of how people use the integration
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: |
|
||||
To write something about what models we support.
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
@@ -88,7 +82,7 @@ rules:
|
||||
comment: |
|
||||
No entities disabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: done
|
||||
|
||||
@@ -161,7 +161,9 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
is None
|
||||
or (subentry := entry.subentries.get(self._current_subentry_id)) is None
|
||||
):
|
||||
raise HomeAssistantError("Subentry not found")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="subentry_not_found"
|
||||
)
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
@@ -190,4 +192,6 @@ async def async_create_fix_flow(
|
||||
"""Create flow."""
|
||||
if issue_id == "model_deprecated":
|
||||
return ModelDeprecatedRepairFlow()
|
||||
raise HomeAssistantError("Unknown issue ID")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="unknown_issue_id"
|
||||
)
|
||||
|
||||
@@ -149,6 +149,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_authentication_error": {
|
||||
"message": "Authentication error with Anthropic API: {message}. Reauthentication required."
|
||||
},
|
||||
"api_error": {
|
||||
"message": "Anthropic API error: {message}."
|
||||
},
|
||||
"api_refusal": {
|
||||
"message": "Potential policy violation detected."
|
||||
},
|
||||
"json_parse_error": {
|
||||
"message": "Error with Claude structured response."
|
||||
},
|
||||
"response_not_found": {
|
||||
"message": "Last content in chat log is not an AssistantContent."
|
||||
},
|
||||
"subentry_not_found": {
|
||||
"message": "Subentry not found."
|
||||
},
|
||||
"system_message_not_found": {
|
||||
"message": "First message must be a system message."
|
||||
},
|
||||
"unexpected_chat_log_content": {
|
||||
"message": "Unexpected content type in chat log: {type}."
|
||||
},
|
||||
"unexpected_stream_object": {
|
||||
"message": "Expected a stream of messages."
|
||||
},
|
||||
"unknown_issue_id": {
|
||||
"message": "Unknown issue ID."
|
||||
},
|
||||
"user_message_not_found": {
|
||||
"message": "Last message must be a user message to add attachments."
|
||||
},
|
||||
"wrong_file_path": {
|
||||
"message": "`{file_path}` does not exist."
|
||||
},
|
||||
"wrong_file_type": {
|
||||
"message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"model_deprecated": {
|
||||
"fix_flow": {
|
||||
|
||||
@@ -30,9 +30,10 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_CREDENTIALS,
|
||||
@@ -42,9 +43,12 @@ from .const import (
|
||||
SIGNAL_CONNECTED,
|
||||
SIGNAL_DISCONNECTED,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
DEFAULT_NAME_TV = "Apple TV"
|
||||
DEFAULT_NAME_HP = "HomePod"
|
||||
|
||||
@@ -77,6 +81,12 @@ DEVICE_EXCEPTIONS = (
|
||||
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Apple TV component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
|
||||
"""Set up a config entry for Apple TV."""
|
||||
manager = AppleTVManager(hass, entry)
|
||||
|
||||
@@ -9,3 +9,5 @@ CONF_START_OFF = "start_off"
|
||||
|
||||
SIGNAL_CONNECTED = "apple_tv_connected"
|
||||
SIGNAL_DISCONNECTED = "apple_tv_disconnected"
|
||||
|
||||
ATTR_TEXT = "text"
|
||||
|
||||
@@ -8,5 +8,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"append_keyboard_text": {
|
||||
"service": "mdi:keyboard"
|
||||
},
|
||||
"clear_keyboard_text": {
|
||||
"service": "mdi:keyboard-off"
|
||||
},
|
||||
"set_keyboard_text": {
|
||||
"service": "mdi:keyboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
homeassistant/components/apple_tv/services.py
Normal file
130
homeassistant/components/apple_tv/services.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Define services for the Apple TV integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyatv.const import KeyboardFocusState
|
||||
from pyatv.exceptions import NotSupportedError, ProtocolError
|
||||
from pyatv.interface import AppleTV as AppleTVInterface
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import ATTR_TEXT, DOMAIN
|
||||
|
||||
SERVICE_SET_KEYBOARD_TEXT = "set_keyboard_text"
|
||||
SERVICE_SET_KEYBOARD_TEXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required(ATTR_TEXT): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_APPEND_KEYBOARD_TEXT = "append_keyboard_text"
|
||||
SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required(ATTR_TEXT): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_CLEAR_KEYBOARD_TEXT = "clear_keyboard_text"
|
||||
SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_atv(call: ServiceCall) -> AppleTVInterface:
|
||||
"""Get the AppleTVInterface for a service call."""
|
||||
entry = service.async_get_config_entry(
|
||||
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
)
|
||||
atv: AppleTVInterface | None = entry.runtime_data.atv
|
||||
if atv is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_connected",
|
||||
)
|
||||
return atv
|
||||
|
||||
|
||||
def _check_keyboard_focus(atv: AppleTVInterface) -> None:
|
||||
"""Check that keyboard is focused on the device."""
|
||||
try:
|
||||
focus_state = atv.keyboard.text_focus_state
|
||||
except NotSupportedError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="keyboard_not_available",
|
||||
) from err
|
||||
if focus_state != KeyboardFocusState.Focused:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="keyboard_not_focused",
|
||||
)
|
||||
|
||||
|
||||
async def _async_set_keyboard_text(call: ServiceCall) -> None:
|
||||
"""Set text in the keyboard input field on an Apple TV."""
|
||||
atv = _get_atv(call)
|
||||
_check_keyboard_focus(atv)
|
||||
try:
|
||||
await atv.keyboard.text_set(call.data[ATTR_TEXT])
|
||||
except ProtocolError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="keyboard_error",
|
||||
) from err
|
||||
|
||||
|
||||
async def _async_append_keyboard_text(call: ServiceCall) -> None:
|
||||
"""Append text to the keyboard input field on an Apple TV."""
|
||||
atv = _get_atv(call)
|
||||
_check_keyboard_focus(atv)
|
||||
try:
|
||||
await atv.keyboard.text_append(call.data[ATTR_TEXT])
|
||||
except ProtocolError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="keyboard_error",
|
||||
) from err
|
||||
|
||||
|
||||
async def _async_clear_keyboard_text(call: ServiceCall) -> None:
|
||||
"""Clear text in the keyboard input field on an Apple TV."""
|
||||
atv = _get_atv(call)
|
||||
_check_keyboard_focus(atv)
|
||||
try:
|
||||
await atv.keyboard.text_clear()
|
||||
except ProtocolError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="keyboard_error",
|
||||
) from err
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the Apple TV integration."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_KEYBOARD_TEXT,
|
||||
_async_set_keyboard_text,
|
||||
schema=SERVICE_SET_KEYBOARD_TEXT_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APPEND_KEYBOARD_TEXT,
|
||||
_async_append_keyboard_text,
|
||||
schema=SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_KEYBOARD_TEXT,
|
||||
_async_clear_keyboard_text,
|
||||
schema=SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA,
|
||||
)
|
||||
31
homeassistant/components/apple_tv/services.yaml
Normal file
31
homeassistant/components/apple_tv/services.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
set_keyboard_text:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: apple_tv
|
||||
text:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
append_keyboard_text:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: apple_tv
|
||||
text:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
clear_keyboard_text:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: apple_tv
|
||||
@@ -69,6 +69,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"keyboard_error": {
|
||||
"message": "An error occurred while sending text to the Apple TV"
|
||||
},
|
||||
"keyboard_not_available": {
|
||||
"message": "Keyboard input is not supported by this device"
|
||||
},
|
||||
"keyboard_not_focused": {
|
||||
"message": "No text input field is currently focused on the Apple TV"
|
||||
},
|
||||
"not_connected": {
|
||||
"message": "Apple TV is not connected"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
@@ -78,5 +92,45 @@
|
||||
"description": "Configure general device settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"append_keyboard_text": {
|
||||
"description": "Appends text to the currently focused text input field on an Apple TV.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]",
|
||||
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]"
|
||||
},
|
||||
"text": {
|
||||
"description": "The text to append.",
|
||||
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::text::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Append keyboard text"
|
||||
},
|
||||
"clear_keyboard_text": {
|
||||
"description": "Clears the text in the currently focused text input field on an Apple TV.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]",
|
||||
"name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Clear keyboard text"
|
||||
},
|
||||
"set_keyboard_text": {
|
||||
"description": "Sets the text in the currently focused text input field on an Apple TV.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"description": "The Apple TV to send text to.",
|
||||
"name": "Apple TV"
|
||||
},
|
||||
"text": {
|
||||
"description": "The text to set.",
|
||||
"name": "Text"
|
||||
}
|
||||
},
|
||||
"name": "Set keyboard text"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from contextlib import AsyncExitStack
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
@@ -54,36 +54,31 @@ async def _run_client(
|
||||
client = runtime_data.client
|
||||
coordinators = runtime_data.coordinators
|
||||
|
||||
def _listen(_: Any) -> None:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_data_updated()
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with timeout(interval):
|
||||
await client.start()
|
||||
async with AsyncExitStack() as stack:
|
||||
async with timeout(interval):
|
||||
await client.start()
|
||||
stack.push_async_callback(client.stop)
|
||||
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
_LOGGER.debug("Client connected %s", client.host)
|
||||
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
await coordinator.state.start()
|
||||
|
||||
with client.listen(_listen):
|
||||
try:
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_connected()
|
||||
await client.process()
|
||||
finally:
|
||||
await client.stop()
|
||||
await stack.enter_async_context(
|
||||
coordinator.async_monitor_client()
|
||||
)
|
||||
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
for coordinator in coordinators.values():
|
||||
coordinator.async_notify_disconnected()
|
||||
await client.process()
|
||||
finally:
|
||||
_LOGGER.debug("Client disconnected %s", client.host)
|
||||
|
||||
except ConnectionFailed:
|
||||
await asyncio.sleep(interval)
|
||||
pass
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception, aborting arcam client")
|
||||
return
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from arcam.fmj import ConnectionFailed
|
||||
from arcam.fmj.client import Client
|
||||
from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -51,7 +53,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
self.client = client
|
||||
self.state = State(client, zone)
|
||||
self.last_update_success = False
|
||||
self.update_in_progress = False
|
||||
|
||||
name = config_entry.title
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
@@ -74,24 +76,34 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data for manual refresh."""
|
||||
try:
|
||||
self.update_in_progress = True
|
||||
await self.state.update()
|
||||
except ConnectionFailed as err:
|
||||
raise UpdateFailed(
|
||||
f"Connection failed during update for zone {self.state.zn}"
|
||||
) from err
|
||||
finally:
|
||||
self.update_in_progress = False
|
||||
|
||||
@callback
|
||||
def async_notify_data_updated(self) -> None:
|
||||
"""Notify that new data has been received from the device."""
|
||||
self.async_set_updated_data(None)
|
||||
def _async_notify_packet(self, packet: ResponsePacket | AmxDuetResponse) -> None:
|
||||
"""Packet callback to detect changes to state."""
|
||||
if (
|
||||
not isinstance(packet, ResponsePacket)
|
||||
or packet.zn != self.state.zn
|
||||
or self.update_in_progress
|
||||
):
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_notify_connected(self) -> None:
|
||||
"""Handle client connected."""
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@callback
|
||||
def async_notify_disconnected(self) -> None:
|
||||
"""Handle client disconnected."""
|
||||
self.last_update_success = False
|
||||
self.async_update_listeners()
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_monitor_client(self) -> AsyncGenerator[None]:
|
||||
"""Monitor a client and state for changes while connected."""
|
||||
async with self.state:
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
try:
|
||||
with self.client.listen(self._async_notify_packet):
|
||||
yield
|
||||
finally:
|
||||
self.hass.async_create_task(self.async_refresh())
|
||||
|
||||
@@ -26,3 +26,8 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
|
||||
if description is not None:
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.client.connected
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["arcam"],
|
||||
"requirements": ["arcam-fmj==1.8.3"],
|
||||
"requirements": ["arcam-fmj==1.8.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -91,7 +91,6 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
|
||||
value_fn=lambda state: (
|
||||
vp.colorspace.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
and vp.colorspace is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The Arris TG2492LG component."""
|
||||
"""The Arris TG2492LG integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The aruba component."""
|
||||
"""The Aruba integration."""
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"services": {
|
||||
"announce": {
|
||||
"description": "Lets an Assist satellite announce a message.",
|
||||
"description": "Lets a satellite announce a message.",
|
||||
"fields": {
|
||||
"media_id": {
|
||||
"description": "The media ID to announce instead of using text-to-speech.",
|
||||
@@ -94,10 +94,10 @@
|
||||
"name": "Preannounce media ID"
|
||||
}
|
||||
},
|
||||
"name": "Announce on satellite"
|
||||
"name": "Announce"
|
||||
},
|
||||
"ask_question": {
|
||||
"description": "Lets an Assist satellite ask a question and get the user's response.",
|
||||
"description": "Asks a question and gets the user's response.",
|
||||
"fields": {
|
||||
"answers": {
|
||||
"description": "Possible answers to the question.",
|
||||
@@ -124,10 +124,10 @@
|
||||
"name": "Question media ID"
|
||||
}
|
||||
},
|
||||
"name": "Ask question on satellite"
|
||||
"name": "Ask question"
|
||||
},
|
||||
"start_conversation": {
|
||||
"description": "Starts a conversation from an Assist satellite.",
|
||||
"description": "Starts a conversation from a satellite.",
|
||||
"fields": {
|
||||
"extra_system_prompt": {
|
||||
"description": "Provide background information to the AI about the request.",
|
||||
@@ -150,13 +150,13 @@
|
||||
"name": "Message"
|
||||
}
|
||||
},
|
||||
"name": "Start conversation on satellite"
|
||||
"name": "Start conversation"
|
||||
}
|
||||
},
|
||||
"title": "Assist satellite",
|
||||
"triggers": {
|
||||
"idle": {
|
||||
"description": "Triggers after one or more Assist satellites become idle after having processed a command.",
|
||||
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -165,7 +165,7 @@
|
||||
"name": "Satellite became idle"
|
||||
},
|
||||
"listening": {
|
||||
"description": "Triggers after one or more Assist satellites start listening for a command from someone.",
|
||||
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -174,7 +174,7 @@
|
||||
"name": "Satellite started listening"
|
||||
},
|
||||
"processing": {
|
||||
"description": "Triggers after one or more Assist satellites start processing a command after having heard it.",
|
||||
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
@@ -183,7 +183,7 @@
|
||||
"name": "Satellite started processing"
|
||||
},
|
||||
"responding": {
|
||||
"description": "Triggers after one or more Assist satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
|
||||
|
||||
@@ -142,6 +142,13 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
# Home Assistant already accepts URL-based client_ids via
|
||||
# IndieAuth without prior registration, which is compatible with
|
||||
# draft-ietf-oauth-client-id-metadata-document. This flag
|
||||
# advertises that support to encourage clients to use it. The
|
||||
# metadata document is not actually fetched as IndieAuth doesn't
|
||||
# require it.
|
||||
"client_id_metadata_document_supported": True,
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The bbox component."""
|
||||
"""The Bbox integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The bitcoin component."""
|
||||
"""The Bitcoin integration."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "blebox",
|
||||
"name": "BleBox devices",
|
||||
"codeowners": ["@bbx-a", "@swistakm"],
|
||||
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/blebox",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The blinksticklight component."""
|
||||
"""The BlinkStick integration."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support for Blinkstick lights."""
|
||||
"""Support for BlinkStick lights."""
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
@@ -40,7 +40,7 @@ def setup_platform(
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up Blinkstick device specified by serial number."""
|
||||
"""Set up BlinkStick device specified by serial number."""
|
||||
|
||||
name = config[CONF_NAME]
|
||||
serial = config[CONF_SERIAL]
|
||||
|
||||
@@ -52,7 +52,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Rotate the access token."""
|
||||
access_tokens.append(hex(_RND.getrandbits(256))[2:])
|
||||
|
||||
async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL)
|
||||
async_track_time_interval(
|
||||
hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True
|
||||
)
|
||||
|
||||
hass.http.register_view(BrandsIntegrationView(hass))
|
||||
hass.http.register_view(BrandsHardwareView(hass))
|
||||
|
||||
@@ -578,13 +578,13 @@ class CalendarEntity(Entity):
|
||||
return STATE_OFF
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
This sets up listeners to handle state transitions for start or end of
|
||||
the current or upcoming event.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
if self._alarm_unsubs is None:
|
||||
self._alarm_unsubs = []
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -760,12 +760,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return CameraCapabilities(frontend_stream_types)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
Schedules async_refresh_providers if support of streams have changed.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
if self.__supports_stream != (
|
||||
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
||||
):
|
||||
|
||||
@@ -11,7 +11,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
|
||||
|
||||
@@ -12,5 +12,7 @@ SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
|
||||
|
||||
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
|
||||
|
||||
DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES)
|
||||
|
||||
# Interval between periodic state polls to catch externally-triggered changes.
|
||||
STATE_POLL_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.components.bluetooth.active_update_coordinator import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import STATE_POLL_INTERVAL
|
||||
from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,6 +51,15 @@ class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
|
||||
)
|
||||
self.title = title
|
||||
|
||||
# The device API couples brightness and dimming time into a
|
||||
# single command (set_brightness_and_dimming_time), so both
|
||||
# values must be tracked here for cross-entity use.
|
||||
self.last_brightness_pct: int = (
|
||||
device.state.brightness_level
|
||||
if device.state.brightness_level is not None
|
||||
else SORTED_BRIGHTNESS_LEVELS[0]
|
||||
)
|
||||
|
||||
@callback
|
||||
def _needs_poll(
|
||||
self,
|
||||
|
||||
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Diagnostics support for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry
|
||||
|
||||
SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"})
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: CasperGlowConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
service_info = bluetooth.async_last_service_info(
|
||||
hass, coordinator.device.address, connectable=True
|
||||
)
|
||||
|
||||
return {
|
||||
"service_info": async_redact_data(
|
||||
service_info.as_dict() if service_info else None,
|
||||
SERVICE_INFO_TO_REDACT,
|
||||
),
|
||||
}
|
||||
@@ -12,6 +12,11 @@
|
||||
"resume": {
|
||||
"default": "mdi:play"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"dimming_time": {
|
||||
"default": "mdi:timer-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
if state.brightness_level is not None:
|
||||
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
|
||||
self.coordinator.last_brightness_pct = state.brightness_level
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
@@ -97,6 +98,7 @@ class CasperGlowLight(CasperGlowEntity, LightEntity):
|
||||
)
|
||||
)
|
||||
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
|
||||
self.coordinator.last_brightness_pct = brightness_pct
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycasperglow"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pycasperglow==1.1.0"]
|
||||
"requirements": ["pycasperglow==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No network discovery.
|
||||
@@ -52,8 +52,10 @@ rules:
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: No applicable device classes for binary_sensor, button, light, or select entities.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
|
||||
92
homeassistant/components/casper_glow/select.py
Normal file
92
homeassistant/components/casper_glow/select.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Casper Glow integration select platform for dimming time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.const import EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DIMMING_TIME_OPTIONS
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CasperGlowConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the select platform for Casper Glow."""
|
||||
async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)])
|
||||
|
||||
|
||||
class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity):
|
||||
"""Select entity for Casper Glow dimming time."""
|
||||
|
||||
_attr_translation_key = "dimming_time"
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_options = list(DIMMING_TIME_OPTIONS)
|
||||
_attr_unit_of_measurement = UnitOfTime.MINUTES
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize the dimming time select entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the currently selected dimming time from the coordinator."""
|
||||
if self.coordinator.last_dimming_time_minutes is None:
|
||||
return None
|
||||
return str(self.coordinator.last_dimming_time_minutes)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last known dimming time and register state update callback."""
|
||||
await super().async_added_to_hass()
|
||||
if self.coordinator.last_dimming_time_minutes is None and (
|
||||
last_state := await self.async_get_last_state()
|
||||
):
|
||||
if last_state.state in DIMMING_TIME_OPTIONS:
|
||||
self.coordinator.last_dimming_time_minutes = int(last_state.state)
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
if state.brightness_level is not None:
|
||||
self.coordinator.last_brightness_pct = state.brightness_level
|
||||
if (
|
||||
state.configured_dimming_time_minutes is not None
|
||||
and self.coordinator.last_dimming_time_minutes is None
|
||||
):
|
||||
self.coordinator.last_dimming_time_minutes = (
|
||||
state.configured_dimming_time_minutes
|
||||
)
|
||||
# Dimming time is not part of the device state
|
||||
# that is provided via BLE update. Therefore
|
||||
# we need to trigger a state update for the select entity
|
||||
# to update the current state.
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the dimming time."""
|
||||
await self._async_command(
|
||||
self._device.set_brightness_and_dimming_time(
|
||||
self.coordinator.last_brightness_pct, int(option)
|
||||
)
|
||||
)
|
||||
self.coordinator.last_dimming_time_minutes = int(option)
|
||||
# Dimming time is not part of the device state
|
||||
# that is provided via BLE update. Therefore
|
||||
# we need to trigger a state update for the select entity
|
||||
# to update the current state.
|
||||
self.async_write_ha_state()
|
||||
@@ -39,6 +39,11 @@
|
||||
"resume": {
|
||||
"name": "Resume dimming"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"dimming_time": {
|
||||
"name": "Dimming time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -9,7 +9,6 @@ from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import CLIMATE
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
@@ -92,7 +91,7 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[ClimateEntity] = []
|
||||
for device in coordinator.data[CLIMATE].values():
|
||||
values = load_api_data(device, CLIMATE_DOMAIN)
|
||||
values = load_api_data(device, "climate")
|
||||
if values[0] == 0 and values[4] == 0:
|
||||
# No climate data, device is only a humidifier/dehumidifier
|
||||
|
||||
@@ -140,7 +139,7 @@ class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
|
||||
def _update_attributes(self) -> None:
|
||||
"""Update class attributes."""
|
||||
device = self.coordinator.data[CLIMATE][self._device.index]
|
||||
values = load_api_data(device, CLIMATE_DOMAIN)
|
||||
values = load_api_data(device, "climate")
|
||||
|
||||
_active = values[1]
|
||||
_mode = values[2] # Values from API: "O", "L", "U"
|
||||
|
||||
@@ -9,7 +9,6 @@ from aiocomelit import ComelitSerialBridgeObject
|
||||
from aiocomelit.const import CLIMATE
|
||||
|
||||
from homeassistant.components.humidifier import (
|
||||
DOMAIN as HUMIDIFIER_DOMAIN,
|
||||
MODE_AUTO,
|
||||
MODE_NORMAL,
|
||||
HumidifierAction,
|
||||
@@ -68,7 +67,7 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[ComelitHumidifierEntity] = []
|
||||
for device in coordinator.data[CLIMATE].values():
|
||||
values = load_api_data(device, HUMIDIFIER_DOMAIN)
|
||||
values = load_api_data(device, "humidifier")
|
||||
if values[0] == 0 and values[4] == 0:
|
||||
# No humidity data, device is only a climate
|
||||
|
||||
@@ -142,7 +141,7 @@ class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
|
||||
def _update_attributes(self) -> None:
|
||||
"""Update class attributes."""
|
||||
device = self.coordinator.data[CLIMATE][self._device.index]
|
||||
values = load_api_data(device, HUMIDIFIER_DOMAIN)
|
||||
values = load_api_data(device, "humidifier")
|
||||
|
||||
_active = values[1]
|
||||
_mode = values[2] # Values from API: "O", "L", "U"
|
||||
|
||||
@@ -113,9 +113,6 @@
|
||||
"humidity_while_off": {
|
||||
"message": "Cannot change humidity while off"
|
||||
},
|
||||
"invalid_clima_data": {
|
||||
"message": "Invalid 'clima' data"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Failed to update data: {error}"
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Concatenate
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, Literal
|
||||
|
||||
from aiocomelit.api import ComelitSerialBridgeObject
|
||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
|
||||
from aiohttp import ClientSession, CookieJar
|
||||
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -30,17 +29,19 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
)
|
||||
|
||||
|
||||
def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]:
|
||||
def load_api_data(
|
||||
device: ComelitSerialBridgeObject,
|
||||
domain: Literal["climate", "humidifier"],
|
||||
) -> list[Any]:
|
||||
"""Load data from the API."""
|
||||
# This function is called when the data is loaded from the API
|
||||
if not isinstance(device.val, list):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=domain, translation_key="invalid_clima_data"
|
||||
)
|
||||
# This function is called when the data is loaded from the API.
|
||||
# For climate and humidifier device.val is always a list.
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(device.val, list)
|
||||
# CLIMATE has a 2 item tuple:
|
||||
# - first for Clima
|
||||
# - second for Humidifier
|
||||
return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1]
|
||||
return device.val[0] if domain == "climate" else device.val[1]
|
||||
|
||||
|
||||
async def cleanup_stale_entity(
|
||||
|
||||
@@ -210,7 +210,7 @@ def websocket_update_entity(
|
||||
)
|
||||
return
|
||||
|
||||
changes = {}
|
||||
changes: dict[str, Any] = {}
|
||||
|
||||
for key in (
|
||||
"area_id",
|
||||
|
||||
@@ -44,18 +44,18 @@ class DemoRemote(RemoteEntity):
|
||||
return {"last_command_sent": self._last_command_sent}
|
||||
return None
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote on."""
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the remote off."""
|
||||
self._attr_is_on = False
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send a command to a device."""
|
||||
for com in command:
|
||||
self._last_command_sent = com
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -61,12 +61,12 @@ class DemoSwitch(SwitchEntity):
|
||||
name=device_name,
|
||||
)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
self._attr_is_on = True
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
self._attr_is_on = False
|
||||
self.schedule_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The denon component."""
|
||||
"""The Denon Network Receivers integration."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The denonavr component."""
|
||||
"""The Denon AVR Network Receivers integration."""
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from devolo_home_control_api.devices.zwave import Zwave
|
||||
from devolo_home_control_api.homecontrol import HomeControl
|
||||
|
||||
@@ -188,6 +190,8 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
|
||||
def sync_callback(self, message: tuple) -> None:
|
||||
"""Update the consumption sensor state."""
|
||||
if message[0] == self._attr_unique_id:
|
||||
if TYPE_CHECKING:
|
||||
assert self._attr_unique_id is not None
|
||||
self._value = getattr(
|
||||
self._device_instance.consumption_property[self._attr_unique_id],
|
||||
self._sensor_type,
|
||||
|
||||
@@ -353,10 +353,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
|
||||
# Device was de/re-connected, state might have changed
|
||||
self.async_write_ha_state()
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state."""
|
||||
self._attr_supported_features = self._supported_features()
|
||||
super().async_write_ha_state()
|
||||
super()._async_write_ha_state()
|
||||
|
||||
async def _device_connect(self, location: str) -> None:
|
||||
"""Connect to the device now that it's available."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The dnsip component."""
|
||||
"""The DNS IP integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload dnsip config entry."""
|
||||
"""Unload DNS IP config entry."""
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def _fix_device_registry_identifiers(
|
||||
if old_identifier not in device_entry.identifiers: # type: ignore[comparison-overlap]
|
||||
continue
|
||||
new_identifiers = device_entry.identifiers.copy()
|
||||
new_identifiers.discard(old_identifier) # type: ignore[arg-type]
|
||||
new_identifiers.discard(old_identifier)
|
||||
new_identifiers.add((DOMAIN, entry.data["station"]))
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, new_identifiers=new_identifiers
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The ebox component."""
|
||||
"""The EBox integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The edimax component."""
|
||||
"""The Edimax integration."""
|
||||
|
||||
@@ -273,7 +273,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
continue
|
||||
|
||||
# Build kwargs common to both modes
|
||||
kwargs = base_stream_params | {
|
||||
kwargs: dict[str, Any] = base_stream_params | {
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> boo
|
||||
|
||||
elk_temp_unit = elk.panel.temperature_units
|
||||
if elk_temp_unit == "C":
|
||||
temperature_unit = UnitOfTemperature.CELSIUS
|
||||
temperature_unit = UnitOfTemperature.CELSIUS # type: ignore[unreachable]
|
||||
else:
|
||||
temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
config["temperature_unit"] = temperature_unit
|
||||
|
||||
@@ -361,7 +361,8 @@ class EvoController(EvoClimateEntity):
|
||||
async def async_tcs_svc_request(self, service: str, data: dict[str, Any]) -> None:
|
||||
"""Process a service request (system mode) for a controller.
|
||||
|
||||
Data validation is not required, it will have been done upstream.
|
||||
Data validation is not required here; it is performed upstream by the service
|
||||
handler (service schema plus runtime checks).
|
||||
"""
|
||||
|
||||
if service == EvoService.RESET_SYSTEM:
|
||||
@@ -387,9 +388,16 @@ class EvoController(EvoClimateEntity):
|
||||
) -> None:
|
||||
"""Set a Controller to any of its native operating modes."""
|
||||
until = dt_util.as_utc(until) if until else None
|
||||
await self.coordinator.call_client_api(
|
||||
self._evo_device.set_mode(mode, until=until)
|
||||
)
|
||||
try:
|
||||
await self.coordinator.call_client_api(
|
||||
self._evo_device.set_mode(mode, until=until)
|
||||
)
|
||||
except evo.InvalidSystemModeError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_system_mode",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
|
||||
@@ -139,6 +139,9 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
try:
|
||||
result = await client_api
|
||||
|
||||
except ec2.InvalidSystemModeError:
|
||||
raise
|
||||
|
||||
except ec2.ApiRequestFailedError as err:
|
||||
self.logger.error(err)
|
||||
return None
|
||||
|
||||
@@ -5,17 +5,18 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
from typing import Any, Final
|
||||
|
||||
from evohomeasync2 import ControlSystem
|
||||
from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE
|
||||
from evohomeasync2.schemas.const import (
|
||||
S2_DURATION as SZ_DURATION,
|
||||
S2_PERIOD as SZ_PERIOD,
|
||||
SystemMode as EvoSystemMode,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.service import verify_domain_control
|
||||
@@ -23,8 +24,19 @@ from homeassistant.helpers.service import verify_domain_control
|
||||
from .const import ATTR_DURATION, ATTR_PERIOD, ATTR_SETPOINT, DOMAIN, EvoService
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
|
||||
# system mode schemas are built dynamically when the services are registered
|
||||
# because supported modes can vary for edge-case systems
|
||||
# System service schemas (registered as domain services)
|
||||
SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
|
||||
# unsupported modes are rejected at runtime with ServiceValidationError
|
||||
vol.Required(ATTR_MODE): cv.string, # avoid vol.In(SystemMode)
|
||||
vol.Exclusive(ATTR_DURATION, "temporary"): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
|
||||
),
|
||||
vol.Exclusive(ATTR_PERIOD, "temporary"): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
|
||||
),
|
||||
}
|
||||
|
||||
# Zone service schemas (registered as entity services)
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
|
||||
@@ -59,16 +71,56 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_set_system_mode_params(tcs: ControlSystem, data: dict[str, Any]) -> None:
|
||||
"""Validate that a set_system_mode service call is properly formed."""
|
||||
|
||||
mode = data[ATTR_MODE]
|
||||
tcs_modes = {m[SZ_SYSTEM_MODE]: m for m in tcs.allowed_system_modes}
|
||||
|
||||
# Validation occurs here, instead of in the library, because it uses a slightly
|
||||
# different schema (until instead of duration/period) for the method invoked
|
||||
# via this service call
|
||||
|
||||
if (mode_info := tcs_modes.get(mode)) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mode_not_supported",
|
||||
translation_placeholders={ATTR_MODE: mode},
|
||||
)
|
||||
|
||||
# voluptuous schema ensures that duration and period are not both present
|
||||
|
||||
if not mode_info[SZ_CAN_BE_TEMPORARY]:
|
||||
if ATTR_DURATION in data or ATTR_PERIOD in data:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mode_cant_be_temporary",
|
||||
translation_placeholders={ATTR_MODE: mode},
|
||||
)
|
||||
return
|
||||
|
||||
timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary
|
||||
|
||||
if timing_mode == SZ_DURATION and ATTR_PERIOD in data:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mode_cant_have_period",
|
||||
translation_placeholders={ATTR_MODE: mode},
|
||||
)
|
||||
|
||||
if timing_mode == SZ_PERIOD and ATTR_DURATION in data:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mode_cant_have_duration",
|
||||
translation_placeholders={ATTR_MODE: mode},
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def setup_service_functions(
|
||||
hass: HomeAssistant, coordinator: EvoDataUpdateCoordinator
|
||||
) -> None:
|
||||
"""Set up the service handlers for the system/zone operating modes.
|
||||
|
||||
Not all Honeywell TCC-compatible systems support all operating modes. In addition,
|
||||
each mode will require any of four distinct service schemas. This has to be
|
||||
enumerated before registering the appropriate handlers.
|
||||
"""
|
||||
"""Set up the service handlers for Evohome systems."""
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
async def force_refresh(call: ServiceCall) -> None:
|
||||
@@ -77,7 +129,14 @@ def setup_service_functions(
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
async def set_system_mode(call: ServiceCall) -> None:
|
||||
"""Set the system mode."""
|
||||
"""Set the Evohome system mode or reset the system."""
|
||||
|
||||
# No additional validation for RESET_SYSTEM here, as the library method invoked
|
||||
# via that service call may be able to emulate the reset even if the system
|
||||
# doesn't support AutoWithReset natively
|
||||
|
||||
if call.service == EvoService.SET_SYSTEM_MODE:
|
||||
_validate_set_system_mode_params(coordinator.tcs, call.data)
|
||||
|
||||
payload = {
|
||||
"unique_id": coordinator.tcs.id,
|
||||
@@ -86,59 +145,14 @@ def setup_service_functions(
|
||||
}
|
||||
async_dispatcher_send(hass, DOMAIN, payload)
|
||||
|
||||
assert coordinator.tcs is not None # mypy
|
||||
|
||||
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
|
||||
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
|
||||
|
||||
# Enumerate which operating modes are supported by this system
|
||||
modes = list(coordinator.tcs.allowed_system_modes)
|
||||
|
||||
system_mode_schemas = []
|
||||
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET]
|
||||
|
||||
# Permanent-only modes will use this schema
|
||||
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]]
|
||||
if perm_modes: # any of: "Auto", "HeatingOff": permanent only
|
||||
schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)})
|
||||
system_mode_schemas.append(schema)
|
||||
|
||||
modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]]
|
||||
|
||||
# These modes are set for a number of hours (or indefinitely): use this schema
|
||||
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_DURATION]
|
||||
if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_MODE): vol.In(temp_modes),
|
||||
vol.Optional(ATTR_DURATION): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
|
||||
),
|
||||
}
|
||||
)
|
||||
system_mode_schemas.append(schema)
|
||||
|
||||
# These modes are set for a number of days (or indefinitely): use this schema
|
||||
temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_PERIOD]
|
||||
if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_MODE): vol.In(temp_modes),
|
||||
vol.Optional(ATTR_PERIOD): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
|
||||
),
|
||||
}
|
||||
)
|
||||
system_mode_schemas.append(schema)
|
||||
|
||||
if system_mode_schemas:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
EvoService.SET_SYSTEM_MODE,
|
||||
set_system_mode,
|
||||
schema=vol.Schema(vol.Any(*system_mode_schemas)),
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
EvoService.SET_SYSTEM_MODE,
|
||||
set_system_mode,
|
||||
schema=vol.Schema(SET_SYSTEM_MODE_SCHEMA),
|
||||
)
|
||||
|
||||
_register_zone_entity_services(hass)
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"invalid_system_mode": {
|
||||
"message": "The requested system mode is not supported: {error}"
|
||||
},
|
||||
"mode_cant_be_temporary": {
|
||||
"message": "The mode `{mode}` does not support `duration` or `period`"
|
||||
},
|
||||
"mode_cant_have_duration": {
|
||||
"message": "The mode `{mode}` does not support `duration`; use `period` instead"
|
||||
},
|
||||
"mode_cant_have_period": {
|
||||
"message": "The mode `{mode}` does not support `period`; use `duration` instead"
|
||||
},
|
||||
"mode_not_supported": {
|
||||
"message": "The mode `{mode}` is not supported by this controller"
|
||||
},
|
||||
"zone_only_service": {
|
||||
"message": "Only zones support the `{service}` action"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The fail2ban component."""
|
||||
"""The Fail2Ban integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The fido component."""
|
||||
"""The Fido integration."""
|
||||
|
||||
@@ -4,9 +4,12 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import FitbitScope
|
||||
from .const import DOMAIN, FitbitScope
|
||||
from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import config_from_entry_data
|
||||
@@ -16,11 +19,17 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool:
|
||||
"""Set up fitbit from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
fitbit_api = api.OAuthFitbitApi(
|
||||
hass, session, unit_system=entry.data.get("unit_system")
|
||||
|
||||
@@ -121,5 +121,10 @@
|
||||
"name": "Water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Fortinet FortiOS components."""
|
||||
"""Fortinet FortiOS integration."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support to use FortiOS device like FortiGate as device tracker.
|
||||
|
||||
This component is part of the device_tracker platform.
|
||||
This FortiOS integration provides a device_tracker platform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .coordinator import (
|
||||
FreshrConfigEntry,
|
||||
@@ -21,10 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo
|
||||
await devices_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
readings: dict[str, FreshrReadingsCoordinator] = {
|
||||
device.id: FreshrReadingsCoordinator(
|
||||
device_id: FreshrReadingsCoordinator(
|
||||
hass, entry, device, devices_coordinator.client
|
||||
)
|
||||
for device in devices_coordinator.data
|
||||
for device_id, device in devices_coordinator.data.items()
|
||||
}
|
||||
await asyncio.gather(
|
||||
*(
|
||||
@@ -38,6 +38,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo
|
||||
readings=readings,
|
||||
)
|
||||
|
||||
known_devices: set[str] = set(readings)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update() -> None:
|
||||
current = set(devices_coordinator.data)
|
||||
removed_ids = known_devices - current
|
||||
if removed_ids:
|
||||
known_devices.difference_update(removed_ids)
|
||||
for device_id in removed_ids:
|
||||
entry.runtime_data.readings.pop(device_id, None)
|
||||
new_ids = current - known_devices
|
||||
if not new_ids:
|
||||
return
|
||||
known_devices.update(new_ids)
|
||||
for device_id in new_ids:
|
||||
device = devices_coordinator.data[device_id]
|
||||
readings_coordinator = FreshrReadingsCoordinator(
|
||||
hass, entry, device, devices_coordinator.client
|
||||
)
|
||||
entry.runtime_data.readings[device_id] = readings_coordinator
|
||||
hass.async_create_task(
|
||||
readings_coordinator.async_refresh(),
|
||||
name=f"freshr_readings_refresh_{device_id}",
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
devices_coordinator.async_add_listener(_handle_coordinator_update)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
@@ -30,22 +30,31 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def _validate_input(self, username: str, password: str) -> str | None:
|
||||
"""Validate credentials, returning an error key or None on success."""
|
||||
client = FreshrClient(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.login(username, password)
|
||||
except LoginError:
|
||||
return "invalid_auth"
|
||||
except ClientError:
|
||||
return "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
client = FreshrClient(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
|
||||
except LoginError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
error = await self._validate_input(
|
||||
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
if error:
|
||||
errors["base"] = error
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -58,6 +67,34 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
error = await self._validate_input(
|
||||
reconfigure_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
if error:
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={
|
||||
CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, _user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
@@ -72,18 +109,11 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
client = FreshrClient(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.login(
|
||||
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except LoginError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
error = await self._validate_input(
|
||||
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
if error:
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -32,7 +33,7 @@ class FreshrData:
|
||||
type FreshrConfigEntry = ConfigEntry[FreshrData]
|
||||
|
||||
|
||||
class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]):
|
||||
"""Coordinator that refreshes the device list once an hour."""
|
||||
|
||||
config_entry: FreshrConfigEntry
|
||||
@@ -48,7 +49,7 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
)
|
||||
self.client = FreshrClient(session=async_create_clientsession(hass))
|
||||
|
||||
async def _async_update_data(self) -> list[DeviceSummary]:
|
||||
async def _async_update_data(self) -> dict[str, DeviceSummary]:
|
||||
"""Fetch the list of devices from the Fresh-r API."""
|
||||
username = self.config_entry.data[CONF_USERNAME]
|
||||
password = self.config_entry.data[CONF_PASSWORD]
|
||||
@@ -68,8 +69,23 @@ class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
else:
|
||||
return devices
|
||||
|
||||
current = {device.id: device for device in devices}
|
||||
|
||||
if self.data is not None:
|
||||
stale_ids = set(self.data) - set(current)
|
||||
if stale_ids:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in stale_ids:
|
||||
if device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, device_id)}
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
return current
|
||||
|
||||
|
||||
class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):
|
||||
|
||||
34
homeassistant/components/freshr/diagnostics.py
Normal file
34
homeassistant/components/freshr/diagnostics.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Diagnostics support for Fresh-r."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FreshrConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: FreshrConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"devices": [
|
||||
dataclasses.asdict(device) for device in runtime_data.devices.data.values()
|
||||
],
|
||||
"readings": {
|
||||
device_id: dataclasses.asdict(coordinator.data)
|
||||
if coordinator.data is not None
|
||||
else None
|
||||
for device_id, coordinator in runtime_data.readings.items()
|
||||
},
|
||||
}
|
||||
@@ -41,11 +41,13 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration connects to a cloud service; no local network discovery is possible.
|
||||
discovery: todo
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No local network discovery of devices is possible (no zeroconf, mdns or other discovery mechanisms).
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
@@ -53,18 +55,18 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
|
||||
stale-devices: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -112,26 +112,43 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fresh-r sensors from a config entry."""
|
||||
entities: list[FreshrSensor] = []
|
||||
for device in config_entry.runtime_data.devices.data:
|
||||
descriptions = SENSOR_TYPES.get(
|
||||
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
|
||||
)
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
|
||||
serial_number=device.id,
|
||||
manufacturer="Fresh-r",
|
||||
)
|
||||
entities.extend(
|
||||
FreshrSensor(
|
||||
config_entry.runtime_data.readings[device.id],
|
||||
description,
|
||||
device_info,
|
||||
coordinator = config_entry.runtime_data.devices
|
||||
known_devices: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _check_devices() -> None:
|
||||
current = set(coordinator.data)
|
||||
removed_ids = known_devices - current
|
||||
if removed_ids:
|
||||
known_devices.difference_update(removed_ids)
|
||||
new_ids = current - known_devices
|
||||
if not new_ids:
|
||||
return
|
||||
known_devices.update(new_ids)
|
||||
entities: list[FreshrSensor] = []
|
||||
for device_id in new_ids:
|
||||
device = coordinator.data[device_id]
|
||||
descriptions = SENSOR_TYPES.get(
|
||||
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
|
||||
)
|
||||
for description in descriptions
|
||||
)
|
||||
async_add_entities(entities)
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
|
||||
serial_number=device_id,
|
||||
manufacturer="Fresh-r",
|
||||
)
|
||||
entities.extend(
|
||||
FreshrSensor(
|
||||
config_entry.runtime_data.readings[device_id],
|
||||
description,
|
||||
device_info,
|
||||
)
|
||||
for description in descriptions
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
_check_devices()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_devices))
|
||||
|
||||
|
||||
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -19,6 +20,15 @@
|
||||
},
|
||||
"description": "Re-enter the password for your Fresh-r account `{username}`."
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::freshr::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Update the password for your Fresh-r account `{username}`."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -10,11 +10,9 @@ from requests.exceptions import RequestException
|
||||
from homeassistant.components.image import ImageEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
|
||||
from .const import DOMAIN, Platform
|
||||
from .coordinator import AvmWrapper, FritzConfigEntry
|
||||
from .entity import FritzBoxBaseEntity
|
||||
|
||||
@@ -24,32 +22,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def _migrate_to_new_unique_id(
|
||||
hass: HomeAssistant, avm_wrapper: AvmWrapper, ssid: str
|
||||
) -> None:
|
||||
"""Migrate old unique id to new unique id."""
|
||||
|
||||
old_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code")
|
||||
new_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code"
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
Platform.IMAGE,
|
||||
DOMAIN,
|
||||
old_unique_id,
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
|
||||
_LOGGER.debug(
|
||||
"Migrating guest Wi-Fi image unique_id from [%s] to [%s]",
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FritzConfigEntry,
|
||||
@@ -62,8 +34,6 @@ async def async_setup_entry(
|
||||
avm_wrapper.fritz_guest_wifi.get_info
|
||||
)
|
||||
|
||||
await _migrate_to_new_unique_id(hass, avm_wrapper, guest_wifi_info["NewSSID"])
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
FritzGuestWifiQRImage(
|
||||
@@ -90,7 +60,7 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
|
||||
) -> None:
|
||||
"""Initialize the image entity."""
|
||||
self._attr_name = ssid
|
||||
self._attr_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code"
|
||||
self._attr_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code")
|
||||
self._current_qr_bytes: bytes | None = None
|
||||
super().__init__(avm_wrapper, device_friendly_name)
|
||||
ImageEntity.__init__(self, hass)
|
||||
|
||||
@@ -34,23 +34,17 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: no known limitations, yet
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: add the known supported devices
|
||||
docs-supported-functions:
|
||||
status: todo
|
||||
comment: need to be overhauled
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases:
|
||||
status: todo
|
||||
comment: need to be overhauled
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
|
||||
@@ -97,7 +97,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
super().__init__(coordinator, ain)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Write the state to the HASS state machine."""
|
||||
if self.data.holiday_active:
|
||||
self._attr_supported_features = ClimateEntityFeature.PRESET_MODE
|
||||
@@ -109,7 +109,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
self._attr_supported_features = SUPPORTED_FEATURES
|
||||
self._attr_hvac_modes = HVAC_MODES
|
||||
self._attr_preset_modes = PRESET_MODES
|
||||
return super().async_write_ha_state()
|
||||
return super()._async_write_ha_state()
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260325.6"]
|
||||
"requirements": ["home-assistant-frontend==20260325.5"]
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
|
||||
"requirements": ["gardena-bluetooth==2.1.0"]
|
||||
"requirements": ["gardena-bluetooth==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The Glances component."""
|
||||
"""The Glances integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants for Glances component."""
|
||||
"""Constants for Glances integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import sys
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support gathering system information of hosts which are running glances."""
|
||||
"""Support gathering system information of hosts which are running Glances."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["goodwe"],
|
||||
"requirements": ["goodwe==0.4.8"]
|
||||
"requirements": ["goodwe==0.4.10"]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
|
||||
from .api import ApiAuthImpl, get_feature_access
|
||||
@@ -88,11 +91,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo
|
||||
_LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err))
|
||||
return False
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
try:
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
# Force a token refresh to fix a bug where tokens were persisted with
|
||||
# expires_in (relative time delta) and expires_at (absolute time) swapped.
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientError
|
||||
from gassist_text import TextAssistant
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, discovery, intent
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
@@ -58,13 +63,11 @@ async def async_setup_entry(
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="reauth_required"
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="reauth_required"
|
||||
) from err
|
||||
except (OAuth2TokenRequestError, ClientError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
mem_storage = InMemoryStorage(hass)
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from gassist_text import TextAssistant
|
||||
from google.oauth2.credentials import Credentials
|
||||
@@ -26,7 +25,11 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
|
||||
@@ -79,9 +82,8 @@ async def async_send_text_commands(
|
||||
session = entry.runtime_data.session
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
entry.async_start_reauth(hass)
|
||||
except OAuth2TokenRequestReauthError:
|
||||
entry.async_start_reauth(hass)
|
||||
raise
|
||||
|
||||
credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call]
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from functools import partial
|
||||
|
||||
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
||||
from google.auth.exceptions import RefreshError
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import Resource, build
|
||||
|
||||
@@ -14,6 +13,8 @@ from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
@@ -37,24 +38,26 @@ class AsyncConfigEntryAuth:
|
||||
|
||||
async def check_and_refresh_token(self) -> str:
|
||||
"""Check the token."""
|
||||
setup_in_progress = (
|
||||
self.oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
|
||||
)
|
||||
|
||||
try:
|
||||
await self.oauth_session.async_ensure_token_valid()
|
||||
except (RefreshError, ClientResponseError, ClientError) as ex:
|
||||
if (
|
||||
self.oauth_session.config_entry.state
|
||||
is ConfigEntryState.SETUP_IN_PROGRESS
|
||||
):
|
||||
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from ex
|
||||
except OAuth2TokenRequestReauthError as ex:
|
||||
if setup_in_progress:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from ex
|
||||
self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass)
|
||||
raise
|
||||
except OAuth2TokenRequestError as ex:
|
||||
if setup_in_progress:
|
||||
raise ConfigEntryNotReady from ex
|
||||
raise
|
||||
except ClientError as ex:
|
||||
if setup_in_progress:
|
||||
raise ConfigEntryNotReady from ex
|
||||
if isinstance(ex, RefreshError) or (
|
||||
hasattr(ex, "status") and ex.status == 400
|
||||
):
|
||||
self.oauth_session.config_entry.async_start_reauth(
|
||||
self.oauth_session.hass
|
||||
)
|
||||
raise HomeAssistantError(ex) from ex
|
||||
return self.access_token
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user