mirror of
https://github.com/home-assistant/core.git
synced 2025-10-06 10:19:33 +00:00
Compare commits
3 Commits
openai-mod
...
llm-task-p
Author | SHA1 | Date | |
---|---|---|---|
![]() |
17a5815ca1 | ||
![]() |
a8d4caab01 | ||
![]() |
2be6acec03 |
@@ -8,7 +8,6 @@
|
|||||||
"PYTHONASYNCIODEBUG": "1"
|
"PYTHONASYNCIODEBUG": "1"
|
||||||
},
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
|
|
||||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||||
},
|
},
|
||||||
// Port 5683 udp is used by Shelly integration
|
// Port 5683 udp is used by Shelly integration
|
||||||
|
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,14 +1,15 @@
|
|||||||
name: Report an issue with Home Assistant Core
|
name: Report an issue with Home Assistant Core
|
||||||
description: Report an issue with Home Assistant Core.
|
description: Report an issue with Home Assistant Core.
|
||||||
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
This issue form is for reporting bugs only!
|
This issue form is for reporting bugs only!
|
||||||
|
|
||||||
If you have a feature or enhancement request, please [request them here instead][fr].
|
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
|
||||||
|
|
||||||
[fr]: https://github.com/orgs/home-assistant/discussions
|
[fr]: https://community.home-assistant.io/c/feature-requests
|
||||||
- type: textarea
|
- type: textarea
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -10,8 +10,8 @@ contact_links:
|
|||||||
url: https://www.home-assistant.io/help
|
url: https://www.home-assistant.io/help
|
||||||
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||||
- name: Feature Request
|
- name: Feature Request
|
||||||
url: https://github.com/orgs/home-assistant/discussions
|
url: https://community.home-assistant.io/c/feature-requests
|
||||||
about: Please use this link to request new features or enhancements to existing features.
|
about: Please use our Community Forum for making feature requests.
|
||||||
- name: I'm unsure where to go
|
- name: I'm unsure where to go
|
||||||
url: https://www.home-assistant.io/join-chat
|
url: https://www.home-assistant.io/join-chat
|
||||||
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
||||||
|
53
.github/ISSUE_TEMPLATE/task.yml
vendored
53
.github/ISSUE_TEMPLATE/task.yml
vendored
@@ -1,53 +0,0 @@
|
|||||||
name: Task
|
|
||||||
description: For staff only - Create a task
|
|
||||||
type: Task
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
## ⚠️ RESTRICTED ACCESS
|
|
||||||
|
|
||||||
**This form is restricted to Open Home Foundation staff, authorized contributors, and integration code owners only.**
|
|
||||||
|
|
||||||
If you are a community member wanting to contribute, please:
|
|
||||||
- For bug reports: Use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)
|
|
||||||
- For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### For authorized contributors
|
|
||||||
|
|
||||||
Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked.
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
description: |
|
|
||||||
Provide a clear and detailed description of the task that needs to be accomplished.
|
|
||||||
|
|
||||||
Be specific about what needs to be done, why it's important, and any constraints or requirements.
|
|
||||||
placeholder: |
|
|
||||||
Describe the task, including:
|
|
||||||
- What needs to be done
|
|
||||||
- Why this task is needed
|
|
||||||
- Expected outcome
|
|
||||||
- Any constraints or requirements
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: additional_context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: |
|
|
||||||
Any additional information, links, research, or context that would be helpful.
|
|
||||||
|
|
||||||
Include links to related issues, research, prototypes, roadmap opportunities etc.
|
|
||||||
placeholder: |
|
|
||||||
- Roadmap opportunity: [link]
|
|
||||||
- Epic: [link]
|
|
||||||
- Feature request: [link]
|
|
||||||
- Technical design documents: [link]
|
|
||||||
- Prototype/mockup: [link]
|
|
||||||
- Dependencies: [links]
|
|
||||||
validations:
|
|
||||||
required: false
|
|
1225
.github/copilot-instructions.md
vendored
1225
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
3
.github/dependabot.yml
vendored
3
.github/dependabot.yml
vendored
@@ -6,6 +6,3 @@ updates:
|
|||||||
interval: daily
|
interval: daily
|
||||||
time: "06:00"
|
time: "06:00"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
labels:
|
|
||||||
- dependency
|
|
||||||
- github_actions
|
|
||||||
|
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -94,7 +94,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Download nightly wheels of frontend
|
- name: Download nightly wheels of frontend
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: dawidd6/action-download-artifact@v11
|
uses: dawidd6/action-download-artifact@v10
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: home-assistant/frontend
|
repo: home-assistant/frontend
|
||||||
@@ -105,10 +105,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Download nightly wheels of intents
|
- name: Download nightly wheels of intents
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: dawidd6/action-download-artifact@v11
|
uses: dawidd6/action-download-artifact@v10
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: OHF-Voice/intents-package
|
repo: home-assistant/intents-package
|
||||||
branch: main
|
branch: main
|
||||||
workflow: nightly.yaml
|
workflow: nightly.yaml
|
||||||
workflow_conclusion: success
|
workflow_conclusion: success
|
||||||
@@ -324,7 +324,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@v3.9.2
|
uses: sigstore/cosign-installer@v3.8.2
|
||||||
with:
|
with:
|
||||||
cosign-release: "v2.2.3"
|
cosign-release: "v2.2.3"
|
||||||
|
|
||||||
|
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -37,10 +37,10 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CACHE_VERSION: 4
|
CACHE_VERSION: 2
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 1
|
MYPY_CACHE_VERSION: 1
|
||||||
HA_SHORT_VERSION: "2025.8"
|
HA_SHORT_VERSION: "2025.7"
|
||||||
DEFAULT_PYTHON: "3.13"
|
DEFAULT_PYTHON: "3.13"
|
||||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||||
# 10.3 is the oldest supported version
|
# 10.3 is the oldest supported version
|
||||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.29.4
|
uses: github/codeql-action/init@v3.29.0
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.29.4
|
uses: github/codeql-action/analyze@v3.29.0
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
@@ -231,7 +231,7 @@ jobs:
|
|||||||
- name: Detect duplicates using AI
|
- name: Detect duplicates using AI
|
||||||
id: ai_detection
|
id: ai_detection
|
||||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||||
uses: actions/ai-inference@v1.2.3
|
uses: actions/ai-inference@v1.1.0
|
||||||
with:
|
with:
|
||||||
model: openai/gpt-4o
|
model: openai/gpt-4o
|
||||||
system-prompt: |
|
system-prompt: |
|
||||||
|
@@ -57,7 +57,7 @@ jobs:
|
|||||||
- name: Detect language using AI
|
- name: Detect language using AI
|
||||||
id: ai_language_detection
|
id: ai_language_detection
|
||||||
if: steps.detect_language.outputs.should_continue == 'true'
|
if: steps.detect_language.outputs.should_continue == 'true'
|
||||||
uses: actions/ai-inference@v1.2.3
|
uses: actions/ai-inference@v1.1.0
|
||||||
with:
|
with:
|
||||||
model: openai/gpt-4o-mini
|
model: openai/gpt-4o-mini
|
||||||
system-prompt: |
|
system-prompt: |
|
||||||
|
84
.github/workflows/restrict-task-creation.yml
vendored
84
.github/workflows/restrict-task-creation.yml
vendored
@@ -1,84 +0,0 @@
|
|||||||
name: Restrict task creation
|
|
||||||
|
|
||||||
# yamllint disable-line rule:truthy
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [opened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-authorization:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Only run if this is a Task issue type (from the issue form)
|
|
||||||
if: github.event.issue.issue_type == 'Task'
|
|
||||||
steps:
|
|
||||||
- name: Check if user is authorized
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const issueAuthor = context.payload.issue.user.login;
|
|
||||||
|
|
||||||
// First check if user is an organization member
|
|
||||||
try {
|
|
||||||
await github.rest.orgs.checkMembershipForUser({
|
|
||||||
org: 'home-assistant',
|
|
||||||
username: issueAuthor
|
|
||||||
});
|
|
||||||
console.log(`✅ ${issueAuthor} is an organization member`);
|
|
||||||
return; // Authorized, no need to check further
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`ℹ️ ${issueAuthor} is not an organization member, checking codeowners...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not an org member, check if they're a codeowner
|
|
||||||
try {
|
|
||||||
// Fetch CODEOWNERS file from the repository
|
|
||||||
const { data: codeownersFile } = await github.rest.repos.getContent({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
path: 'CODEOWNERS',
|
|
||||||
ref: 'dev'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Decode the content (it's base64 encoded)
|
|
||||||
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8');
|
|
||||||
|
|
||||||
// Check if the issue author is mentioned in CODEOWNERS
|
|
||||||
// GitHub usernames in CODEOWNERS are prefixed with @
|
|
||||||
if (codeownersContent.includes(`@${issueAuthor}`)) {
|
|
||||||
console.log(`✅ ${issueAuthor} is a integration code owner`);
|
|
||||||
return; // Authorized
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking CODEOWNERS:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we reach here, user is not authorized
|
|
||||||
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
|
|
||||||
|
|
||||||
// Close the issue with a comment
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
|
|
||||||
`Task issues are restricted to Open Home Foundation staff, authorized contributors, and integration code owners.\n\n` +
|
|
||||||
`If you would like to:\n` +
|
|
||||||
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)\n` +
|
|
||||||
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
|
|
||||||
`If you believe you should have access to create Task issues, please contact the maintainers.`
|
|
||||||
});
|
|
||||||
|
|
||||||
await github.rest.issues.update({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
state: 'closed'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add a label to indicate this was auto-closed
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
labels: ['auto-closed']
|
|
||||||
});
|
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -138,7 +138,3 @@ tmp_cache
|
|||||||
|
|
||||||
# Will be created from script/split_tests.py
|
# Will be created from script/split_tests.py
|
||||||
pytest_buckets.txt
|
pytest_buckets.txt
|
||||||
|
|
||||||
# AI tooling
|
|
||||||
.claude
|
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.12.1
|
rev: v0.11.12
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
args:
|
args:
|
||||||
|
@@ -67,7 +67,6 @@ homeassistant.components.alert.*
|
|||||||
homeassistant.components.alexa.*
|
homeassistant.components.alexa.*
|
||||||
homeassistant.components.alexa_devices.*
|
homeassistant.components.alexa_devices.*
|
||||||
homeassistant.components.alpha_vantage.*
|
homeassistant.components.alpha_vantage.*
|
||||||
homeassistant.components.altruist.*
|
|
||||||
homeassistant.components.amazon_polly.*
|
homeassistant.components.amazon_polly.*
|
||||||
homeassistant.components.amberelectric.*
|
homeassistant.components.amberelectric.*
|
||||||
homeassistant.components.ambient_network.*
|
homeassistant.components.ambient_network.*
|
||||||
@@ -377,12 +376,10 @@ homeassistant.components.onedrive.*
|
|||||||
homeassistant.components.onewire.*
|
homeassistant.components.onewire.*
|
||||||
homeassistant.components.onkyo.*
|
homeassistant.components.onkyo.*
|
||||||
homeassistant.components.open_meteo.*
|
homeassistant.components.open_meteo.*
|
||||||
homeassistant.components.open_router.*
|
|
||||||
homeassistant.components.openai_conversation.*
|
homeassistant.components.openai_conversation.*
|
||||||
homeassistant.components.openexchangerates.*
|
homeassistant.components.openexchangerates.*
|
||||||
homeassistant.components.opensky.*
|
homeassistant.components.opensky.*
|
||||||
homeassistant.components.openuv.*
|
homeassistant.components.openuv.*
|
||||||
homeassistant.components.opower.*
|
|
||||||
homeassistant.components.oralb.*
|
homeassistant.components.oralb.*
|
||||||
homeassistant.components.otbr.*
|
homeassistant.components.otbr.*
|
||||||
homeassistant.components.overkiz.*
|
homeassistant.components.overkiz.*
|
||||||
@@ -505,7 +502,6 @@ homeassistant.components.tautulli.*
|
|||||||
homeassistant.components.tcp.*
|
homeassistant.components.tcp.*
|
||||||
homeassistant.components.technove.*
|
homeassistant.components.technove.*
|
||||||
homeassistant.components.tedee.*
|
homeassistant.components.tedee.*
|
||||||
homeassistant.components.telegram_bot.*
|
|
||||||
homeassistant.components.text.*
|
homeassistant.components.text.*
|
||||||
homeassistant.components.thethingsnetwork.*
|
homeassistant.components.thethingsnetwork.*
|
||||||
homeassistant.components.threshold.*
|
homeassistant.components.threshold.*
|
||||||
@@ -536,7 +532,6 @@ homeassistant.components.unifiprotect.*
|
|||||||
homeassistant.components.upcloud.*
|
homeassistant.components.upcloud.*
|
||||||
homeassistant.components.update.*
|
homeassistant.components.update.*
|
||||||
homeassistant.components.uptime.*
|
homeassistant.components.uptime.*
|
||||||
homeassistant.components.uptime_kuma.*
|
|
||||||
homeassistant.components.uptimerobot.*
|
homeassistant.components.uptimerobot.*
|
||||||
homeassistant.components.usb.*
|
homeassistant.components.usb.*
|
||||||
homeassistant.components.uvc.*
|
homeassistant.components.uvc.*
|
||||||
|
32
CODEOWNERS
generated
32
CODEOWNERS
generated
@@ -93,8 +93,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||||
/homeassistant/components/alexa_devices/ @chemelli74
|
/homeassistant/components/alexa_devices/ @chemelli74
|
||||||
/tests/components/alexa_devices/ @chemelli74
|
/tests/components/alexa_devices/ @chemelli74
|
||||||
/homeassistant/components/altruist/ @airalab @LoSk-p
|
|
||||||
/tests/components/altruist/ @airalab @LoSk-p
|
|
||||||
/homeassistant/components/amazon_polly/ @jschlyter
|
/homeassistant/components/amazon_polly/ @jschlyter
|
||||||
/homeassistant/components/amberelectric/ @madpilot
|
/homeassistant/components/amberelectric/ @madpilot
|
||||||
/tests/components/amberelectric/ @madpilot
|
/tests/components/amberelectric/ @madpilot
|
||||||
@@ -331,8 +329,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/demo/ @home-assistant/core
|
/tests/components/demo/ @home-assistant/core
|
||||||
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
||||||
/tests/components/denonavr/ @ol-iver @starkillerOG
|
/tests/components/denonavr/ @ol-iver @starkillerOG
|
||||||
/homeassistant/components/derivative/ @afaucogney @karwosts
|
/homeassistant/components/derivative/ @afaucogney
|
||||||
/tests/components/derivative/ @afaucogney @karwosts
|
/tests/components/derivative/ @afaucogney
|
||||||
/homeassistant/components/devialet/ @fwestenberg
|
/homeassistant/components/devialet/ @fwestenberg
|
||||||
/tests/components/devialet/ @fwestenberg
|
/tests/components/devialet/ @fwestenberg
|
||||||
/homeassistant/components/device_automation/ @home-assistant/core
|
/homeassistant/components/device_automation/ @home-assistant/core
|
||||||
@@ -452,8 +450,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/eq3btsmart/ @eulemitkeule @dbuezas
|
/tests/components/eq3btsmart/ @eulemitkeule @dbuezas
|
||||||
/homeassistant/components/escea/ @lazdavila
|
/homeassistant/components/escea/ @lazdavila
|
||||||
/tests/components/escea/ @lazdavila
|
/tests/components/escea/ @lazdavila
|
||||||
/homeassistant/components/esphome/ @jesserockz @kbx81 @bdraco
|
/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco
|
||||||
/tests/components/esphome/ @jesserockz @kbx81 @bdraco
|
/tests/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco
|
||||||
/homeassistant/components/eufylife_ble/ @bdr99
|
/homeassistant/components/eufylife_ble/ @bdr99
|
||||||
/tests/components/eufylife_ble/ @bdr99
|
/tests/components/eufylife_ble/ @bdr99
|
||||||
/homeassistant/components/event/ @home-assistant/core
|
/homeassistant/components/event/ @home-assistant/core
|
||||||
@@ -684,8 +682,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/husqvarna_automower/ @Thomas55555
|
/tests/components/husqvarna_automower/ @Thomas55555
|
||||||
/homeassistant/components/husqvarna_automower_ble/ @alistair23
|
/homeassistant/components/husqvarna_automower_ble/ @alistair23
|
||||||
/tests/components/husqvarna_automower_ble/ @alistair23
|
/tests/components/husqvarna_automower_ble/ @alistair23
|
||||||
/homeassistant/components/huum/ @frwickst @vincentwolsink
|
/homeassistant/components/huum/ @frwickst
|
||||||
/tests/components/huum/ @frwickst @vincentwolsink
|
/tests/components/huum/ @frwickst
|
||||||
/homeassistant/components/hvv_departures/ @vigonotion
|
/homeassistant/components/hvv_departures/ @vigonotion
|
||||||
/tests/components/hvv_departures/ @vigonotion
|
/tests/components/hvv_departures/ @vigonotion
|
||||||
/homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan
|
/homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan
|
||||||
@@ -788,6 +786,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
||||||
/homeassistant/components/jewish_calendar/ @tsvi
|
/homeassistant/components/jewish_calendar/ @tsvi
|
||||||
/tests/components/jewish_calendar/ @tsvi
|
/tests/components/jewish_calendar/ @tsvi
|
||||||
|
/homeassistant/components/juicenet/ @jesserockz
|
||||||
|
/tests/components/juicenet/ @jesserockz
|
||||||
/homeassistant/components/justnimbus/ @kvanzuijlen
|
/homeassistant/components/justnimbus/ @kvanzuijlen
|
||||||
/tests/components/justnimbus/ @kvanzuijlen
|
/tests/components/justnimbus/ @kvanzuijlen
|
||||||
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
||||||
@@ -1102,8 +1102,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/onvif/ @hunterjm @jterrace
|
/tests/components/onvif/ @hunterjm @jterrace
|
||||||
/homeassistant/components/open_meteo/ @frenck
|
/homeassistant/components/open_meteo/ @frenck
|
||||||
/tests/components/open_meteo/ @frenck
|
/tests/components/open_meteo/ @frenck
|
||||||
/homeassistant/components/open_router/ @joostlek
|
|
||||||
/tests/components/open_router/ @joostlek
|
|
||||||
/homeassistant/components/openai_conversation/ @balloob
|
/homeassistant/components/openai_conversation/ @balloob
|
||||||
/tests/components/openai_conversation/ @balloob
|
/tests/components/openai_conversation/ @balloob
|
||||||
/homeassistant/components/openerz/ @misialq
|
/homeassistant/components/openerz/ @misialq
|
||||||
@@ -1171,8 +1169,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/ping/ @jpbede
|
/tests/components/ping/ @jpbede
|
||||||
/homeassistant/components/plaato/ @JohNan
|
/homeassistant/components/plaato/ @JohNan
|
||||||
/tests/components/plaato/ @JohNan
|
/tests/components/plaato/ @JohNan
|
||||||
/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r
|
|
||||||
/tests/components/playstation_network/ @jackjpowell @tr4nt0r
|
|
||||||
/homeassistant/components/plex/ @jjlawren
|
/homeassistant/components/plex/ @jjlawren
|
||||||
/tests/components/plex/ @jjlawren
|
/tests/components/plex/ @jjlawren
|
||||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
||||||
@@ -1555,8 +1551,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/technove/ @Moustachauve
|
/tests/components/technove/ @Moustachauve
|
||||||
/homeassistant/components/tedee/ @patrickhilker @zweckj
|
/homeassistant/components/tedee/ @patrickhilker @zweckj
|
||||||
/tests/components/tedee/ @patrickhilker @zweckj
|
/tests/components/tedee/ @patrickhilker @zweckj
|
||||||
/homeassistant/components/telegram_bot/ @hanwg
|
|
||||||
/tests/components/telegram_bot/ @hanwg
|
|
||||||
/homeassistant/components/tellduslive/ @fredrike
|
/homeassistant/components/tellduslive/ @fredrike
|
||||||
/tests/components/tellduslive/ @fredrike
|
/tests/components/tellduslive/ @fredrike
|
||||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||||
@@ -1586,8 +1580,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/tile/ @bachya
|
/tests/components/tile/ @bachya
|
||||||
/homeassistant/components/tilt_ble/ @apt-itude
|
/homeassistant/components/tilt_ble/ @apt-itude
|
||||||
/tests/components/tilt_ble/ @apt-itude
|
/tests/components/tilt_ble/ @apt-itude
|
||||||
/homeassistant/components/tilt_pi/ @michaelheyman
|
|
||||||
/tests/components/tilt_pi/ @michaelheyman
|
|
||||||
/homeassistant/components/time/ @home-assistant/core
|
/homeassistant/components/time/ @home-assistant/core
|
||||||
/tests/components/time/ @home-assistant/core
|
/tests/components/time/ @home-assistant/core
|
||||||
/homeassistant/components/time_date/ @fabaff
|
/homeassistant/components/time_date/ @fabaff
|
||||||
@@ -1660,8 +1652,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/upnp/ @StevenLooman
|
/tests/components/upnp/ @StevenLooman
|
||||||
/homeassistant/components/uptime/ @frenck
|
/homeassistant/components/uptime/ @frenck
|
||||||
/tests/components/uptime/ @frenck
|
/tests/components/uptime/ @frenck
|
||||||
/homeassistant/components/uptime_kuma/ @tr4nt0r
|
|
||||||
/tests/components/uptime_kuma/ @tr4nt0r
|
|
||||||
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
|
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
|
||||||
/tests/components/uptimerobot/ @ludeeus @chemelli74
|
/tests/components/uptimerobot/ @ludeeus @chemelli74
|
||||||
/homeassistant/components/usb/ @bdraco
|
/homeassistant/components/usb/ @bdraco
|
||||||
@@ -1678,8 +1668,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
|
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
|
||||||
/homeassistant/components/valve/ @home-assistant/core
|
/homeassistant/components/valve/ @home-assistant/core
|
||||||
/tests/components/valve/ @home-assistant/core
|
/tests/components/valve/ @home-assistant/core
|
||||||
/homeassistant/components/vegehub/ @ghowevege
|
|
||||||
/tests/components/vegehub/ @ghowevege
|
|
||||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||||
/tests/components/velbus/ @Cereal2nd @brefra
|
/tests/components/velbus/ @Cereal2nd @brefra
|
||||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
||||||
@@ -1760,8 +1748,8 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/wirelesstag/ @sergeymaysak
|
/homeassistant/components/wirelesstag/ @sergeymaysak
|
||||||
/homeassistant/components/withings/ @joostlek
|
/homeassistant/components/withings/ @joostlek
|
||||||
/tests/components/withings/ @joostlek
|
/tests/components/withings/ @joostlek
|
||||||
/homeassistant/components/wiz/ @sbidy @arturpragacz
|
/homeassistant/components/wiz/ @sbidy
|
||||||
/tests/components/wiz/ @sbidy @arturpragacz
|
/tests/components/wiz/ @sbidy
|
||||||
/homeassistant/components/wled/ @frenck
|
/homeassistant/components/wled/ @frenck
|
||||||
/tests/components/wled/ @frenck
|
/tests/components/wled/ @frenck
|
||||||
/homeassistant/components/wmspro/ @mback2k
|
/homeassistant/components/wmspro/ @mback2k
|
||||||
|
@@ -1,7 +1,15 @@
|
|||||||
FROM mcr.microsoft.com/vscode/devcontainers/base:debian
|
FROM mcr.microsoft.com/devcontainers/python:1-3.13
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
# Uninstall pre-installed formatting and linting tools
|
||||||
|
# They would conflict with our pinned versions
|
||||||
|
RUN \
|
||||||
|
pipx uninstall pydocstyle \
|
||||||
|
&& pipx uninstall pycodestyle \
|
||||||
|
&& pipx uninstall mypy \
|
||||||
|
&& pipx uninstall pylint
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
|
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
@@ -24,18 +32,21 @@ RUN \
|
|||||||
libxml2 \
|
libxml2 \
|
||||||
git \
|
git \
|
||||||
cmake \
|
cmake \
|
||||||
autoconf \
|
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Add go2rtc binary
|
# Add go2rtc binary
|
||||||
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
|
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
|
||||||
|
|
||||||
|
# Install uv
|
||||||
|
RUN pip3 install uv
|
||||||
|
|
||||||
WORKDIR /usr/src
|
WORKDIR /usr/src
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
# Setup hass-release
|
||||||
|
RUN git clone --depth 1 https://github.com/home-assistant/hass-release \
|
||||||
RUN uv python install 3.13.2
|
&& uv pip install --system -e hass-release/ \
|
||||||
|
&& chown -R vscode /usr/src/hass-release/data
|
||||||
|
|
||||||
USER vscode
|
USER vscode
|
||||||
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
|
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
|
||||||
@@ -44,10 +55,6 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
|||||||
|
|
||||||
WORKDIR /tmp
|
WORKDIR /tmp
|
||||||
|
|
||||||
# Setup hass-release
|
|
||||||
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
|
|
||||||
&& uv pip install -e ~/hass-release/
|
|
||||||
|
|
||||||
# Install Python dependencies from requirements
|
# Install Python dependencies from requirements
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
|
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
|
||||||
@@ -58,4 +65,4 @@ RUN uv pip install -r requirements_test.txt
|
|||||||
WORKDIR /workspaces
|
WORKDIR /workspaces
|
||||||
|
|
||||||
# Set the default shell to bash instead of sh
|
# Set the default shell to bash instead of sh
|
||||||
ENV SHELL=/bin/bash
|
ENV SHELL /bin/bash
|
||||||
|
@@ -38,7 +38,8 @@ def validate_python() -> None:
|
|||||||
|
|
||||||
def ensure_config_path(config_dir: str) -> None:
|
def ensure_config_path(config_dir: str) -> None:
|
||||||
"""Validate the configuration directory."""
|
"""Validate the configuration directory."""
|
||||||
from . import config as config_util # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config as config_util
|
||||||
|
|
||||||
lib_dir = os.path.join(config_dir, "deps")
|
lib_dir = os.path.join(config_dir, "deps")
|
||||||
|
|
||||||
@@ -79,7 +80,8 @@ def ensure_config_path(config_dir: str) -> None:
|
|||||||
|
|
||||||
def get_arguments() -> argparse.Namespace:
|
def get_arguments() -> argparse.Namespace:
|
||||||
"""Get parsed passed in arguments."""
|
"""Get parsed passed in arguments."""
|
||||||
from . import config as config_util # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config as config_util
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Home Assistant: Observe, Control, Automate.",
|
description="Home Assistant: Observe, Control, Automate.",
|
||||||
@@ -175,7 +177,8 @@ def main() -> int:
|
|||||||
validate_os()
|
validate_os()
|
||||||
|
|
||||||
if args.script is not None:
|
if args.script is not None:
|
||||||
from . import scripts # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import scripts
|
||||||
|
|
||||||
return scripts.run(args.script)
|
return scripts.run(args.script)
|
||||||
|
|
||||||
@@ -185,7 +188,8 @@ def main() -> int:
|
|||||||
|
|
||||||
ensure_config_path(config_dir)
|
ensure_config_path(config_dir)
|
||||||
|
|
||||||
from . import config, runner # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config, runner
|
||||||
|
|
||||||
safe_mode = config.safe_mode_enabled(config_dir)
|
safe_mode = config.safe_mode_enabled(config_dir)
|
||||||
|
|
||||||
|
@@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def _generate_secret() -> str:
|
def _generate_secret() -> str:
|
||||||
"""Generate a secret."""
|
"""Generate a secret."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return str(pyotp.random_base32())
|
return str(pyotp.random_base32())
|
||||||
|
|
||||||
|
|
||||||
def _generate_random() -> int:
|
def _generate_random() -> int:
|
||||||
"""Generate a 32 digit number."""
|
"""Generate a 32 digit number."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
||||||
|
|
||||||
|
|
||||||
def _generate_otp(secret: str, count: int) -> str:
|
def _generate_otp(secret: str, count: int) -> str:
|
||||||
"""Generate one time password."""
|
"""Generate one time password."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return str(pyotp.HOTP(secret).at(count))
|
return str(pyotp.HOTP(secret).at(count))
|
||||||
|
|
||||||
|
|
||||||
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
||||||
"""Verify one time password."""
|
"""Verify one time password."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return bool(pyotp.HOTP(secret).verify(otp, count))
|
return bool(pyotp.HOTP(secret).verify(otp, count))
|
||||||
|
|
||||||
|
@@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
|||||||
|
|
||||||
def _generate_qr_code(data: str) -> str:
|
def _generate_qr_code(data: str) -> str:
|
||||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||||
import pyqrcode # noqa: PLC0415
|
import pyqrcode # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
qr_code = pyqrcode.create(data)
|
qr_code = pyqrcode.create(data)
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str:
|
|||||||
|
|
||||||
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
||||||
"""Generate a secret, url, and QR code."""
|
"""Generate a secret, url, and QR code."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
ota_secret = pyotp.random_base32()
|
ota_secret = pyotp.random_base32()
|
||||||
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
||||||
@@ -107,7 +107,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
|||||||
|
|
||||||
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
||||||
"""Create a ota_secret for user."""
|
"""Create a ota_secret for user."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
ota_secret: str = secret or pyotp.random_base32()
|
ota_secret: str = secret or pyotp.random_base32()
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
|||||||
|
|
||||||
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
||||||
"""Validate two factor authentication code."""
|
"""Validate two factor authentication code."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
||||||
# even we cannot find user, we still do verify
|
# even we cannot find user, we still do verify
|
||||||
@@ -196,7 +196,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
|
|||||||
Return self.async_show_form(step_id='init') if user_input is None.
|
Return self.async_show_form(step_id='init') if user_input is None.
|
||||||
Return self.async_create_entry(data={'result': result}) if finish.
|
Return self.async_create_entry(data={'result': result}) if finish.
|
||||||
"""
|
"""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
@@ -75,8 +75,8 @@ from .core_config import async_process_ha_core_config
|
|||||||
from .exceptions import HomeAssistantError
|
from .exceptions import HomeAssistantError
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
area_registry,
|
area_registry,
|
||||||
|
backup,
|
||||||
category_registry,
|
category_registry,
|
||||||
condition,
|
|
||||||
config_validation as cv,
|
config_validation as cv,
|
||||||
device_registry,
|
device_registry,
|
||||||
entity,
|
entity,
|
||||||
@@ -89,7 +89,6 @@ from .helpers import (
|
|||||||
restore_state,
|
restore_state,
|
||||||
template,
|
template,
|
||||||
translation,
|
translation,
|
||||||
trigger,
|
|
||||||
)
|
)
|
||||||
from .helpers.dispatcher import async_dispatcher_send_internal
|
from .helpers.dispatcher import async_dispatcher_send_internal
|
||||||
from .helpers.storage import get_internal_store_manager
|
from .helpers.storage import get_internal_store_manager
|
||||||
@@ -332,9 +331,6 @@ async def async_setup_hass(
|
|||||||
if not is_virtual_env():
|
if not is_virtual_env():
|
||||||
await async_mount_local_lib_path(runtime_config.config_dir)
|
await async_mount_local_lib_path(runtime_config.config_dir)
|
||||||
|
|
||||||
if hass.config.safe_mode:
|
|
||||||
_LOGGER.info("Starting in safe mode")
|
|
||||||
|
|
||||||
basic_setup_success = (
|
basic_setup_success = (
|
||||||
await async_from_config_dict(config_dict, hass) is not None
|
await async_from_config_dict(config_dict, hass) is not None
|
||||||
)
|
)
|
||||||
@@ -387,6 +383,8 @@ async def async_setup_hass(
|
|||||||
{"recovery_mode": {}, "http": http_conf},
|
{"recovery_mode": {}, "http": http_conf},
|
||||||
hass,
|
hass,
|
||||||
)
|
)
|
||||||
|
elif hass.config.safe_mode:
|
||||||
|
_LOGGER.info("Starting in safe mode")
|
||||||
|
|
||||||
if runtime_config.open_ui:
|
if runtime_config.open_ui:
|
||||||
hass.add_job(open_hass_ui, hass)
|
hass.add_job(open_hass_ui, hass)
|
||||||
@@ -396,7 +394,7 @@ async def async_setup_hass(
|
|||||||
|
|
||||||
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
||||||
"""Open the UI."""
|
"""Open the UI."""
|
||||||
import webbrowser # noqa: PLC0415
|
import webbrowser # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
if hass.config.api is None or "frontend" not in hass.config.components:
|
if hass.config.api is None or "frontend" not in hass.config.components:
|
||||||
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
|
_LOGGER.warning("Cannot launch the UI because frontend not loaded")
|
||||||
@@ -454,8 +452,6 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
|||||||
create_eager_task(restore_state.async_load(hass)),
|
create_eager_task(restore_state.async_load(hass)),
|
||||||
create_eager_task(hass.config_entries.async_initialize()),
|
create_eager_task(hass.config_entries.async_initialize()),
|
||||||
create_eager_task(async_get_system_info(hass)),
|
create_eager_task(async_get_system_info(hass)),
|
||||||
create_eager_task(condition.async_setup(hass)),
|
|
||||||
create_eager_task(trigger.async_setup(hass)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -565,7 +561,8 @@ async def async_enable_logging(
|
|||||||
|
|
||||||
if not log_no_color:
|
if not log_no_color:
|
||||||
try:
|
try:
|
||||||
from colorlog import ColoredFormatter # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from colorlog import ColoredFormatter
|
||||||
|
|
||||||
# basicConfig must be called after importing colorlog in order to
|
# basicConfig must be called after importing colorlog in order to
|
||||||
# ensure that the handlers it sets up wraps the correct streams.
|
# ensure that the handlers it sets up wraps the correct streams.
|
||||||
@@ -695,10 +692,10 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
|
|||||||
|
|
||||||
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
||||||
"""Get domains of components to set up."""
|
"""Get domains of components to set up."""
|
||||||
# The common config section [homeassistant] could be filtered here,
|
# Filter out the repeating and common config section [homeassistant]
|
||||||
# but that is not necessary, since it corresponds to the core integration,
|
domains = {
|
||||||
# that is always unconditionally loaded.
|
domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN
|
||||||
domains = {cv.domain_key(key) for key in config}
|
}
|
||||||
|
|
||||||
# Add config entry and default domains
|
# Add config entry and default domains
|
||||||
if not hass.config.recovery_mode:
|
if not hass.config.recovery_mode:
|
||||||
@@ -726,28 +723,34 @@ async def _async_resolve_domains_and_preload(
|
|||||||
together with all their dependencies.
|
together with all their dependencies.
|
||||||
"""
|
"""
|
||||||
domains_to_setup = _get_domains(hass, config)
|
domains_to_setup = _get_domains(hass, config)
|
||||||
|
platform_integrations = conf_util.extract_platform_integrations(
|
||||||
# Also process all base platforms since we do not require the manifest
|
config, BASE_PLATFORMS
|
||||||
# to list them as dependencies.
|
)
|
||||||
# We want to later avoid lock contention when multiple integrations try to load
|
# Ensure base platforms that have platform integrations are added to `domains`,
|
||||||
# their manifests at once.
|
# so they can be setup first instead of discovering them later when a config
|
||||||
|
# entry setup task notices that it's needed and there is already a long line
|
||||||
|
# to use the import executor.
|
||||||
#
|
#
|
||||||
# Additionally process integrations that are defined under base platforms
|
|
||||||
# to speed things up.
|
|
||||||
# For example if we have
|
# For example if we have
|
||||||
# sensor:
|
# sensor:
|
||||||
# - platform: template
|
# - platform: template
|
||||||
#
|
#
|
||||||
# `template` has to be loaded to validate the config for sensor.
|
# `template` has to be loaded to validate the config for sensor
|
||||||
# The more platforms under `sensor:`, the longer
|
# so we want to start loading `sensor` as soon as we know
|
||||||
|
# it will be needed. The more platforms under `sensor:`, the longer
|
||||||
# it will take to finish setup for `sensor` because each of these
|
# it will take to finish setup for `sensor` because each of these
|
||||||
# platforms has to be imported before we can validate the config.
|
# platforms has to be imported before we can validate the config.
|
||||||
#
|
#
|
||||||
# Thankfully we are migrating away from the platform pattern
|
# Thankfully we are migrating away from the platform pattern
|
||||||
# so this will be less of a problem in the future.
|
# so this will be less of a problem in the future.
|
||||||
platform_integrations = conf_util.extract_platform_integrations(
|
domains_to_setup.update(platform_integrations)
|
||||||
config, BASE_PLATFORMS
|
|
||||||
)
|
# Additionally process base platforms since we do not require the manifest
|
||||||
|
# to list them as dependencies.
|
||||||
|
# We want to later avoid lock contention when multiple integrations try to load
|
||||||
|
# their manifests at once.
|
||||||
|
# Also process integrations that are defined under base platforms
|
||||||
|
# to speed things up.
|
||||||
additional_domains_to_process = {
|
additional_domains_to_process = {
|
||||||
*BASE_PLATFORMS,
|
*BASE_PLATFORMS,
|
||||||
*chain.from_iterable(platform_integrations.values()),
|
*chain.from_iterable(platform_integrations.values()),
|
||||||
@@ -865,9 +868,9 @@ async def _async_set_up_integrations(
|
|||||||
domains = set(integrations) & all_domains
|
domains = set(integrations) & all_domains
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Domains to be set up: %s\nDependencies: %s",
|
"Domains to be set up: %s | %s",
|
||||||
domains or "{}",
|
domains,
|
||||||
(all_domains - domains) or "{}",
|
all_domains - domains,
|
||||||
)
|
)
|
||||||
|
|
||||||
async_set_domains_to_be_loaded(hass, all_domains)
|
async_set_domains_to_be_loaded(hass, all_domains)
|
||||||
@@ -876,6 +879,10 @@ async def _async_set_up_integrations(
|
|||||||
if "recorder" in all_domains:
|
if "recorder" in all_domains:
|
||||||
recorder.async_initialize_recorder(hass)
|
recorder.async_initialize_recorder(hass)
|
||||||
|
|
||||||
|
# Initialize backup
|
||||||
|
if "backup" in all_domains:
|
||||||
|
backup.async_initialize_backup(hass)
|
||||||
|
|
||||||
stages: list[tuple[str, set[str], int | None]] = [
|
stages: list[tuple[str, set[str], int | None]] = [
|
||||||
*(
|
*(
|
||||||
(name, domain_group, timeout)
|
(name, domain_group, timeout)
|
||||||
@@ -908,13 +915,12 @@ async def _async_set_up_integrations(
|
|||||||
stage_all_domains = stage_domains | stage_dep_domains
|
stage_all_domains = stage_domains | stage_dep_domains
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Setting up stage %s: %s; already set up: %s\n"
|
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
|
||||||
"Dependencies: %s; already set up: %s",
|
|
||||||
name,
|
name,
|
||||||
stage_domains,
|
stage_domains,
|
||||||
(stage_domains_unfiltered - stage_domains) or "{}",
|
stage_domains_unfiltered - stage_domains,
|
||||||
stage_dep_domains or "{}",
|
stage_dep_domains,
|
||||||
(stage_dep_domains_unfiltered - stage_dep_domains) or "{}",
|
stage_dep_domains_unfiltered - stage_dep_domains,
|
||||||
)
|
)
|
||||||
|
|
||||||
if timeout is None:
|
if timeout is None:
|
||||||
|
@@ -1,11 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "sony",
|
"domain": "sony",
|
||||||
"name": "Sony",
|
"name": "Sony",
|
||||||
"integrations": [
|
"integrations": ["braviatv", "ps4", "sony_projector", "songpal"]
|
||||||
"braviatv",
|
|
||||||
"ps4",
|
|
||||||
"sony_projector",
|
|
||||||
"songpal",
|
|
||||||
"playstation_network"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "switchbot",
|
"domain": "switchbot",
|
||||||
"name": "SwitchBot",
|
"name": "SwitchBot",
|
||||||
"integrations": ["switchbot", "switchbot_cloud"],
|
"integrations": ["switchbot", "switchbot_cloud"]
|
||||||
"iot_standards": ["matter"]
|
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "tilt",
|
|
||||||
"name": "Tilt",
|
|
||||||
"integrations": ["tilt_ble", "tilt_pi"]
|
|
||||||
}
|
|
@@ -185,7 +185,6 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
|||||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||||
name="Daily forecast wind bearing",
|
name="Daily forecast wind bearing",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
@@ -193,7 +192,6 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
|||||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||||
name="Hourly forecast wind bearing",
|
name="Hourly forecast wind bearing",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
@@ -336,8 +334,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
|||||||
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
|
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
|
||||||
name="Wind bearing",
|
name="Wind bearing",
|
||||||
native_unit_of_measurement=DEGREE,
|
native_unit_of_measurement=DEGREE,
|
||||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
|
||||||
),
|
),
|
||||||
AemetSensorEntityDescription(
|
AemetSensorEntityDescription(
|
||||||
key=ATTR_API_WIND_MAX_SPEED,
|
key=ATTR_API_WIND_MAX_SPEED,
|
||||||
|
@@ -1,47 +1,24 @@
|
|||||||
"""Integration to offer AI tasks to Home Assistant."""
|
"""Integration to offer AI tasks to Home Assistant."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.core import (
|
from homeassistant.helpers import config_validation as cv, storage
|
||||||
HassJobType,
|
|
||||||
HomeAssistant,
|
|
||||||
ServiceCall,
|
|
||||||
ServiceResponse,
|
|
||||||
SupportsResponse,
|
|
||||||
callback,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers import config_validation as cv, selector, storage
|
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||||
|
|
||||||
from .const import (
|
from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN
|
||||||
ATTR_ATTACHMENTS,
|
|
||||||
ATTR_INSTRUCTIONS,
|
|
||||||
ATTR_REQUIRED,
|
|
||||||
ATTR_STRUCTURE,
|
|
||||||
ATTR_TASK_NAME,
|
|
||||||
DATA_COMPONENT,
|
|
||||||
DATA_PREFERENCES,
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_GENERATE_DATA,
|
|
||||||
AITaskEntityFeature,
|
|
||||||
)
|
|
||||||
from .entity import AITaskEntity
|
from .entity import AITaskEntity
|
||||||
from .http import async_setup as async_setup_http
|
from .http import async_setup as async_setup_conversation_http
|
||||||
from .task import GenDataTask, GenDataTaskResult, async_generate_data
|
from .task import GenTextTask, GenTextTaskResult, async_generate_text
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
"AITaskEntity",
|
"AITaskEntity",
|
||||||
"AITaskEntityFeature",
|
"GenTextTask",
|
||||||
"GenDataTask",
|
"GenTextTaskResult",
|
||||||
"GenDataTaskResult",
|
"async_generate_text",
|
||||||
"async_generate_data",
|
|
||||||
"async_setup",
|
"async_setup",
|
||||||
"async_setup_entry",
|
"async_setup_entry",
|
||||||
"async_unload_entry",
|
"async_unload_entry",
|
||||||
@@ -51,27 +28,6 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
STRUCTURE_FIELD_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Optional(CONF_DESCRIPTION): str,
|
|
||||||
vol.Optional(ATTR_REQUIRED): bool,
|
|
||||||
vol.Required(CONF_SELECTOR): selector.validate_selector,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_structure_fields(value: dict[str, Any]) -> vol.Schema:
|
|
||||||
"""Validate the structure fields as a voluptuous Schema."""
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise vol.Invalid("Structure must be a dictionary")
|
|
||||||
fields = {}
|
|
||||||
for k, v in value.items():
|
|
||||||
field_class = vol.Required if v.get(ATTR_REQUIRED, False) else vol.Optional
|
|
||||||
fields[field_class(k, description=v.get(CONF_DESCRIPTION))] = selector.selector(
|
|
||||||
v[CONF_SELECTOR]
|
|
||||||
)
|
|
||||||
return vol.Schema(fields, extra=vol.PREVENT_EXTRA)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Register the process service."""
|
"""Register the process service."""
|
||||||
@@ -79,28 +35,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
hass.data[DATA_COMPONENT] = entity_component
|
hass.data[DATA_COMPONENT] = entity_component
|
||||||
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
||||||
await hass.data[DATA_PREFERENCES].async_load()
|
await hass.data[DATA_PREFERENCES].async_load()
|
||||||
async_setup_http(hass)
|
async_setup_conversation_http(hass)
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_GENERATE_DATA,
|
|
||||||
async_service_generate_data,
|
|
||||||
schema=vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_TASK_NAME): cv.string,
|
|
||||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
|
|
||||||
vol.Required(ATTR_INSTRUCTIONS): cv.string,
|
|
||||||
vol.Optional(ATTR_STRUCTURE): vol.All(
|
|
||||||
vol.Schema({str: STRUCTURE_FIELD_SCHEMA}),
|
|
||||||
_validate_structure_fields,
|
|
||||||
),
|
|
||||||
vol.Optional(ATTR_ATTACHMENTS): vol.All(
|
|
||||||
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
supports_response=SupportsResponse.ONLY,
|
|
||||||
job_type=HassJobType.Coroutinefunction,
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -114,18 +49,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
async def async_service_generate_data(call: ServiceCall) -> ServiceResponse:
|
|
||||||
"""Run the run task service."""
|
|
||||||
result = await async_generate_data(hass=call.hass, **call.data)
|
|
||||||
return result.as_dict()
|
|
||||||
|
|
||||||
|
|
||||||
class AITaskPreferences:
|
class AITaskPreferences:
|
||||||
"""AI Task preferences."""
|
"""AI Task preferences."""
|
||||||
|
|
||||||
KEYS = ("gen_data_entity_id",)
|
gen_text_entity_id: str | None = None
|
||||||
|
|
||||||
gen_data_entity_id: str | None = None
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
"""Initialize the preferences."""
|
"""Initialize the preferences."""
|
||||||
@@ -138,18 +65,17 @@ class AITaskPreferences:
|
|||||||
data = await self._store.async_load()
|
data = await self._store.async_load()
|
||||||
if data is None:
|
if data is None:
|
||||||
return
|
return
|
||||||
for key in self.KEYS:
|
self.gen_text_entity_id = data.get("gen_text_entity_id")
|
||||||
setattr(self, key, data[key])
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_set_preferences(
|
def async_set_preferences(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
gen_data_entity_id: str | None | UndefinedType = UNDEFINED,
|
gen_text_entity_id: str | None | UndefinedType = UNDEFINED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the preferences."""
|
"""Set the preferences."""
|
||||||
changed = False
|
changed = False
|
||||||
for key, value in (("gen_data_entity_id", gen_data_entity_id),):
|
for key, value in (("gen_text_entity_id", gen_text_entity_id),):
|
||||||
if value is not UNDEFINED:
|
if value is not UNDEFINED:
|
||||||
if getattr(self, key) != value:
|
if getattr(self, key) != value:
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
@@ -158,9 +84,16 @@ class AITaskPreferences:
|
|||||||
if not changed:
|
if not changed:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._store.async_delay_save(self.as_dict, 10)
|
self._store.async_delay_save(
|
||||||
|
lambda: {
|
||||||
|
"gen_text_entity_id": self.gen_text_entity_id,
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def as_dict(self) -> dict[str, str | None]:
|
def as_dict(self) -> dict[str, str | None]:
|
||||||
"""Get the current preferences."""
|
"""Get the current preferences."""
|
||||||
return {key: getattr(self, key) for key in self.KEYS}
|
return {
|
||||||
|
"gen_text_entity_id": self.gen_text_entity_id,
|
||||||
|
}
|
||||||
|
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import IntFlag
|
from typing import TYPE_CHECKING
|
||||||
from typing import TYPE_CHECKING, Final
|
|
||||||
|
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
@@ -17,24 +16,6 @@ DOMAIN = "ai_task"
|
|||||||
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
||||||
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
||||||
|
|
||||||
SERVICE_GENERATE_DATA = "generate_data"
|
|
||||||
|
|
||||||
ATTR_INSTRUCTIONS: Final = "instructions"
|
|
||||||
ATTR_TASK_NAME: Final = "task_name"
|
|
||||||
ATTR_STRUCTURE: Final = "structure"
|
|
||||||
ATTR_REQUIRED: Final = "required"
|
|
||||||
ATTR_ATTACHMENTS: Final = "attachments"
|
|
||||||
|
|
||||||
DEFAULT_SYSTEM_PROMPT = (
|
DEFAULT_SYSTEM_PROMPT = (
|
||||||
"You are a Home Assistant expert and help users with their tasks."
|
"You are a Home Assistant expert and help users with their tasks."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AITaskEntityFeature(IntFlag):
|
|
||||||
"""Supported features of the AI task entity."""
|
|
||||||
|
|
||||||
GENERATE_DATA = 1
|
|
||||||
"""Generate data based on instructions."""
|
|
||||||
|
|
||||||
SUPPORT_ATTACHMENTS = 2
|
|
||||||
"""Support attachments with generate data."""
|
|
||||||
|
@@ -4,8 +4,6 @@ from collections.abc import AsyncGenerator
|
|||||||
import contextlib
|
import contextlib
|
||||||
from typing import final
|
from typing import final
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
|
|
||||||
from homeassistant.components.conversation import (
|
from homeassistant.components.conversation import (
|
||||||
ChatLog,
|
ChatLog,
|
||||||
UserContent,
|
UserContent,
|
||||||
@@ -13,19 +11,18 @@ from homeassistant.components.conversation import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||||
from homeassistant.helpers import llm
|
from homeassistant.helpers import llm
|
||||||
from homeassistant.helpers.chat_session import ChatSession
|
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
|
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN
|
||||||
from .task import GenDataTask, GenDataTaskResult
|
from .task import GenTextTask, GenTextTaskResult
|
||||||
|
|
||||||
|
|
||||||
class AITaskEntity(RestoreEntity):
|
class AITaskEntity(RestoreEntity):
|
||||||
"""Entity that supports conversations."""
|
"""Entity that supports conversations."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_supported_features = AITaskEntityFeature(0)
|
|
||||||
__last_activity: str | None = None
|
__last_activity: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -36,11 +33,6 @@ class AITaskEntity(RestoreEntity):
|
|||||||
return None
|
return None
|
||||||
return self.__last_activity
|
return self.__last_activity
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def supported_features(self) -> AITaskEntityFeature:
|
|
||||||
"""Flag supported features."""
|
|
||||||
return self._attr_supported_features
|
|
||||||
|
|
||||||
async def async_internal_added_to_hass(self) -> None:
|
async def async_internal_added_to_hass(self) -> None:
|
||||||
"""Call when the entity is added to hass."""
|
"""Call when the entity is added to hass."""
|
||||||
await super().async_internal_added_to_hass()
|
await super().async_internal_added_to_hass()
|
||||||
@@ -56,12 +48,12 @@ class AITaskEntity(RestoreEntity):
|
|||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def _async_get_ai_task_chat_log(
|
async def _async_get_ai_task_chat_log(
|
||||||
self,
|
self,
|
||||||
session: ChatSession,
|
task: GenTextTask,
|
||||||
task: GenDataTask,
|
|
||||||
) -> AsyncGenerator[ChatLog]:
|
) -> AsyncGenerator[ChatLog]:
|
||||||
"""Context manager used to manage the ChatLog used during an AI Task."""
|
"""Context manager used to manage the ChatLog used during an AI Task."""
|
||||||
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
||||||
with (
|
with (
|
||||||
|
async_get_chat_session(self.hass) as session,
|
||||||
async_get_chat_log(
|
async_get_chat_log(
|
||||||
self.hass,
|
self.hass,
|
||||||
session,
|
session,
|
||||||
@@ -79,28 +71,25 @@ class AITaskEntity(RestoreEntity):
|
|||||||
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
||||||
)
|
)
|
||||||
|
|
||||||
chat_log.async_add_user_content(
|
chat_log.async_add_user_content(UserContent(task.instructions))
|
||||||
UserContent(task.instructions, attachments=task.attachments)
|
|
||||||
)
|
|
||||||
|
|
||||||
yield chat_log
|
yield chat_log
|
||||||
|
|
||||||
@final
|
@final
|
||||||
async def internal_async_generate_data(
|
async def internal_async_generate_text(
|
||||||
self,
|
self,
|
||||||
session: ChatSession,
|
task: GenTextTask,
|
||||||
task: GenDataTask,
|
) -> GenTextTaskResult:
|
||||||
) -> GenDataTaskResult:
|
"""Run a gen text task."""
|
||||||
"""Run a gen data task."""
|
|
||||||
self.__last_activity = dt_util.utcnow().isoformat()
|
self.__last_activity = dt_util.utcnow().isoformat()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
async with self._async_get_ai_task_chat_log(session, task) as chat_log:
|
async with self._async_get_ai_task_chat_log(task) as chat_log:
|
||||||
return await self._async_generate_data(task, chat_log)
|
return await self._async_generate_text(task, chat_log)
|
||||||
|
|
||||||
async def _async_generate_data(
|
async def _async_generate_text(
|
||||||
self,
|
self,
|
||||||
task: GenDataTask,
|
task: GenTextTask,
|
||||||
chat_log: ChatLog,
|
chat_log: ChatLog,
|
||||||
) -> GenDataTaskResult:
|
) -> GenTextTaskResult:
|
||||||
"""Handle a gen data task."""
|
"""Handle a gen text task."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
@@ -8,15 +8,43 @@ from homeassistant.components import websocket_api
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
from .const import DATA_PREFERENCES
|
from .const import DATA_PREFERENCES
|
||||||
|
from .task import async_generate_text
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_setup(hass: HomeAssistant) -> None:
|
def async_setup(hass: HomeAssistant) -> None:
|
||||||
"""Set up the HTTP API for the conversation integration."""
|
"""Set up the HTTP API for the conversation integration."""
|
||||||
|
websocket_api.async_register_command(hass, websocket_generate_text)
|
||||||
websocket_api.async_register_command(hass, websocket_get_preferences)
|
websocket_api.async_register_command(hass, websocket_get_preferences)
|
||||||
websocket_api.async_register_command(hass, websocket_set_preferences)
|
websocket_api.async_register_command(hass, websocket_set_preferences)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "ai_task/generate_text",
|
||||||
|
vol.Required("task_name"): str,
|
||||||
|
vol.Optional("entity_id"): str,
|
||||||
|
vol.Required("instructions"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def websocket_generate_text(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Run a generate text task."""
|
||||||
|
msg.pop("type")
|
||||||
|
msg_id = msg.pop("id")
|
||||||
|
try:
|
||||||
|
result = await async_generate_text(hass=hass, **msg)
|
||||||
|
except ValueError as err:
|
||||||
|
connection.send_error(msg_id, websocket_api.const.ERR_UNKNOWN_ERROR, str(err))
|
||||||
|
return
|
||||||
|
connection.send_result(msg_id, result.as_dict())
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "ai_task/preferences/get",
|
vol.Required("type"): "ai_task/preferences/get",
|
||||||
@@ -36,7 +64,7 @@ def websocket_get_preferences(
|
|||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "ai_task/preferences/set",
|
vol.Required("type"): "ai_task/preferences/set",
|
||||||
vol.Optional("gen_data_entity_id"): vol.Any(str, None),
|
vol.Optional("gen_text_entity_id"): vol.Any(str, None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@websocket_api.require_admin
|
@websocket_api.require_admin
|
||||||
|
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"services": {
|
|
||||||
"generate_data": {
|
|
||||||
"service": "mdi:file-star-four-points-outline"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"domain": "ai_task",
|
"domain": "ai_task",
|
||||||
"name": "AI Task",
|
"name": "AI Task",
|
||||||
"after_dependencies": ["camera"],
|
|
||||||
"codeowners": ["@home-assistant/core"],
|
"codeowners": ["@home-assistant/core"],
|
||||||
"dependencies": ["conversation", "media_source"],
|
"dependencies": ["conversation"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal"
|
"quality_scale": "internal"
|
||||||
|
@@ -1,33 +0,0 @@
|
|||||||
generate_data:
|
|
||||||
fields:
|
|
||||||
task_name:
|
|
||||||
example: "home summary"
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
instructions:
|
|
||||||
example: "Generate a funny notification that the garage door was left open"
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
multiline: true
|
|
||||||
entity_id:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
entity:
|
|
||||||
filter:
|
|
||||||
domain: ai_task
|
|
||||||
supported_features:
|
|
||||||
- ai_task.AITaskEntityFeature.GENERATE_DATA
|
|
||||||
structure:
|
|
||||||
advanced: true
|
|
||||||
required: false
|
|
||||||
example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }'
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
attachments:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
media:
|
|
||||||
accept:
|
|
||||||
- "*"
|
|
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"services": {
|
|
||||||
"generate_data": {
|
|
||||||
"name": "Generate data",
|
|
||||||
"description": "Uses AI to run a task that generates data.",
|
|
||||||
"fields": {
|
|
||||||
"task_name": {
|
|
||||||
"name": "Task name",
|
|
||||||
"description": "Name of the task."
|
|
||||||
},
|
|
||||||
"instructions": {
|
|
||||||
"name": "Instructions",
|
|
||||||
"description": "Instructions on what needs to be done."
|
|
||||||
},
|
|
||||||
"entity_id": {
|
|
||||||
"name": "Entity ID",
|
|
||||||
"description": "Entity ID to run the task on. If not provided, the preferred entity will be used."
|
|
||||||
},
|
|
||||||
"structure": {
|
|
||||||
"name": "Structured output",
|
|
||||||
"description": "When set, the AI Task will output fields with this in structure. The structure is a dictionary where the keys are the field names and the values contain a 'description', a 'selector', and an optional 'required' field."
|
|
||||||
},
|
|
||||||
"attachments": {
|
|
||||||
"name": "Attachments",
|
|
||||||
"description": "List of files to attach for multi-modal AI analysis."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -3,136 +3,41 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import mimetypes
|
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from homeassistant.components import camera, conversation, media_source
|
from .const import DATA_COMPONENT, DATA_PREFERENCES
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
|
||||||
|
|
||||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
|
||||||
|
|
||||||
|
|
||||||
def _save_camera_snapshot(image: camera.Image) -> Path:
|
async def async_generate_text(
|
||||||
"""Save camera snapshot to temp file."""
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="wb",
|
|
||||||
suffix=mimetypes.guess_extension(image.content_type, False),
|
|
||||||
delete=False,
|
|
||||||
) as temp_file:
|
|
||||||
temp_file.write(image.content)
|
|
||||||
return Path(temp_file.name)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_generate_data(
|
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
*,
|
*,
|
||||||
task_name: str,
|
task_name: str,
|
||||||
entity_id: str | None = None,
|
entity_id: str | None = None,
|
||||||
instructions: str,
|
instructions: str,
|
||||||
structure: vol.Schema | None = None,
|
) -> GenTextTaskResult:
|
||||||
attachments: list[dict] | None = None,
|
|
||||||
) -> GenDataTaskResult:
|
|
||||||
"""Run a task in the AI Task integration."""
|
"""Run a task in the AI Task integration."""
|
||||||
if entity_id is None:
|
if entity_id is None:
|
||||||
entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
|
entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id
|
||||||
|
|
||||||
if entity_id is None:
|
if entity_id is None:
|
||||||
raise HomeAssistantError("No entity_id provided and no preferred entity set")
|
raise ValueError("No entity_id provided and no preferred entity set")
|
||||||
|
|
||||||
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||||
if entity is None:
|
if entity is None:
|
||||||
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
|
raise ValueError(f"AI Task entity {entity_id} not found")
|
||||||
|
|
||||||
if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
|
return await entity.internal_async_generate_text(
|
||||||
raise HomeAssistantError(
|
GenTextTask(
|
||||||
f"AI Task entity {entity_id} does not support generating data"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve attachments
|
|
||||||
resolved_attachments: list[conversation.Attachment] = []
|
|
||||||
created_files: list[Path] = []
|
|
||||||
|
|
||||||
if (
|
|
||||||
attachments
|
|
||||||
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
|
|
||||||
):
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"AI Task entity {entity_id} does not support attachments"
|
|
||||||
)
|
|
||||||
|
|
||||||
for attachment in attachments or []:
|
|
||||||
media_content_id = attachment["media_content_id"]
|
|
||||||
|
|
||||||
# Special case for camera media sources
|
|
||||||
if media_content_id.startswith("media-source://camera/"):
|
|
||||||
# Extract entity_id from the media content ID
|
|
||||||
entity_id = media_content_id.removeprefix("media-source://camera/")
|
|
||||||
|
|
||||||
# Get snapshot from camera
|
|
||||||
image = await camera.async_get_image(hass, entity_id)
|
|
||||||
|
|
||||||
temp_filename = await hass.async_add_executor_job(
|
|
||||||
_save_camera_snapshot, image
|
|
||||||
)
|
|
||||||
created_files.append(temp_filename)
|
|
||||||
|
|
||||||
resolved_attachments.append(
|
|
||||||
conversation.Attachment(
|
|
||||||
media_content_id=media_content_id,
|
|
||||||
mime_type=image.content_type,
|
|
||||||
path=temp_filename,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Handle regular media sources
|
|
||||||
media = await media_source.async_resolve_media(hass, media_content_id, None)
|
|
||||||
if media.path is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
"Only local attachments are currently supported"
|
|
||||||
)
|
|
||||||
resolved_attachments.append(
|
|
||||||
conversation.Attachment(
|
|
||||||
media_content_id=media_content_id,
|
|
||||||
mime_type=media.mime_type,
|
|
||||||
path=media.path,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with async_get_chat_session(hass) as session:
|
|
||||||
if created_files:
|
|
||||||
|
|
||||||
def cleanup_files() -> None:
|
|
||||||
"""Cleanup temporary files."""
|
|
||||||
for file in created_files:
|
|
||||||
file.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def cleanup_files_callback() -> None:
|
|
||||||
"""Cleanup temporary files."""
|
|
||||||
hass.async_add_executor_job(cleanup_files)
|
|
||||||
|
|
||||||
session.async_on_cleanup(cleanup_files_callback)
|
|
||||||
|
|
||||||
return await entity.internal_async_generate_data(
|
|
||||||
session,
|
|
||||||
GenDataTask(
|
|
||||||
name=task_name,
|
name=task_name,
|
||||||
instructions=instructions,
|
instructions=instructions,
|
||||||
structure=structure,
|
)
|
||||||
attachments=resolved_attachments or None,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class GenDataTask:
|
class GenTextTask:
|
||||||
"""Gen data task to be processed."""
|
"""Gen text task to be processed."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
"""Name of the task."""
|
"""Name of the task."""
|
||||||
@@ -140,30 +45,24 @@ class GenDataTask:
|
|||||||
instructions: str
|
instructions: str
|
||||||
"""Instructions on what needs to be done."""
|
"""Instructions on what needs to be done."""
|
||||||
|
|
||||||
structure: vol.Schema | None = None
|
|
||||||
"""Optional structure for the data to be generated."""
|
|
||||||
|
|
||||||
attachments: list[conversation.Attachment] | None = None
|
|
||||||
"""List of attachments to go along the instructions."""
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Return task as a string."""
|
"""Return task as a string."""
|
||||||
return f"<GenDataTask {self.name}: {id(self)}>"
|
return f"<GenTextTask {self.name}: {id(self)}>"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class GenDataTaskResult:
|
class GenTextTaskResult:
|
||||||
"""Result of gen data task."""
|
"""Result of gen text task."""
|
||||||
|
|
||||||
conversation_id: str
|
conversation_id: str
|
||||||
"""Unique identifier for the conversation."""
|
"""Unique identifier for the conversation."""
|
||||||
|
|
||||||
data: Any
|
result: str
|
||||||
"""Data generated by the task."""
|
"""Result of the task."""
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, str]:
|
||||||
"""Return result as a dict."""
|
"""Return result as a dict."""
|
||||||
return {
|
return {
|
||||||
"conversation_id": self.conversation_id,
|
"conversation_id": self.conversation_id,
|
||||||
"data": self.data,
|
"result": self.result,
|
||||||
}
|
}
|
||||||
|
@@ -6,7 +6,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "platinum",
|
|
||||||
"requirements": ["airgradient==0.9.2"],
|
"requirements": ["airgradient==0.9.2"],
|
||||||
"zeroconf": ["_airgradient._tcp.local."]
|
"zeroconf": ["_airgradient._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@@ -14,9 +14,9 @@ rules:
|
|||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
This integration does not provide additional actions.
|
This integration does not provide additional actions.
|
||||||
docs-high-level-description: done
|
docs-high-level-description: todo
|
||||||
docs-installation-instructions: done
|
docs-installation-instructions: todo
|
||||||
docs-removal-instructions: done
|
docs-removal-instructions: todo
|
||||||
entity-event-setup:
|
entity-event-setup:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
@@ -34,7 +34,7 @@ rules:
|
|||||||
docs-configuration-parameters:
|
docs-configuration-parameters:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: No options to configure
|
comment: No options to configure
|
||||||
docs-installation-parameters: done
|
docs-installation-parameters: todo
|
||||||
entity-unavailable: done
|
entity-unavailable: done
|
||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: done
|
log-when-unavailable: done
|
||||||
@@ -43,19 +43,23 @@ rules:
|
|||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
This integration does not require authentication.
|
This integration does not require authentication.
|
||||||
test-coverage: done
|
test-coverage: todo
|
||||||
# Gold
|
# Gold
|
||||||
devices: done
|
devices: done
|
||||||
diagnostics: done
|
diagnostics: done
|
||||||
discovery-update-info: done
|
discovery-update-info:
|
||||||
discovery: done
|
status: todo
|
||||||
docs-data-update: done
|
comment: DHCP is still possible
|
||||||
docs-examples: done
|
discovery:
|
||||||
docs-known-limitations: done
|
status: todo
|
||||||
docs-supported-devices: done
|
comment: DHCP is still possible
|
||||||
docs-supported-functions: done
|
docs-data-update: todo
|
||||||
docs-troubleshooting: done
|
docs-examples: todo
|
||||||
docs-use-cases: done
|
docs-known-limitations: todo
|
||||||
|
docs-supported-devices: todo
|
||||||
|
docs-supported-functions: todo
|
||||||
|
docs-troubleshooting: todo
|
||||||
|
docs-use-cases: todo
|
||||||
dynamic-devices:
|
dynamic-devices:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
|
@@ -39,14 +39,14 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
try:
|
try:
|
||||||
location_point_valid = await check_location(
|
location_point_valid = await test_location(
|
||||||
websession,
|
websession,
|
||||||
user_input["api_key"],
|
user_input["api_key"],
|
||||||
user_input["latitude"],
|
user_input["latitude"],
|
||||||
user_input["longitude"],
|
user_input["longitude"],
|
||||||
)
|
)
|
||||||
if not location_point_valid:
|
if not location_point_valid:
|
||||||
location_nearest_valid = await check_location(
|
location_nearest_valid = await test_location(
|
||||||
websession,
|
websession,
|
||||||
user_input["api_key"],
|
user_input["api_key"],
|
||||||
user_input["latitude"],
|
user_input["latitude"],
|
||||||
@@ -88,7 +88,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_location(
|
async def test_location(
|
||||||
client: ClientSession,
|
client: ClientSession,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
latitude: float,
|
latitude: float,
|
||||||
|
@@ -45,6 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo
|
|||||||
# Store Entity and Initialize Platforms
|
# Store Entity and Initialize Platforms
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
|
# Listen for option changes
|
||||||
|
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
# Clean up unused device entries with no entities
|
# Clean up unused device entries with no entities
|
||||||
@@ -85,3 +88,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
async def async_unload_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Handle options update."""
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
@@ -13,7 +13,7 @@ from homeassistant.config_entries import (
|
|||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlowWithReload,
|
OptionsFlow,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
@@ -126,7 +126,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
return AirNowOptionsFlowHandler()
|
return AirNowOptionsFlowHandler()
|
||||||
|
|
||||||
|
|
||||||
class AirNowOptionsFlowHandler(OptionsFlowWithReload):
|
class AirNowOptionsFlowHandler(OptionsFlow):
|
||||||
"""Handle an options flow for AirNow."""
|
"""Handle an options flow for AirNow."""
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(
|
||||||
|
@@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
"""Update data via library."""
|
"""Update data via library."""
|
||||||
data: dict[str, Any] = {}
|
data = {}
|
||||||
try:
|
try:
|
||||||
obs = await self.airnow.observations.latLong(
|
obs = await self.airnow.observations.latLong(
|
||||||
self.latitude,
|
self.latitude,
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airnow",
|
"documentation": "https://www.home-assistant.io/integrations/airnow",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyairnow"],
|
"loggers": ["pyairnow"],
|
||||||
"requirements": ["pyairnow==1.3.1"]
|
"requirements": ["pyairnow==1.2.1"]
|
||||||
}
|
}
|
||||||
|
@@ -6,5 +6,6 @@ CONF_RETURN_AVERAGE: Final = "return_average"
|
|||||||
CONF_CLIP_NEGATIVE: Final = "clip_negatives"
|
CONF_CLIP_NEGATIVE: Final = "clip_negatives"
|
||||||
DOMAIN: Final = "airq"
|
DOMAIN: Final = "airq"
|
||||||
MANUFACTURER: Final = "CorantGmbH"
|
MANUFACTURER: Final = "CorantGmbH"
|
||||||
|
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
|
||||||
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
|
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
|
||||||
UPDATE_INTERVAL: float = 10.0
|
UPDATE_INTERVAL: float = 10.0
|
||||||
|
@@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator):
|
|||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||||
)
|
)
|
||||||
session = async_create_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
self.airq = AirQ(
|
self.airq = AirQ(
|
||||||
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
|
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
|
||||||
)
|
)
|
||||||
|
@@ -4,6 +4,9 @@
|
|||||||
"health_index": {
|
"health_index": {
|
||||||
"default": "mdi:heart-pulse"
|
"default": "mdi:heart-pulse"
|
||||||
},
|
},
|
||||||
|
"absolute_humidity": {
|
||||||
|
"default": "mdi:water"
|
||||||
|
},
|
||||||
"oxygen": {
|
"oxygen": {
|
||||||
"default": "mdi:leaf"
|
"default": "mdi:leaf"
|
||||||
},
|
},
|
||||||
|
@@ -14,7 +14,6 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
|
||||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||||
CONCENTRATION_PARTS_PER_BILLION,
|
CONCENTRATION_PARTS_PER_BILLION,
|
||||||
@@ -29,7 +28,10 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import AirQConfigEntry, AirQCoordinator
|
from . import AirQConfigEntry, AirQCoordinator
|
||||||
from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER
|
from .const import (
|
||||||
|
ACTIVITY_BECQUEREL_PER_CUBIC_METER,
|
||||||
|
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
|||||||
),
|
),
|
||||||
AirQEntityDescription(
|
AirQEntityDescription(
|
||||||
key="humidity_abs",
|
key="humidity_abs",
|
||||||
device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY,
|
translation_key="absolute_humidity",
|
||||||
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value=lambda data: data.get("humidity_abs"),
|
value=lambda data: data.get("humidity_abs"),
|
||||||
|
@@ -93,6 +93,9 @@
|
|||||||
"health_index": {
|
"health_index": {
|
||||||
"name": "Health index"
|
"name": "Health index"
|
||||||
},
|
},
|
||||||
|
"absolute_humidity": {
|
||||||
|
"name": "Absolute humidity"
|
||||||
|
},
|
||||||
"hydrogen": {
|
"hydrogen": {
|
||||||
"name": "Hydrogen"
|
"name": "Hydrogen"
|
||||||
},
|
},
|
||||||
|
@@ -45,8 +45,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
await self.async_set_unique_id(user_input[CONF_ID])
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await airthings.get_token(
|
await airthings.get_token(
|
||||||
@@ -62,6 +60,9 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
_LOGGER.exception("Unexpected exception")
|
_LOGGER.exception("Unexpected exception")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
else:
|
else:
|
||||||
|
await self.async_set_unique_id(user_input[CONF_ID])
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
return self.async_create_entry(title="Airthings", data=user_input)
|
return self.async_create_entry(title="Airthings", data=user_input)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
|
@@ -150,7 +150,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
entities = [
|
entities = [
|
||||||
AirthingsDeviceSensor(
|
AirthingsHeaterEnergySensor(
|
||||||
coordinator,
|
coordinator,
|
||||||
airthings_device,
|
airthings_device,
|
||||||
SENSORS[sensor_types],
|
SENSORS[sensor_types],
|
||||||
@@ -162,7 +162,7 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class AirthingsDeviceSensor(
|
class AirthingsHeaterEnergySensor(
|
||||||
CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity
|
CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity
|
||||||
):
|
):
|
||||||
"""Representation of a Airthings Sensor device."""
|
"""Representation of a Airthings Sensor device."""
|
||||||
|
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aioairzone_cloud"],
|
"loggers": ["aioairzone_cloud"],
|
||||||
"requirements": ["aioairzone-cloud==0.7.0"]
|
"requirements": ["aioairzone-cloud==0.6.12"]
|
||||||
}
|
}
|
||||||
|
@@ -505,13 +505,8 @@ class ClimateCapabilities(AlexaEntity):
|
|||||||
):
|
):
|
||||||
yield AlexaThermostatController(self.hass, self.entity)
|
yield AlexaThermostatController(self.hass, self.entity)
|
||||||
yield AlexaTemperatureSensor(self.hass, self.entity)
|
yield AlexaTemperatureSensor(self.hass, self.entity)
|
||||||
if (
|
if self.entity.domain == water_heater.DOMAIN and (
|
||||||
self.entity.domain == water_heater.DOMAIN
|
supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE
|
||||||
and (
|
|
||||||
supported_features
|
|
||||||
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
|
|
||||||
)
|
|
||||||
and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST)
|
|
||||||
):
|
):
|
||||||
yield AlexaModeController(
|
yield AlexaModeController(
|
||||||
self.entity,
|
self.entity,
|
||||||
@@ -639,9 +634,7 @@ class FanCapabilities(AlexaEntity):
|
|||||||
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
|
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
|
||||||
)
|
)
|
||||||
force_range_controller = False
|
force_range_controller = False
|
||||||
if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get(
|
if supported & fan.FanEntityFeature.PRESET_MODE:
|
||||||
fan.ATTR_PRESET_MODES
|
|
||||||
):
|
|
||||||
yield AlexaModeController(
|
yield AlexaModeController(
|
||||||
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
|
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
|
||||||
)
|
)
|
||||||
@@ -679,11 +672,7 @@ class RemoteCapabilities(AlexaEntity):
|
|||||||
yield AlexaPowerController(self.entity)
|
yield AlexaPowerController(self.entity)
|
||||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||||
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
|
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
|
||||||
if (
|
if activities and supported & remote.RemoteEntityFeature.ACTIVITY:
|
||||||
activities
|
|
||||||
and (supported & remote.RemoteEntityFeature.ACTIVITY)
|
|
||||||
and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
|
|
||||||
):
|
|
||||||
yield AlexaModeController(
|
yield AlexaModeController(
|
||||||
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
|
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
|
||||||
)
|
)
|
||||||
@@ -703,9 +692,7 @@ class HumidifierCapabilities(AlexaEntity):
|
|||||||
"""Yield the supported interfaces."""
|
"""Yield the supported interfaces."""
|
||||||
yield AlexaPowerController(self.entity)
|
yield AlexaPowerController(self.entity)
|
||||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||||
if (
|
if supported & humidifier.HumidifierEntityFeature.MODES:
|
||||||
supported & humidifier.HumidifierEntityFeature.MODES
|
|
||||||
) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES):
|
|
||||||
yield AlexaModeController(
|
yield AlexaModeController(
|
||||||
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
|
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
|
||||||
)
|
)
|
||||||
|
@@ -8,7 +8,6 @@ from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
|||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.NOTIFY,
|
Platform.NOTIFY,
|
||||||
Platform.SENSOR,
|
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -29,8 +28,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
coordinator = entry.runtime_data
|
await entry.runtime_data.api.close()
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
await coordinator.api.close()
|
|
||||||
|
|
||||||
return unload_ok
|
|
||||||
|
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.api import AmazonDevice
|
||||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
@@ -29,8 +28,7 @@ PARALLEL_UPDATES = 0
|
|||||||
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||||
"""Alexa Devices binary sensor entity description."""
|
"""Alexa Devices binary sensor entity description."""
|
||||||
|
|
||||||
is_on_fn: Callable[[AmazonDevice, str], bool]
|
is_on_fn: Callable[[AmazonDevice], bool]
|
||||||
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
|
|
||||||
|
|
||||||
|
|
||||||
BINARY_SENSORS: Final = (
|
BINARY_SENSORS: Final = (
|
||||||
@@ -38,49 +36,13 @@ BINARY_SENSORS: Final = (
|
|||||||
key="online",
|
key="online",
|
||||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
is_on_fn=lambda device, _: device.online,
|
is_on_fn=lambda _device: _device.online,
|
||||||
),
|
),
|
||||||
AmazonBinarySensorEntityDescription(
|
AmazonBinarySensorEntityDescription(
|
||||||
key="bluetooth",
|
key="bluetooth",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
translation_key="bluetooth",
|
translation_key="bluetooth",
|
||||||
is_on_fn=lambda device, _: device.bluetooth_state,
|
is_on_fn=lambda _device: _device.bluetooth_state,
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="babyCryDetectionState",
|
|
||||||
translation_key="baby_cry_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="beepingApplianceDetectionState",
|
|
||||||
translation_key="beeping_appliance_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="coughDetectionState",
|
|
||||||
translation_key="cough_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="dogBarkDetectionState",
|
|
||||||
translation_key="dog_bark_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="humanPresenceDetectionState",
|
|
||||||
device_class=BinarySensorDeviceClass.MOTION,
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="waterSoundsDetectionState",
|
|
||||||
translation_key="water_sounds_detection",
|
|
||||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
|
||||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,7 +60,6 @@ async def async_setup_entry(
|
|||||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||||
for sensor_desc in BINARY_SENSORS
|
for sensor_desc in BINARY_SENSORS
|
||||||
for serial_num in coordinator.data
|
for serial_num in coordinator.data
|
||||||
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +71,4 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
|||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return True if the binary sensor is on."""
|
"""Return True if the binary sensor is on."""
|
||||||
return self.entity_description.is_on_fn(
|
return self.entity_description.is_on_fn(self.device)
|
||||||
self.device, self.entity_description.key
|
|
||||||
)
|
|
||||||
|
@@ -2,50 +2,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonEchoApi
|
from aioamazondevices.api import AmazonEchoApi
|
||||||
from aioamazondevices.exceptions import (
|
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
|
||||||
CannotAuthenticate,
|
|
||||||
CannotConnect,
|
|
||||||
CannotRetrieveData,
|
|
||||||
WrongCountry,
|
|
||||||
)
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.selector import CountrySelector
|
from homeassistant.helpers.selector import CountrySelector
|
||||||
|
|
||||||
from .const import CONF_LOGIN_DATA, DOMAIN
|
from .const import CONF_LOGIN_DATA, DOMAIN
|
||||||
|
|
||||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_PASSWORD): cv.string,
|
|
||||||
vol.Required(CONF_CODE): cv.string,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Validate the user input allows us to connect."""
|
|
||||||
|
|
||||||
api = AmazonEchoApi(
|
|
||||||
data[CONF_COUNTRY],
|
|
||||||
data[CONF_USERNAME],
|
|
||||||
data[CONF_PASSWORD],
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = await api.login_mode_interactive(data[CONF_CODE])
|
|
||||||
finally:
|
|
||||||
await api.close()
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Alexa Devices."""
|
"""Handle a config flow for Alexa Devices."""
|
||||||
@@ -56,16 +25,17 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors = {}
|
errors = {}
|
||||||
if user_input:
|
if user_input:
|
||||||
|
client = AmazonEchoApi(
|
||||||
|
user_input[CONF_COUNTRY],
|
||||||
|
user_input[CONF_USERNAME],
|
||||||
|
user_input[CONF_PASSWORD],
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
data = await validate_input(self.hass, user_input)
|
data = await client.login_mode_interactive(user_input[CONF_CODE])
|
||||||
except CannotConnect:
|
except CannotConnect:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except CannotAuthenticate:
|
except CannotAuthenticate:
|
||||||
errors["base"] = "invalid_auth"
|
errors["base"] = "invalid_auth"
|
||||||
except CannotRetrieveData:
|
|
||||||
errors["base"] = "cannot_retrieve_data"
|
|
||||||
except WrongCountry:
|
|
||||||
errors["base"] = "wrong_country"
|
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(data["customer_info"]["user_id"])
|
await self.async_set_unique_id(data["customer_info"]["user_id"])
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
@@ -74,6 +44,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=user_input[CONF_USERNAME],
|
title=user_input[CONF_USERNAME],
|
||||||
data=user_input | {CONF_LOGIN_DATA: data},
|
data=user_input | {CONF_LOGIN_DATA: data},
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
@@ -89,45 +61,3 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_reauth(
|
|
||||||
self, entry_data: Mapping[str, Any]
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle reauth flow."""
|
|
||||||
self.context["title_placeholders"] = {CONF_USERNAME: entry_data[CONF_USERNAME]}
|
|
||||||
return await self.async_step_reauth_confirm()
|
|
||||||
|
|
||||||
async def async_step_reauth_confirm(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle reauth confirm."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
reauth_entry = self._get_reauth_entry()
|
|
||||||
entry_data = reauth_entry.data
|
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
try:
|
|
||||||
await validate_input(self.hass, {**reauth_entry.data, **user_input})
|
|
||||||
except CannotConnect:
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
except CannotAuthenticate:
|
|
||||||
errors["base"] = "invalid_auth"
|
|
||||||
except CannotRetrieveData:
|
|
||||||
errors["base"] = "cannot_retrieve_data"
|
|
||||||
else:
|
|
||||||
return self.async_update_reload_and_abort(
|
|
||||||
reauth_entry,
|
|
||||||
data={
|
|
||||||
CONF_USERNAME: entry_data[CONF_USERNAME],
|
|
||||||
CONF_PASSWORD: entry_data[CONF_PASSWORD],
|
|
||||||
CONF_CODE: user_input[CONF_CODE],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="reauth_confirm",
|
|
||||||
description_placeholders={CONF_USERNAME: entry_data[CONF_USERNAME]},
|
|
||||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
@@ -12,10 +12,10 @@ from aioamazondevices.exceptions import (
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryError
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
from .const import _LOGGER, CONF_LOGIN_DATA
|
||||||
|
|
||||||
SCAN_INTERVAL = 30
|
SCAN_INTERVAL = 30
|
||||||
|
|
||||||
@@ -52,21 +52,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
try:
|
try:
|
||||||
await self.api.login_mode_stored_data()
|
await self.api.login_mode_stored_data()
|
||||||
return await self.api.get_devices_data()
|
return await self.api.get_devices_data()
|
||||||
except CannotConnect as err:
|
except (CannotConnect, CannotRetrieveData) as err:
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(f"Error occurred while updating {self.name}") from err
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_connect_with_error",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
except CannotRetrieveData as err:
|
|
||||||
raise UpdateFailed(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_retrieve_data_with_error",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
except CannotAuthenticate as err:
|
except CannotAuthenticate as err:
|
||||||
raise ConfigEntryAuthFailed(
|
raise ConfigEntryError("Could not authenticate") from err
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="invalid_auth",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
|
@@ -2,39 +2,9 @@
|
|||||||
"entity": {
|
"entity": {
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"bluetooth": {
|
"bluetooth": {
|
||||||
"default": "mdi:bluetooth-off",
|
"default": "mdi:bluetooth",
|
||||||
"state": {
|
"state": {
|
||||||
"on": "mdi:bluetooth"
|
"off": "mdi:bluetooth-off"
|
||||||
}
|
|
||||||
},
|
|
||||||
"baby_cry_detection": {
|
|
||||||
"default": "mdi:account-voice-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:account-voice"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"beeping_appliance_detection": {
|
|
||||||
"default": "mdi:bell-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:bell-ring"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"cough_detection": {
|
|
||||||
"default": "mdi:blur-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:blur"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dog_bark_detection": {
|
|
||||||
"default": "mdi:dog-side-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:dog-side"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"water_sounds_detection": {
|
|
||||||
"default": "mdi:water-pump-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:water-pump"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,6 +7,6 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioamazondevices"],
|
"loggers": ["aioamazondevices"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["aioamazondevices==3.5.1"]
|
"requirements": ["aioamazondevices==3.1.4"]
|
||||||
}
|
}
|
||||||
|
@@ -15,7 +15,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
|
|
||||||
from .coordinator import AmazonConfigEntry
|
from .coordinator import AmazonConfigEntry
|
||||||
from .entity import AmazonEntity
|
from .entity import AmazonEntity
|
||||||
from .utils import alexa_api_call
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
@@ -71,7 +70,6 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
|||||||
|
|
||||||
entity_description: AmazonNotifyEntityDescription
|
entity_description: AmazonNotifyEntityDescription
|
||||||
|
|
||||||
@alexa_api_call
|
|
||||||
async def async_send_message(
|
async def async_send_message(
|
||||||
self, message: str, title: str | None = None, **kwargs: Any
|
self, message: str, title: str | None = None, **kwargs: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@@ -26,33 +26,35 @@ rules:
|
|||||||
unique-config-entry: done
|
unique-config-entry: done
|
||||||
|
|
||||||
# Silver
|
# Silver
|
||||||
action-exceptions: done
|
action-exceptions: todo
|
||||||
config-entry-unloading: done
|
config-entry-unloading: done
|
||||||
docs-configuration-parameters: done
|
docs-configuration-parameters: todo
|
||||||
docs-installation-parameters: done
|
docs-installation-parameters: todo
|
||||||
entity-unavailable: done
|
entity-unavailable: done
|
||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: done
|
log-when-unavailable: done
|
||||||
parallel-updates: done
|
parallel-updates: done
|
||||||
reauthentication-flow: done
|
reauthentication-flow: todo
|
||||||
test-coverage: done
|
test-coverage:
|
||||||
|
status: todo
|
||||||
|
comment: all tests missing
|
||||||
|
|
||||||
# Gold
|
# Gold
|
||||||
devices: done
|
devices: done
|
||||||
diagnostics: done
|
diagnostics: todo
|
||||||
discovery-update-info:
|
discovery-update-info:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: Network information not relevant
|
comment: Network information not relevant
|
||||||
discovery:
|
discovery:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
|
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
|
||||||
docs-data-update: done
|
docs-data-update: todo
|
||||||
docs-examples: done
|
docs-examples: todo
|
||||||
docs-known-limitations: todo
|
docs-known-limitations: todo
|
||||||
docs-supported-devices: done
|
docs-supported-devices: todo
|
||||||
docs-supported-functions: done
|
docs-supported-functions: todo
|
||||||
docs-troubleshooting: todo
|
docs-troubleshooting: todo
|
||||||
docs-use-cases: done
|
docs-use-cases: todo
|
||||||
dynamic-devices: todo
|
dynamic-devices: todo
|
||||||
entity-category: done
|
entity-category: done
|
||||||
entity-device-class: done
|
entity-device-class: done
|
||||||
|
@@ -1,88 +0,0 @@
|
|||||||
"""Support for sensors."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
|
||||||
SensorDeviceClass,
|
|
||||||
SensorEntity,
|
|
||||||
SensorEntityDescription,
|
|
||||||
)
|
|
||||||
from homeassistant.const import LIGHT_LUX, UnitOfTemperature
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
from homeassistant.helpers.typing import StateType
|
|
||||||
|
|
||||||
from .coordinator import AmazonConfigEntry
|
|
||||||
from .entity import AmazonEntity
|
|
||||||
|
|
||||||
# Coordinator is used to centralize the data updates
|
|
||||||
PARALLEL_UPDATES = 0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
|
||||||
class AmazonSensorEntityDescription(SensorEntityDescription):
|
|
||||||
"""Amazon Devices sensor entity description."""
|
|
||||||
|
|
||||||
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
SENSORS: Final = (
|
|
||||||
AmazonSensorEntityDescription(
|
|
||||||
key="temperature",
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
native_unit_of_measurement_fn=lambda device, _key: (
|
|
||||||
UnitOfTemperature.CELSIUS
|
|
||||||
if device.sensors[_key].scale == "CELSIUS"
|
|
||||||
else UnitOfTemperature.FAHRENHEIT
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AmazonSensorEntityDescription(
|
|
||||||
key="illuminance",
|
|
||||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
|
||||||
native_unit_of_measurement=LIGHT_LUX,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: AmazonConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Amazon Devices sensors based on a config entry."""
|
|
||||||
|
|
||||||
coordinator = entry.runtime_data
|
|
||||||
|
|
||||||
async_add_entities(
|
|
||||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
|
||||||
for sensor_desc in SENSORS
|
|
||||||
for serial_num in coordinator.data
|
|
||||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
|
||||||
"""Sensor device."""
|
|
||||||
|
|
||||||
entity_description: AmazonSensorEntityDescription
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_unit_of_measurement(self) -> str | None:
|
|
||||||
"""Return the unit of measurement of the sensor."""
|
|
||||||
if self.entity_description.native_unit_of_measurement_fn:
|
|
||||||
return self.entity_description.native_unit_of_measurement_fn(
|
|
||||||
self.device, self.entity_description.key
|
|
||||||
)
|
|
||||||
|
|
||||||
return super().native_unit_of_measurement
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_value(self) -> StateType:
|
|
||||||
"""Return the state of the sensor."""
|
|
||||||
return self.device.sensors[self.entity_description.key].value
|
|
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
|
"data_country": "Country code",
|
||||||
"data_code": "One-time password (OTP code)",
|
"data_code": "One-time password (OTP code)",
|
||||||
"data_description_country": "The country where your Amazon account is registered.",
|
"data_description_country": "The country of your Amazon account.",
|
||||||
"data_description_username": "The email address of your Amazon account.",
|
"data_description_username": "The email address of your Amazon account.",
|
||||||
"data_description_password": "The password of your Amazon account.",
|
"data_description_password": "The password of your Amazon account.",
|
||||||
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
|
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
|
||||||
@@ -11,10 +12,10 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"country": "[%key:common::config_flow::data::country%]",
|
"country": "[%key:component::alexa_devices::common::data_country%]",
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
"code": "[%key:component::alexa_devices::common::data_code%]"
|
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
||||||
@@ -22,30 +23,17 @@
|
|||||||
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
||||||
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"reauth_confirm": {
|
|
||||||
"data": {
|
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
|
||||||
"code": "[%key:component::alexa_devices::common::data_code%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
|
||||||
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"cannot_retrieve_data": "Unable to retrieve data from Amazon. Please try again later.",
|
|
||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
|
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -53,21 +41,6 @@
|
|||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"bluetooth": {
|
"bluetooth": {
|
||||||
"name": "Bluetooth"
|
"name": "Bluetooth"
|
||||||
},
|
|
||||||
"baby_cry_detection": {
|
|
||||||
"name": "Baby crying"
|
|
||||||
},
|
|
||||||
"beeping_appliance_detection": {
|
|
||||||
"name": "Beeping appliance"
|
|
||||||
},
|
|
||||||
"cough_detection": {
|
|
||||||
"name": "Coughing"
|
|
||||||
},
|
|
||||||
"dog_bark_detection": {
|
|
||||||
"name": "Dog barking"
|
|
||||||
},
|
|
||||||
"water_sounds_detection": {
|
|
||||||
"name": "Water sounds"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notify": {
|
"notify": {
|
||||||
@@ -83,13 +56,5 @@
|
|||||||
"name": "Do not disturb"
|
"name": "Do not disturb"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"exceptions": {
|
|
||||||
"cannot_connect_with_error": {
|
|
||||||
"message": "Error connecting: {error}"
|
|
||||||
},
|
|
||||||
"cannot_retrieve_data_with_error": {
|
|
||||||
"message": "Error retrieving data: {error}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,7 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
|
|
||||||
from .coordinator import AmazonConfigEntry
|
from .coordinator import AmazonConfigEntry
|
||||||
from .entity import AmazonEntity
|
from .entity import AmazonEntity
|
||||||
from .utils import alexa_api_call
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
@@ -61,7 +60,6 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
|||||||
|
|
||||||
entity_description: AmazonSwitchEntityDescription
|
entity_description: AmazonSwitchEntityDescription
|
||||||
|
|
||||||
@alexa_api_call
|
|
||||||
async def _switch_set_state(self, state: bool) -> None:
|
async def _switch_set_state(self, state: bool) -> None:
|
||||||
"""Set desired switch state."""
|
"""Set desired switch state."""
|
||||||
method = getattr(self.coordinator.api, self.entity_description.method)
|
method = getattr(self.coordinator.api, self.entity_description.method)
|
||||||
|
@@ -1,40 +0,0 @@
|
|||||||
"""Utils for Alexa Devices."""
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable, Coroutine
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Concatenate
|
|
||||||
|
|
||||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
|
||||||
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .entity import AmazonEntity
|
|
||||||
|
|
||||||
|
|
||||||
def alexa_api_call[_T: AmazonEntity, **_P](
|
|
||||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
|
||||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
|
||||||
"""Catch Alexa API call exceptions."""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
|
||||||
"""Wrap all command methods."""
|
|
||||||
try:
|
|
||||||
await func(self, *args, **kwargs)
|
|
||||||
except CannotConnect as err:
|
|
||||||
self.coordinator.last_update_success = False
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_connect_with_error",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
except CannotRetrieveData as err:
|
|
||||||
self.coordinator.last_update_success = False
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="cannot_retrieve_data_with_error",
|
|
||||||
translation_placeholders={"error": repr(err)},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
return cmd_wrapper
|
|
@@ -1,27 +0,0 @@
|
|||||||
"""The Altruist integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
from .coordinator import AltruistConfigEntry, AltruistDataUpdateCoordinator
|
|
||||||
|
|
||||||
PLATFORMS = [Platform.SENSOR]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool:
|
|
||||||
"""Set up Altruist from a config entry."""
|
|
||||||
|
|
||||||
coordinator = AltruistDataUpdateCoordinator(hass, entry)
|
|
||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
@@ -1,107 +0,0 @@
|
|||||||
"""Config flow for the Altruist integration."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
|
||||||
|
|
||||||
from .const import CONF_HOST, DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AltruistConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Altruist."""
|
|
||||||
|
|
||||||
device: AltruistDeviceModel
|
|
||||||
|
|
||||||
async def async_step_user(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle the initial step."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
ip_address = ""
|
|
||||||
if user_input is not None:
|
|
||||||
ip_address = user_input[CONF_HOST]
|
|
||||||
try:
|
|
||||||
client = await AltruistClient.from_ip_address(
|
|
||||||
async_get_clientsession(self.hass), ip_address
|
|
||||||
)
|
|
||||||
except AltruistError:
|
|
||||||
errors["base"] = "no_device_found"
|
|
||||||
else:
|
|
||||||
self.device = client.device
|
|
||||||
await self.async_set_unique_id(
|
|
||||||
client.device_id, raise_on_progress=False
|
|
||||||
)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=self.device.id,
|
|
||||||
data={
|
|
||||||
CONF_HOST: ip_address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
data_schema = self.add_suggested_values_to_schema(
|
|
||||||
vol.Schema({vol.Required(CONF_HOST): str}),
|
|
||||||
{CONF_HOST: ip_address},
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=data_schema,
|
|
||||||
errors=errors,
|
|
||||||
description_placeholders={
|
|
||||||
"ip_address": ip_address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_zeroconf(
|
|
||||||
self, discovery_info: ZeroconfServiceInfo
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle zeroconf discovery."""
|
|
||||||
_LOGGER.debug("Zeroconf discovery: %s", discovery_info)
|
|
||||||
try:
|
|
||||||
client = await AltruistClient.from_ip_address(
|
|
||||||
async_get_clientsession(self.hass), str(discovery_info.ip_address)
|
|
||||||
)
|
|
||||||
except AltruistError:
|
|
||||||
return self.async_abort(reason="no_device_found")
|
|
||||||
|
|
||||||
self.device = client.device
|
|
||||||
_LOGGER.debug("Zeroconf device: %s", client.device)
|
|
||||||
await self.async_set_unique_id(client.device_id)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
self.context.update(
|
|
||||||
{
|
|
||||||
"title_placeholders": {
|
|
||||||
"name": self.device.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return await self.async_step_discovery_confirm()
|
|
||||||
|
|
||||||
async def async_step_discovery_confirm(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Confirm discovery."""
|
|
||||||
if user_input is not None:
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=self.device.id,
|
|
||||||
data={
|
|
||||||
CONF_HOST: self.device.ip_address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self._set_confirm_only()
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="discovery_confirm",
|
|
||||||
description_placeholders={
|
|
||||||
"model": self.device.id,
|
|
||||||
},
|
|
||||||
)
|
|
@@ -1,5 +0,0 @@
|
|||||||
"""Constants for the Altruist integration."""
|
|
||||||
|
|
||||||
DOMAIN = "altruist"
|
|
||||||
|
|
||||||
CONF_HOST = "host"
|
|
@@ -1,64 +0,0 @@
|
|||||||
"""Coordinator module for Altruist integration in Home Assistant.
|
|
||||||
|
|
||||||
This module defines the AltruistDataUpdateCoordinator class, which manages
|
|
||||||
data updates for Altruist sensors using the AltruistClient.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from altruistclient import AltruistClient, AltruistError
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
|
|
||||||
from .const import CONF_HOST
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
|
||||||
|
|
||||||
type AltruistConfigEntry = ConfigEntry[AltruistDataUpdateCoordinator]
|
|
||||||
|
|
||||||
|
|
||||||
class AltruistDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
|
|
||||||
"""Coordinates data updates for Altruist sensors."""
|
|
||||||
|
|
||||||
client: AltruistClient
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: AltruistConfigEntry,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the data update coordinator for Altruist sensors."""
|
|
||||||
device_id = config_entry.unique_id
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
logger=_LOGGER,
|
|
||||||
config_entry=config_entry,
|
|
||||||
name=f"Altruist {device_id}",
|
|
||||||
update_interval=UPDATE_INTERVAL,
|
|
||||||
)
|
|
||||||
self._ip_address = config_entry.data[CONF_HOST]
|
|
||||||
|
|
||||||
async def _async_setup(self) -> None:
|
|
||||||
try:
|
|
||||||
self.client = await AltruistClient.from_ip_address(
|
|
||||||
async_get_clientsession(self.hass), self._ip_address
|
|
||||||
)
|
|
||||||
await self.client.fetch_data()
|
|
||||||
except AltruistError as e:
|
|
||||||
raise ConfigEntryNotReady("Error in Altruist setup") from e
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, str]:
|
|
||||||
try:
|
|
||||||
fetched_data = await self.client.fetch_data()
|
|
||||||
except AltruistError as ex:
|
|
||||||
raise UpdateFailed(
|
|
||||||
f"The Altruist {self.client.device_id} is unavailable: {ex}"
|
|
||||||
) from ex
|
|
||||||
return {item["value_type"]: item["value"] for item in fetched_data}
|
|
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"entity": {
|
|
||||||
"sensor": {
|
|
||||||
"pm_10": {
|
|
||||||
"default": "mdi:thought-bubble"
|
|
||||||
},
|
|
||||||
"pm_25": {
|
|
||||||
"default": "mdi:thought-bubble-outline"
|
|
||||||
},
|
|
||||||
"radiation": {
|
|
||||||
"default": "mdi:radioactive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "altruist",
|
|
||||||
"name": "Altruist",
|
|
||||||
"codeowners": ["@airalab", "@LoSk-p"],
|
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/altruist",
|
|
||||||
"integration_type": "device",
|
|
||||||
"iot_class": "local_polling",
|
|
||||||
"quality_scale": "bronze",
|
|
||||||
"requirements": ["altruistclient==0.1.1"],
|
|
||||||
"zeroconf": ["_altruist._tcp.local."]
|
|
||||||
}
|
|
@@ -1,83 +0,0 @@
|
|||||||
rules:
|
|
||||||
# Bronze
|
|
||||||
action-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide additional actions.
|
|
||||||
appropriate-polling: done
|
|
||||||
brands: done
|
|
||||||
common-modules: done
|
|
||||||
config-flow-test-coverage: done
|
|
||||||
config-flow: done
|
|
||||||
dependency-transparency: done
|
|
||||||
docs-actions:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide additional actions.
|
|
||||||
docs-high-level-description: done
|
|
||||||
docs-installation-instructions: done
|
|
||||||
docs-removal-instructions: done
|
|
||||||
entity-event-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
Entities of this integration does not explicitly subscribe to events.
|
|
||||||
entity-unique-id: done
|
|
||||||
has-entity-name: done
|
|
||||||
runtime-data: done
|
|
||||||
test-before-configure: done
|
|
||||||
test-before-setup: done
|
|
||||||
unique-config-entry: done
|
|
||||||
|
|
||||||
# Silver
|
|
||||||
action-exceptions:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide additional actions.
|
|
||||||
config-entry-unloading: done
|
|
||||||
docs-configuration-parameters:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide options flow.
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: done
|
|
||||||
integration-owner: done
|
|
||||||
log-when-unavailable: done
|
|
||||||
parallel-updates: todo
|
|
||||||
reauthentication-flow: todo
|
|
||||||
test-coverage: done
|
|
||||||
|
|
||||||
# Gold
|
|
||||||
devices: done
|
|
||||||
diagnostics: todo
|
|
||||||
discovery-update-info: todo
|
|
||||||
discovery: done
|
|
||||||
docs-data-update: todo
|
|
||||||
docs-examples: todo
|
|
||||||
docs-known-limitations: todo
|
|
||||||
docs-supported-devices: todo
|
|
||||||
docs-supported-functions: todo
|
|
||||||
docs-troubleshooting: todo
|
|
||||||
docs-use-cases: todo
|
|
||||||
dynamic-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
Device type integration
|
|
||||||
entity-category: todo
|
|
||||||
entity-device-class: done
|
|
||||||
entity-disabled-by-default: todo
|
|
||||||
entity-translations: done
|
|
||||||
exception-translations: todo
|
|
||||||
icon-translations: done
|
|
||||||
reconfiguration-flow: todo
|
|
||||||
repair-issues:
|
|
||||||
status: exempt
|
|
||||||
comment: No known use cases for repair issues or flows, yet
|
|
||||||
stale-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
Device type integration
|
|
||||||
|
|
||||||
# Platinum
|
|
||||||
async-dependency: done
|
|
||||||
inject-websession: done
|
|
||||||
strict-typing: done
|
|
@@ -1,249 +0,0 @@
|
|||||||
"""Defines the Altruist sensor platform."""
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
|
||||||
SensorDeviceClass,
|
|
||||||
SensorEntity,
|
|
||||||
SensorEntityDescription,
|
|
||||||
SensorStateClass,
|
|
||||||
)
|
|
||||||
from homeassistant.const import (
|
|
||||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
|
||||||
CONCENTRATION_PARTS_PER_MILLION,
|
|
||||||
PERCENTAGE,
|
|
||||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
|
||||||
EntityCategory,
|
|
||||||
UnitOfPressure,
|
|
||||||
UnitOfSoundPressure,
|
|
||||||
UnitOfTemperature,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
|
|
||||||
from . import AltruistConfigEntry
|
|
||||||
from .coordinator import AltruistDataUpdateCoordinator
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AltruistSensorEntityDescription(SensorEntityDescription):
|
|
||||||
"""Class to describe a Sensor entity."""
|
|
||||||
|
|
||||||
native_value_fn: Callable[[str], float] = float
|
|
||||||
state_class = SensorStateClass.MEASUREMENT
|
|
||||||
|
|
||||||
|
|
||||||
SENSOR_DESCRIPTIONS = [
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
|
||||||
key="BME280_humidity",
|
|
||||||
translation_key="humidity",
|
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "BME280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PRESSURE,
|
|
||||||
key="BME280_pressure",
|
|
||||||
translation_key="pressure",
|
|
||||||
native_unit_of_measurement=UnitOfPressure.PA,
|
|
||||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
translation_placeholders={"sensor_name": "BME280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="BME280_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "BME280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PRESSURE,
|
|
||||||
key="BMP_pressure",
|
|
||||||
translation_key="pressure",
|
|
||||||
native_unit_of_measurement=UnitOfPressure.PA,
|
|
||||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
translation_placeholders={"sensor_name": "BMP"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="BMP_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "BMP"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="BMP280_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "BMP280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PRESSURE,
|
|
||||||
key="BMP280_pressure",
|
|
||||||
translation_key="pressure",
|
|
||||||
native_unit_of_measurement=UnitOfPressure.PA,
|
|
||||||
suggested_unit_of_measurement=UnitOfPressure.MMHG,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
translation_placeholders={"sensor_name": "BMP280"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
|
||||||
key="HTU21D_humidity",
|
|
||||||
translation_key="humidity",
|
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "HTU21D"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="HTU21D_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "HTU21D"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PM10,
|
|
||||||
translation_key="pm_10",
|
|
||||||
key="SDS_P1",
|
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.PM25,
|
|
||||||
translation_key="pm_25",
|
|
||||||
key="SDS_P2",
|
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
|
||||||
key="SHT3X_humidity",
|
|
||||||
translation_key="humidity",
|
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "SHT3X"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
|
||||||
key="SHT3X_temperature",
|
|
||||||
translation_key="temperature",
|
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "SHT3X"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
|
||||||
key="signal",
|
|
||||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
|
||||||
key="PCBA_noiseMax",
|
|
||||||
translation_key="noise_max",
|
|
||||||
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
|
||||||
key="PCBA_noiseAvg",
|
|
||||||
translation_key="noise_avg",
|
|
||||||
native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.CO2,
|
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
|
||||||
translation_key="co2",
|
|
||||||
key="CCS_CO2",
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "CCS"},
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
|
||||||
key="CCS_TVOC",
|
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
key="GC",
|
|
||||||
native_unit_of_measurement="μR/h",
|
|
||||||
translation_key="radiation",
|
|
||||||
suggested_display_precision=2,
|
|
||||||
),
|
|
||||||
AltruistSensorEntityDescription(
|
|
||||||
device_class=SensorDeviceClass.CO2,
|
|
||||||
translation_key="co2",
|
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
|
||||||
key="SCD4x_co2",
|
|
||||||
suggested_display_precision=2,
|
|
||||||
translation_placeholders={"sensor_name": "SCD4x"},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: AltruistConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Add sensors for passed config_entry in HA."""
|
|
||||||
coordinator = config_entry.runtime_data
|
|
||||||
async_add_entities(
|
|
||||||
AltruistSensor(coordinator, sensor_description)
|
|
||||||
for sensor_description in SENSOR_DESCRIPTIONS
|
|
||||||
if sensor_description.key in coordinator.data
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AltruistSensor(CoordinatorEntity[AltruistDataUpdateCoordinator], SensorEntity):
|
|
||||||
"""Implementation of a Altruist sensor."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: AltruistDataUpdateCoordinator,
|
|
||||||
description: AltruistSensorEntityDescription,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the Altruist sensor."""
|
|
||||||
super().__init__(coordinator)
|
|
||||||
self._device = coordinator.client.device
|
|
||||||
self.entity_description: AltruistSensorEntityDescription = description
|
|
||||||
self._attr_unique_id = f"{self._device.id}-{description.key}"
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
connections={(CONNECTION_NETWORK_MAC, self._device.id)},
|
|
||||||
manufacturer="Robonomics",
|
|
||||||
model="Altruist",
|
|
||||||
sw_version=self._device.fw_version,
|
|
||||||
configuration_url=f"http://{self._device.ip_address}",
|
|
||||||
serial_number=self._device.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
"""Return True if entity is available."""
|
|
||||||
return (
|
|
||||||
super().available and self.entity_description.key in self.coordinator.data
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_value(self) -> float | int:
|
|
||||||
"""Return the native value of the sensor."""
|
|
||||||
string_value = self.coordinator.data[self.entity_description.key]
|
|
||||||
return self.entity_description.native_value_fn(string_value)
|
|
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"flow_title": "{name}",
|
|
||||||
"step": {
|
|
||||||
"discovery_confirm": {
|
|
||||||
"description": "Do you want to start setup {model}?"
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"data": {
|
|
||||||
"host": "[%key:common::config_flow::data::host%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "Altruist IP address or hostname in the local network"
|
|
||||||
},
|
|
||||||
"description": "Fill in Altruist IP address or hostname in your local network"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"no_device_found": "[%key:common::config_flow::error::cannot_connect%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"entity": {
|
|
||||||
"sensor": {
|
|
||||||
"humidity": {
|
|
||||||
"name": "{sensor_name} humidity"
|
|
||||||
},
|
|
||||||
"pressure": {
|
|
||||||
"name": "{sensor_name} pressure"
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"name": "{sensor_name} temperature"
|
|
||||||
},
|
|
||||||
"noise_max": {
|
|
||||||
"name": "Maximum noise"
|
|
||||||
},
|
|
||||||
"noise_avg": {
|
|
||||||
"name": "Average noise"
|
|
||||||
},
|
|
||||||
"co2": {
|
|
||||||
"name": "{sensor_name} CO2"
|
|
||||||
},
|
|
||||||
"radiation": {
|
|
||||||
"name": "Radiation level"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -2,22 +2,11 @@
|
|||||||
|
|
||||||
import amberelectric
|
import amberelectric
|
||||||
|
|
||||||
from homeassistant.components.sensor import ConfigType
|
|
||||||
from homeassistant.const import CONF_API_TOKEN
|
from homeassistant.const import CONF_API_TOKEN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
|
|
||||||
from .const import CONF_SITE_ID, DOMAIN, PLATFORMS
|
from .const import CONF_SITE_ID, PLATFORMS
|
||||||
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
|
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
|
||||||
from .services import setup_services
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
||||||
"""Set up the Amber component."""
|
|
||||||
setup_services(hass)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool:
|
||||||
|
@@ -1,24 +1,14 @@
|
|||||||
"""Amber Electric Constants."""
|
"""Amber Electric Constants."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
DOMAIN: Final = "amberelectric"
|
DOMAIN = "amberelectric"
|
||||||
CONF_SITE_NAME = "site_name"
|
CONF_SITE_NAME = "site_name"
|
||||||
CONF_SITE_ID = "site_id"
|
CONF_SITE_ID = "site_id"
|
||||||
|
|
||||||
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
|
|
||||||
ATTR_CHANNEL_TYPE = "channel_type"
|
|
||||||
|
|
||||||
ATTRIBUTION = "Data provided by Amber Electric"
|
ATTRIBUTION = "Data provided by Amber Electric"
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||||
|
|
||||||
SERVICE_GET_FORECASTS = "get_forecasts"
|
|
||||||
|
|
||||||
GENERAL_CHANNEL = "general"
|
|
||||||
CONTROLLED_LOAD_CHANNEL = "controlled_load"
|
|
||||||
FEED_IN_CHANNEL = "feed_in"
|
|
||||||
|
@@ -10,6 +10,7 @@ from amberelectric.models.actual_interval import ActualInterval
|
|||||||
from amberelectric.models.channel import ChannelType
|
from amberelectric.models.channel import ChannelType
|
||||||
from amberelectric.models.current_interval import CurrentInterval
|
from amberelectric.models.current_interval import CurrentInterval
|
||||||
from amberelectric.models.forecast_interval import ForecastInterval
|
from amberelectric.models.forecast_interval import ForecastInterval
|
||||||
|
from amberelectric.models.price_descriptor import PriceDescriptor
|
||||||
from amberelectric.rest import ApiException
|
from amberelectric.rest import ApiException
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -17,7 +18,6 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import LOGGER
|
from .const import LOGGER
|
||||||
from .helpers import normalize_descriptor
|
|
||||||
|
|
||||||
type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
|
type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
|
||||||
|
|
||||||
@@ -49,6 +49,27 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
|
|||||||
return interval.channel_type == ChannelType.FEEDIN
|
return interval.channel_type == ChannelType.FEEDIN
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
|
||||||
|
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
|
||||||
|
if descriptor is None:
|
||||||
|
return None
|
||||||
|
if descriptor.value == "spike":
|
||||||
|
return "spike"
|
||||||
|
if descriptor.value == "high":
|
||||||
|
return "high"
|
||||||
|
if descriptor.value == "neutral":
|
||||||
|
return "neutral"
|
||||||
|
if descriptor.value == "low":
|
||||||
|
return "low"
|
||||||
|
if descriptor.value == "veryLow":
|
||||||
|
return "very_low"
|
||||||
|
if descriptor.value == "extremelyLow":
|
||||||
|
return "extremely_low"
|
||||||
|
if descriptor.value == "negative":
|
||||||
|
return "negative"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AmberUpdateCoordinator(DataUpdateCoordinator):
|
class AmberUpdateCoordinator(DataUpdateCoordinator):
|
||||||
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
|
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
|
||||||
|
|
||||||
@@ -82,7 +103,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
"grid": {},
|
"grid": {},
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = self._api.get_current_prices(self.site_id, next=288)
|
data = self._api.get_current_prices(self.site_id, next=48)
|
||||||
intervals = [interval.actual_instance for interval in data]
|
intervals = [interval.actual_instance for interval in data]
|
||||||
except ApiException as api_exception:
|
except ApiException as api_exception:
|
||||||
raise UpdateFailed("Missing price data, skipping update") from api_exception
|
raise UpdateFailed("Missing price data, skipping update") from api_exception
|
||||||
|
@@ -1,25 +0,0 @@
|
|||||||
"""Formatting helpers used to convert things."""
|
|
||||||
|
|
||||||
from amberelectric.models.price_descriptor import PriceDescriptor
|
|
||||||
|
|
||||||
DESCRIPTOR_MAP: dict[str, str] = {
|
|
||||||
PriceDescriptor.SPIKE: "spike",
|
|
||||||
PriceDescriptor.HIGH: "high",
|
|
||||||
PriceDescriptor.NEUTRAL: "neutral",
|
|
||||||
PriceDescriptor.LOW: "low",
|
|
||||||
PriceDescriptor.VERYLOW: "very_low",
|
|
||||||
PriceDescriptor.EXTREMELYLOW: "extremely_low",
|
|
||||||
PriceDescriptor.NEGATIVE: "negative",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
|
|
||||||
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
|
|
||||||
if descriptor in DESCRIPTOR_MAP:
|
|
||||||
return DESCRIPTOR_MAP[descriptor]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def format_cents_to_dollars(cents: float) -> float:
|
|
||||||
"""Return a formatted conversion from cents to dollars."""
|
|
||||||
return round(cents / 100, 2)
|
|
@@ -22,10 +22,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"get_forecasts": {
|
|
||||||
"service": "mdi:transmission-tower"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -23,12 +23,16 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import ATTRIBUTION
|
from .const import ATTRIBUTION
|
||||||
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
|
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor
|
||||||
from .helpers import format_cents_to_dollars, normalize_descriptor
|
|
||||||
|
|
||||||
UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
|
UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_cents_to_dollars(cents: float) -> float:
|
||||||
|
"""Return a formatted conversion from cents to dollars."""
|
||||||
|
return round(cents / 100, 2)
|
||||||
|
|
||||||
|
|
||||||
def friendly_channel_type(channel_type: str) -> str:
|
def friendly_channel_type(channel_type: str) -> str:
|
||||||
"""Return a human readable version of the channel type."""
|
"""Return a human readable version of the channel type."""
|
||||||
if channel_type == "controlled_load":
|
if channel_type == "controlled_load":
|
||||||
|
@@ -1,121 +0,0 @@
|
|||||||
"""Amber Electric Service class."""
|
|
||||||
|
|
||||||
from amberelectric.models.channel import ChannelType
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
|
||||||
from homeassistant.core import (
|
|
||||||
HomeAssistant,
|
|
||||||
ServiceCall,
|
|
||||||
ServiceResponse,
|
|
||||||
SupportsResponse,
|
|
||||||
)
|
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
|
||||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
|
||||||
from homeassistant.util.json import JsonValueType
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
ATTR_CHANNEL_TYPE,
|
|
||||||
ATTR_CONFIG_ENTRY_ID,
|
|
||||||
CONTROLLED_LOAD_CHANNEL,
|
|
||||||
DOMAIN,
|
|
||||||
FEED_IN_CHANNEL,
|
|
||||||
GENERAL_CHANNEL,
|
|
||||||
SERVICE_GET_FORECASTS,
|
|
||||||
)
|
|
||||||
from .coordinator import AmberConfigEntry
|
|
||||||
from .helpers import format_cents_to_dollars, normalize_descriptor
|
|
||||||
|
|
||||||
GET_FORECASTS_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}),
|
|
||||||
ATTR_CHANNEL_TYPE: vol.In(
|
|
||||||
[GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry:
|
|
||||||
"""Get the Amber config entry."""
|
|
||||||
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="integration_not_found",
|
|
||||||
translation_placeholders={"target": config_entry_id},
|
|
||||||
)
|
|
||||||
if entry.state is not ConfigEntryState.LOADED:
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="not_loaded",
|
|
||||||
translation_placeholders={"target": entry.title},
|
|
||||||
)
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]:
|
|
||||||
"""Return an array of forecasts."""
|
|
||||||
results: list[JsonValueType] = []
|
|
||||||
|
|
||||||
if channel_type not in data["forecasts"]:
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="channel_not_found",
|
|
||||||
translation_placeholders={"channel_type": channel_type},
|
|
||||||
)
|
|
||||||
|
|
||||||
intervals = data["forecasts"][channel_type]
|
|
||||||
|
|
||||||
for interval in intervals:
|
|
||||||
datum = {}
|
|
||||||
datum["duration"] = interval.duration
|
|
||||||
datum["date"] = interval.var_date.isoformat()
|
|
||||||
datum["nem_date"] = interval.nem_time.isoformat()
|
|
||||||
datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
|
|
||||||
if interval.channel_type == ChannelType.FEEDIN:
|
|
||||||
datum["per_kwh"] = datum["per_kwh"] * -1
|
|
||||||
datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
|
|
||||||
datum["start_time"] = interval.start_time.isoformat()
|
|
||||||
datum["end_time"] = interval.end_time.isoformat()
|
|
||||||
datum["renewables"] = round(interval.renewables)
|
|
||||||
datum["spike_status"] = interval.spike_status.value
|
|
||||||
datum["descriptor"] = normalize_descriptor(interval.descriptor)
|
|
||||||
|
|
||||||
if interval.range is not None:
|
|
||||||
datum["range_min"] = format_cents_to_dollars(interval.range.min)
|
|
||||||
datum["range_max"] = format_cents_to_dollars(interval.range.max)
|
|
||||||
|
|
||||||
if interval.advanced_price is not None:
|
|
||||||
multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1
|
|
||||||
datum["advanced_price_low"] = multiplier * format_cents_to_dollars(
|
|
||||||
interval.advanced_price.low
|
|
||||||
)
|
|
||||||
datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars(
|
|
||||||
interval.advanced_price.predicted
|
|
||||||
)
|
|
||||||
datum["advanced_price_high"] = multiplier * format_cents_to_dollars(
|
|
||||||
interval.advanced_price.high
|
|
||||||
)
|
|
||||||
|
|
||||||
results.append(datum)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def setup_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Set up the services for the Amber integration."""
|
|
||||||
|
|
||||||
async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse:
|
|
||||||
channel_type = call.data[ATTR_CHANNEL_TYPE]
|
|
||||||
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
|
|
||||||
coordinator = entry.runtime_data
|
|
||||||
forecasts = get_forecasts(channel_type, coordinator.data)
|
|
||||||
return {"forecasts": forecasts}
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_GET_FORECASTS,
|
|
||||||
handle_get_forecasts,
|
|
||||||
GET_FORECASTS_SCHEMA,
|
|
||||||
supports_response=SupportsResponse.ONLY,
|
|
||||||
)
|
|
@@ -1,16 +0,0 @@
|
|||||||
get_forecasts:
|
|
||||||
fields:
|
|
||||||
config_entry_id:
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
config_entry:
|
|
||||||
integration: amberelectric
|
|
||||||
channel_type:
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
select:
|
|
||||||
options:
|
|
||||||
- general
|
|
||||||
- controlled_load
|
|
||||||
- feed_in
|
|
||||||
translation_key: channel_type
|
|
@@ -1,61 +1,25 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"error": {
|
|
||||||
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
|
|
||||||
"no_site": "No site provided",
|
|
||||||
"unknown_error": "[%key:common::config_flow::error::unknown%]"
|
|
||||||
},
|
|
||||||
"step": {
|
"step": {
|
||||||
"site": {
|
|
||||||
"data": {
|
|
||||||
"site_id": "Site NMI",
|
|
||||||
"site_name": "Site name"
|
|
||||||
},
|
|
||||||
"description": "Select the NMI of the site you would like to add"
|
|
||||||
},
|
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"api_token": "[%key:common::config_flow::data::api_token%]",
|
"api_token": "[%key:common::config_flow::data::api_token%]",
|
||||||
"site_id": "Site ID"
|
"site_id": "Site ID"
|
||||||
},
|
},
|
||||||
"description": "Go to {api_url} to generate an API key"
|
"description": "Go to {api_url} to generate an API key"
|
||||||
}
|
},
|
||||||
|
"site": {
|
||||||
|
"data": {
|
||||||
|
"site_id": "Site NMI",
|
||||||
|
"site_name": "Site Name"
|
||||||
|
},
|
||||||
|
"description": "Select the NMI of the site you would like to add"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"error": {
|
||||||
"get_forecasts": {
|
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||||
"name": "Get price forecasts",
|
"no_site": "No site provided",
|
||||||
"description": "Retrieves price forecasts from Amber Electric for a site.",
|
"unknown_error": "[%key:common::config_flow::error::unknown%]"
|
||||||
"fields": {
|
|
||||||
"config_entry_id": {
|
|
||||||
"description": "The config entry of the site to get forecasts for.",
|
|
||||||
"name": "Config entry"
|
|
||||||
},
|
|
||||||
"channel_type": {
|
|
||||||
"name": "Channel type",
|
|
||||||
"description": "The channel to get forecasts for."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exceptions": {
|
|
||||||
"integration_not_found": {
|
|
||||||
"message": "Config entry \"{target}\" not found in registry."
|
|
||||||
},
|
|
||||||
"not_loaded": {
|
|
||||||
"message": "{target} is not loaded."
|
|
||||||
},
|
|
||||||
"channel_not_found": {
|
|
||||||
"message": "There is no {channel_type} channel at this site."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"selector": {
|
|
||||||
"channel_type": {
|
|
||||||
"options": {
|
|
||||||
"general": "General",
|
|
||||||
"controlled_load": "Controlled load",
|
|
||||||
"feed_in": "Feed-in"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,5 +7,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["amcrest"],
|
"loggers": ["amcrest"],
|
||||||
"quality_scale": "legacy",
|
"quality_scale": "legacy",
|
||||||
"requirements": ["amcrest==1.9.9"]
|
"requirements": ["amcrest==1.9.8"]
|
||||||
}
|
}
|
||||||
|
@@ -14,7 +14,6 @@ from homeassistant.util.hass_dict import HassKey
|
|||||||
|
|
||||||
from .analytics import Analytics
|
from .analytics import Analytics
|
||||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||||
from .http import AnalyticsDevicesView
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||||
|
|
||||||
@@ -56,8 +55,6 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
|
|||||||
websocket_api.async_register_command(hass, websocket_analytics)
|
websocket_api.async_register_command(hass, websocket_analytics)
|
||||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||||
|
|
||||||
hass.http.register_view(AnalyticsDevicesView)
|
|
||||||
|
|
||||||
hass.data[DATA_COMPONENT] = analytics
|
hass.data[DATA_COMPONENT] = analytics
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@@ -27,7 +27,7 @@ from homeassistant.config_entries import SOURCE_IGNORE
|
|||||||
from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
|
from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
@@ -77,11 +77,6 @@ from .const import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def gen_uuid() -> str:
|
|
||||||
"""Generate a new UUID."""
|
|
||||||
return uuid.uuid4().hex
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AnalyticsData:
|
class AnalyticsData:
|
||||||
"""Analytics data."""
|
"""Analytics data."""
|
||||||
@@ -189,7 +184,7 @@ class Analytics:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self._data.uuid is None:
|
if self._data.uuid is None:
|
||||||
self._data.uuid = gen_uuid()
|
self._data.uuid = uuid.uuid4().hex
|
||||||
await self._store.async_save(dataclass_asdict(self._data))
|
await self._store.async_save(dataclass_asdict(self._data))
|
||||||
|
|
||||||
if self.supervisor:
|
if self.supervisor:
|
||||||
@@ -386,83 +381,3 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
|||||||
).values():
|
).values():
|
||||||
domains.update(platforms)
|
domains.update(platforms)
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
||||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
|
||||||
"""Return the devices payload."""
|
|
||||||
integrations_without_model_id: set[str] = set()
|
|
||||||
devices: list[dict[str, Any]] = []
|
|
||||||
dev_reg = dr.async_get(hass)
|
|
||||||
# Devices that need via device info set
|
|
||||||
new_indexes: dict[str, int] = {}
|
|
||||||
via_devices: dict[str, str] = {}
|
|
||||||
|
|
||||||
seen_integrations = set()
|
|
||||||
|
|
||||||
for device in dev_reg.devices.values():
|
|
||||||
# Ignore services
|
|
||||||
if device.entry_type:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not device.primary_config_entry:
|
|
||||||
continue
|
|
||||||
|
|
||||||
config_entry = hass.config_entries.async_get_entry(device.primary_config_entry)
|
|
||||||
|
|
||||||
if config_entry is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
seen_integrations.add(config_entry.domain)
|
|
||||||
|
|
||||||
if not device.model_id:
|
|
||||||
integrations_without_model_id.add(config_entry.domain)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not device.manufacturer:
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_indexes[device.id] = len(devices)
|
|
||||||
devices.append(
|
|
||||||
{
|
|
||||||
"integration": config_entry.domain,
|
|
||||||
"manufacturer": device.manufacturer,
|
|
||||||
"model_id": device.model_id,
|
|
||||||
"model": device.model,
|
|
||||||
"sw_version": device.sw_version,
|
|
||||||
"hw_version": device.hw_version,
|
|
||||||
"has_suggested_area": device.suggested_area is not None,
|
|
||||||
"has_configuration_url": device.configuration_url is not None,
|
|
||||||
"via_device": None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if device.via_device_id:
|
|
||||||
via_devices[device.id] = device.via_device_id
|
|
||||||
|
|
||||||
for from_device, via_device in via_devices.items():
|
|
||||||
if via_device not in new_indexes:
|
|
||||||
continue
|
|
||||||
devices[new_indexes[from_device]]["via_device"] = new_indexes[via_device]
|
|
||||||
|
|
||||||
integrations = {
|
|
||||||
domain: integration
|
|
||||||
for domain, integration in (
|
|
||||||
await async_get_integrations(hass, seen_integrations)
|
|
||||||
).items()
|
|
||||||
if isinstance(integration, Integration)
|
|
||||||
}
|
|
||||||
|
|
||||||
for device_info in devices:
|
|
||||||
if integration := integrations.get(device_info["integration"]):
|
|
||||||
device_info["is_custom_integration"] = not integration.is_built_in
|
|
||||||
|
|
||||||
return {
|
|
||||||
"version": "home-assistant:1",
|
|
||||||
"no_model_id": sorted(
|
|
||||||
[
|
|
||||||
domain
|
|
||||||
for domain in integrations_without_model_id
|
|
||||||
if domain in integrations and integrations[domain].is_built_in
|
|
||||||
]
|
|
||||||
),
|
|
||||||
"devices": devices,
|
|
||||||
}
|
|
||||||
|
@@ -1,27 +0,0 @@
|
|||||||
"""HTTP endpoints for analytics integration."""
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
from .analytics import async_devices_payload
|
|
||||||
|
|
||||||
|
|
||||||
class AnalyticsDevicesView(HomeAssistantView):
|
|
||||||
"""View to handle analytics devices payload download requests."""
|
|
||||||
|
|
||||||
url = "/api/analytics/devices"
|
|
||||||
name = "api:analytics:devices"
|
|
||||||
|
|
||||||
@require_admin
|
|
||||||
async def get(self, request: web.Request) -> web.Response:
|
|
||||||
"""Return analytics devices payload as JSON."""
|
|
||||||
hass: HomeAssistant = request.app[KEY_HASS]
|
|
||||||
payload = await async_devices_payload(hass)
|
|
||||||
return self.json(
|
|
||||||
payload,
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": "attachment; filename=analytics_devices.json"
|
|
||||||
},
|
|
||||||
)
|
|
@@ -3,7 +3,7 @@
|
|||||||
"name": "Analytics",
|
"name": "Analytics",
|
||||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||||
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
||||||
"dependencies": ["api", "websocket_api", "http"],
|
"dependencies": ["api", "websocket_api"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
|
@@ -55,6 +55,7 @@ async def async_setup_entry(
|
|||||||
entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names)
|
entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -64,3 +65,10 @@ async def async_unload_entry(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_listener(
|
||||||
|
hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Handle options update."""
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
@@ -11,11 +11,7 @@ from python_homeassistant_analytics import (
|
|||||||
from python_homeassistant_analytics.models import Environment, IntegrationType
|
from python_homeassistant_analytics.models import Environment, IntegrationType
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||||
ConfigFlow,
|
|
||||||
ConfigFlowResult,
|
|
||||||
OptionsFlowWithReload,
|
|
||||||
)
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
@@ -133,7 +129,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
|
class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
|
||||||
"""Handle Homeassistant Analytics options."""
|
"""Handle Homeassistant Analytics options."""
|
||||||
|
|
||||||
async def async_step_init(
|
async def async_step_init(
|
||||||
|
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.components.camera import CameraEntityFeature
|
|
||||||
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
|
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
@@ -32,7 +31,6 @@ class IPWebcamCamera(MjpegCamera):
|
|||||||
"""Representation of a IP Webcam camera."""
|
"""Representation of a IP Webcam camera."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_supported_features = CameraEntityFeature.STREAM
|
|
||||||
|
|
||||||
def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None:
|
def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None:
|
||||||
"""Initialize the camera."""
|
"""Initialize the camera."""
|
||||||
@@ -48,17 +46,3 @@ class IPWebcamCamera(MjpegCamera):
|
|||||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||||
name=coordinator.config_entry.data[CONF_HOST],
|
name=coordinator.config_entry.data[CONF_HOST],
|
||||||
)
|
)
|
||||||
self._coordinator = coordinator
|
|
||||||
|
|
||||||
async def stream_source(self) -> str:
|
|
||||||
"""Get the stream source for the Android IP camera."""
|
|
||||||
return self._coordinator.cam.get_rtsp_url(
|
|
||||||
video_codec="h264", # most compatible & recommended
|
|
||||||
# while "opus" is compatible with more devices,
|
|
||||||
# HA's stream integration requires AAC or MP3,
|
|
||||||
# and IP webcam doesn't provide MP3 audio.
|
|
||||||
# aac is supported on select devices >= android 4.1.
|
|
||||||
# The stream will be quiet on devices that don't support aac,
|
|
||||||
# but it won't fail.
|
|
||||||
audio_codec="aac",
|
|
||||||
)
|
|
||||||
|
@@ -56,7 +56,7 @@ SERVICE_UPLOAD = "upload"
|
|||||||
ANDROIDTV_STATES = {
|
ANDROIDTV_STATES = {
|
||||||
"off": MediaPlayerState.OFF,
|
"off": MediaPlayerState.OFF,
|
||||||
"idle": MediaPlayerState.IDLE,
|
"idle": MediaPlayerState.IDLE,
|
||||||
"standby": MediaPlayerState.IDLE,
|
"standby": MediaPlayerState.STANDBY,
|
||||||
"playing": MediaPlayerState.PLAYING,
|
"playing": MediaPlayerState.PLAYING,
|
||||||
"paused": MediaPlayerState.PAUSED,
|
"paused": MediaPlayerState.PAUSED,
|
||||||
}
|
}
|
||||||
|
@@ -5,18 +5,26 @@ from __future__ import annotations
|
|||||||
from asyncio import timeout
|
from asyncio import timeout
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth
|
from androidtvremote2 import (
|
||||||
|
AndroidTVRemote,
|
||||||
|
CannotConnect,
|
||||||
|
ConnectionClosed,
|
||||||
|
InvalidAuth,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
|
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
|
|
||||||
from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
|
from .helpers import create_api, get_enable_ime
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||||
|
|
||||||
|
AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
|
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
|
||||||
@@ -68,14 +76,21 @@ async def async_setup_entry(
|
|||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
|
||||||
)
|
)
|
||||||
|
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||||
entry.async_on_unload(api.disconnect)
|
entry.async_on_unload(api.disconnect)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry
|
|
||||||
) -> bool:
|
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
_LOGGER.debug("async_unload_entry: %s", entry.data)
|
_LOGGER.debug("async_unload_entry: %s", entry.data)
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Handle options update."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"async_update_options: data: %s options: %s", entry.data, entry.options
|
||||||
|
)
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
@@ -16,10 +16,10 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
SOURCE_REAUTH,
|
SOURCE_REAUTH,
|
||||||
SOURCE_RECONFIGURE,
|
ConfigEntry,
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlowWithReload,
|
OptionsFlow,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
|
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
@@ -33,7 +33,7 @@ from homeassistant.helpers.selector import (
|
|||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
|
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
|
||||||
from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime
|
from .helpers import create_api, get_enable_ime
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,6 +41,12 @@ APPS_NEW_ID = "NewApp"
|
|||||||
CONF_APP_DELETE = "app_delete"
|
CONF_APP_DELETE = "app_delete"
|
||||||
CONF_APP_ID = "app_id"
|
CONF_APP_ID = "app_id"
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("host"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
STEP_PAIR_DATA_SCHEMA = vol.Schema(
|
STEP_PAIR_DATA_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("pin"): str,
|
vol.Required("pin"): str,
|
||||||
@@ -61,7 +67,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle the initial and reconfigure step."""
|
"""Handle the initial step."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self.host = user_input[CONF_HOST]
|
self.host = user_input[CONF_HOST]
|
||||||
@@ -70,32 +76,15 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
await api.async_generate_cert_if_missing()
|
await api.async_generate_cert_if_missing()
|
||||||
self.name, self.mac = await api.async_get_name_and_mac()
|
self.name, self.mac = await api.async_get_name_and_mac()
|
||||||
await self.async_set_unique_id(format_mac(self.mac))
|
await self.async_set_unique_id(format_mac(self.mac))
|
||||||
if self.source == SOURCE_RECONFIGURE:
|
|
||||||
self._abort_if_unique_id_mismatch()
|
|
||||||
return self.async_update_reload_and_abort(
|
|
||||||
self._get_reconfigure_entry(),
|
|
||||||
data={
|
|
||||||
CONF_HOST: self.host,
|
|
||||||
CONF_NAME: self.name,
|
|
||||||
CONF_MAC: self.mac,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
|
self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
|
||||||
return await self._async_start_pair()
|
return await self._async_start_pair()
|
||||||
except (CannotConnect, ConnectionClosed):
|
except (CannotConnect, ConnectionClosed):
|
||||||
# Likely invalid IP address or device is network unreachable. Stay
|
# Likely invalid IP address or device is network unreachable. Stay
|
||||||
# in the user step allowing the user to enter a different host.
|
# in the user step allowing the user to enter a different host.
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
else:
|
|
||||||
user_input = {}
|
|
||||||
default_host = user_input.get(CONF_HOST, vol.UNDEFINED)
|
|
||||||
if self.source == SOURCE_RECONFIGURE:
|
|
||||||
default_host = self._get_reconfigure_entry().data[CONF_HOST]
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user",
|
step_id="user",
|
||||||
data_schema=vol.Schema(
|
data_schema=STEP_USER_DATA_SCHEMA,
|
||||||
{vol.Required(CONF_HOST, default=default_host): str}
|
|
||||||
),
|
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,10 +105,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
pin = user_input["pin"]
|
pin = user_input["pin"]
|
||||||
await self.api.async_finish_pairing(pin)
|
await self.api.async_finish_pairing(pin)
|
||||||
if self.source == SOURCE_REAUTH:
|
if self.source == SOURCE_REAUTH:
|
||||||
return self.async_update_reload_and_abort(
|
await self.hass.config_entries.async_reload(
|
||||||
self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True
|
self._get_reauth_entry().entry_id
|
||||||
)
|
)
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=self.name,
|
title=self.name,
|
||||||
data={
|
data={
|
||||||
@@ -228,25 +217,19 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_reconfigure(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle reconfiguration."""
|
|
||||||
return await self.async_step_user(user_input)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
config_entry: AndroidTVRemoteConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
) -> AndroidTVRemoteOptionsFlowHandler:
|
) -> AndroidTVRemoteOptionsFlowHandler:
|
||||||
"""Create the options flow."""
|
"""Create the options flow."""
|
||||||
return AndroidTVRemoteOptionsFlowHandler(config_entry)
|
return AndroidTVRemoteOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
|
||||||
class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload):
|
class AndroidTVRemoteOptionsFlowHandler(OptionsFlow):
|
||||||
"""Android TV Remote options flow."""
|
"""Android TV Remote options flow."""
|
||||||
|
|
||||||
def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None:
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
"""Initialize options flow."""
|
"""Initialize options flow."""
|
||||||
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
|
self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {}))
|
||||||
self._conf_app_id: str | None = None
|
self._conf_app_id: str | None = None
|
||||||
|
@@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data
|
|||||||
from homeassistant.const import CONF_HOST, CONF_MAC
|
from homeassistant.const import CONF_HOST, CONF_MAC
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .helpers import AndroidTVRemoteConfigEntry
|
from . import AndroidTVRemoteConfigEntry
|
||||||
|
|
||||||
TO_REDACT = {CONF_HOST, CONF_MAC}
|
TO_REDACT = {CONF_HOST, CONF_MAC}
|
||||||
|
|
||||||
|
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
|
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
|
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@@ -13,7 +14,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device
|
|||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
from .const import CONF_APPS, DOMAIN
|
from .const import CONF_APPS, DOMAIN
|
||||||
from .helpers import AndroidTVRemoteConfigEntry
|
|
||||||
|
|
||||||
|
|
||||||
class AndroidTVRemoteBaseEntity(Entity):
|
class AndroidTVRemoteBaseEntity(Entity):
|
||||||
@@ -23,9 +23,7 @@ class AndroidTVRemoteBaseEntity(Entity):
|
|||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None:
|
||||||
self, api: AndroidTVRemote, config_entry: AndroidTVRemoteConfigEntry
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
self._api = api
|
self._api = api
|
||||||
self._host = config_entry.data[CONF_HOST]
|
self._host = config_entry.data[CONF_HOST]
|
||||||
|
@@ -10,8 +10,6 @@ from homeassistant.helpers.storage import STORAGE_DIR
|
|||||||
|
|
||||||
from .const import CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE
|
from .const import CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE
|
||||||
|
|
||||||
AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote]
|
|
||||||
|
|
||||||
|
|
||||||
def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRemote:
|
def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRemote:
|
||||||
"""Create an AndroidTVRemote instance."""
|
"""Create an AndroidTVRemote instance."""
|
||||||
@@ -25,6 +23,6 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool:
|
def get_enable_ime(entry: ConfigEntry) -> bool:
|
||||||
"""Get value of enable_ime option or its default value."""
|
"""Get value of enable_ime option or its default value."""
|
||||||
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return]
|
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE)
|
||||||
|
@@ -7,6 +7,6 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["androidtvremote2"],
|
"loggers": ["androidtvremote2"],
|
||||||
"requirements": ["androidtvremote2==0.2.3"],
|
"requirements": ["androidtvremote2==0.2.2"],
|
||||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from androidtvremote2 import AndroidTVRemote, ConnectionClosed, VolumeInfo
|
from androidtvremote2 import AndroidTVRemote, ConnectionClosed
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
BrowseMedia,
|
BrowseMedia,
|
||||||
@@ -20,9 +20,9 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from . import AndroidTVRemoteConfigEntry
|
||||||
from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
|
from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
|
||||||
from .entity import AndroidTVRemoteBaseEntity
|
from .entity import AndroidTVRemoteBaseEntity
|
||||||
from .helpers import AndroidTVRemoteConfigEntry
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
@@ -75,11 +75,13 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
|||||||
else current_app
|
else current_app
|
||||||
)
|
)
|
||||||
|
|
||||||
def _update_volume_info(self, volume_info: VolumeInfo) -> None:
|
def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None:
|
||||||
"""Update volume info."""
|
"""Update volume info."""
|
||||||
if volume_info.get("max"):
|
if volume_info.get("max"):
|
||||||
self._attr_volume_level = volume_info["level"] / volume_info["max"]
|
self._attr_volume_level = int(volume_info["level"]) / int(
|
||||||
self._attr_is_volume_muted = volume_info["muted"]
|
volume_info["max"]
|
||||||
|
)
|
||||||
|
self._attr_is_volume_muted = bool(volume_info["muted"])
|
||||||
else:
|
else:
|
||||||
self._attr_volume_level = None
|
self._attr_volume_level = None
|
||||||
self._attr_is_volume_muted = None
|
self._attr_is_volume_muted = None
|
||||||
@@ -91,7 +93,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
|||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _volume_info_updated(self, volume_info: VolumeInfo) -> None:
|
def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None:
|
||||||
"""Update the state when the volume info changes."""
|
"""Update the state when the volume info changes."""
|
||||||
self._update_volume_info(volume_info)
|
self._update_volume_info(volume_info)
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
@@ -100,9 +102,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
|||||||
"""Register callbacks."""
|
"""Register callbacks."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
if self._api.current_app is not None:
|
|
||||||
self._update_current_app(self._api.current_app)
|
self._update_current_app(self._api.current_app)
|
||||||
if self._api.volume_info is not None:
|
|
||||||
self._update_volume_info(self._api.volume_info)
|
self._update_volume_info(self._api.volume_info)
|
||||||
|
|
||||||
self._api.add_current_app_updated_callback(self._current_app_updated)
|
self._api.add_current_app_updated_callback(self._current_app_updated)
|
||||||
|
@@ -20,9 +20,9 @@ from homeassistant.components.remote import (
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from . import AndroidTVRemoteConfigEntry
|
||||||
from .const import CONF_APP_NAME
|
from .const import CONF_APP_NAME
|
||||||
from .entity import AndroidTVRemoteBaseEntity
|
from .entity import AndroidTVRemoteBaseEntity
|
||||||
from .helpers import AndroidTVRemoteConfigEntry
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
@@ -63,7 +63,6 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity):
|
|||||||
self._attr_activity_list = [
|
self._attr_activity_list = [
|
||||||
app.get(CONF_APP_NAME, "") for app in self._apps.values()
|
app.get(CONF_APP_NAME, "") for app in self._apps.values()
|
||||||
]
|
]
|
||||||
if self._api.current_app is not None:
|
|
||||||
self._update_current_app(self._api.current_app)
|
self._update_current_app(self._api.current_app)
|
||||||
self._api.add_current_app_updated_callback(self._current_app_updated)
|
self._api.add_current_app_updated_callback(self._current_app_updated)
|
||||||
|
|
||||||
|
@@ -6,18 +6,6 @@
|
|||||||
"description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.",
|
"description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "[%key:common::config_flow::data::host%]"
|
"host": "[%key:common::config_flow::data::host%]"
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "The hostname or IP address of the Android TV device."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reconfigure": {
|
|
||||||
"description": "Update the IP address of this previously configured Android TV device.",
|
|
||||||
"data": {
|
|
||||||
"host": "[%key:common::config_flow::data::host%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "The hostname or IP address of the Android TV device."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zeroconf_confirm": {
|
"zeroconf_confirm": {
|
||||||
@@ -28,9 +16,6 @@
|
|||||||
"description": "Enter the pairing code displayed on the Android TV ({name}).",
|
"description": "Enter the pairing code displayed on the Android TV ({name}).",
|
||||||
"data": {
|
"data": {
|
||||||
"pin": "[%key:common::config_flow::data::pin%]"
|
"pin": "[%key:common::config_flow::data::pin%]"
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"pin": "Pairing code displayed on the Android TV device."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
@@ -47,9 +32,7 @@
|
|||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"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%]",
|
|
||||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -57,11 +40,7 @@
|
|||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"apps": "Configure applications list",
|
"apps": "Configure applications list",
|
||||||
"enable_ime": "Enable IME"
|
"enable_ime": "Enable IME. Needed for getting the current app. Disable for devices that show 'Use keyboard on mobile device screen' instead of the on screen keyboard."
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"apps": "Here you can define the list of applications, specify names and icons that will be displayed in the UI.",
|
|
||||||
"enable_ime": "Enable this option to be able to get the current app name and send text as keyboard input. Disable it for devices that show 'Use keyboard on mobile device screen' instead of the on-screen keyboard."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apps": {
|
"apps": {
|
||||||
@@ -74,10 +53,8 @@
|
|||||||
"app_delete": "Check to delete this application"
|
"app_delete": "Check to delete this application"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"app_name": "Name of the application as you would like it to be displayed in Home Assistant.",
|
|
||||||
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
|
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
|
||||||
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename",
|
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename"
|
||||||
"app_delete": "Check this box to delete the application from the list."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"cannot_receive_deviceinfo": "Failed to retrieve MAC address. Make sure the device is turned on"
|
"cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user