Compare commits
No commits in common. "dev" and "2025.6.0b7" have entirely different histories.
dev
...
2025.6.0b7
|
@ -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
|
||||||
|
|
|
@ -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!
|
||||||
|
|
|
@ -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@v9
|
||||||
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@v9
|
||||||
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.1
|
uses: sigstore/cosign-installer@v3.8.2
|
||||||
with:
|
with:
|
||||||
cosign-release: "v2.2.3"
|
cosign-release: "v2.2.3"
|
||||||
|
|
||||||
|
@ -509,7 +509,7 @@ jobs:
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||||
with:
|
with:
|
||||||
context: . # So action will not pull the repository again
|
context: . # So action will not pull the repository again
|
||||||
file: ./script/hassfest/docker/Dockerfile
|
file: ./script/hassfest/docker/Dockerfile
|
||||||
|
@ -522,7 +522,7 @@ jobs:
|
||||||
- name: Push Docker image
|
- name: Push Docker image
|
||||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
|
||||||
with:
|
with:
|
||||||
context: . # So action will not pull the repository again
|
context: . # So action will not pull the repository again
|
||||||
file: ./script/hassfest/docker/Dockerfile
|
file: ./script/hassfest/docker/Dockerfile
|
||||||
|
@ -531,7 +531,7 @@ jobs:
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Generate artifact attestation
|
||||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||||
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
|
|
|
@ -37,10 +37,10 @@ on:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CACHE_VERSION: 3
|
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.6"
|
||||||
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
|
||||||
|
@ -360,7 +360,7 @@ jobs:
|
||||||
- name: Run ruff
|
- name: Run ruff
|
||||||
run: |
|
run: |
|
||||||
. venv/bin/activate
|
. venv/bin/activate
|
||||||
pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
|
pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure
|
||||||
env:
|
env:
|
||||||
RUFF_OUTPUT_FORMAT: github
|
RUFF_OUTPUT_FORMAT: github
|
||||||
|
|
||||||
|
|
|
@ -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.0
|
uses: github/codeql-action/init@v3.28.18
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.29.0
|
uses: github/codeql-action/analyze@v3.28.18
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
|
|
@ -1,385 +0,0 @@
|
||||||
name: Auto-detect duplicate issues
|
|
||||||
|
|
||||||
# yamllint disable-line rule:truthy
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [labeled]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
models: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
detect-duplicates:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check if integration label was added and extract details
|
|
||||||
id: extract
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
// Debug: Log the event payload
|
|
||||||
console.log('Event name:', context.eventName);
|
|
||||||
console.log('Event action:', context.payload.action);
|
|
||||||
console.log('Event payload keys:', Object.keys(context.payload));
|
|
||||||
|
|
||||||
// Check the specific label that was added
|
|
||||||
const addedLabel = context.payload.label;
|
|
||||||
if (!addedLabel) {
|
|
||||||
console.log('No label found in labeled event payload');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Label added: ${addedLabel.name}`);
|
|
||||||
|
|
||||||
if (!addedLabel.name.startsWith('integration:')) {
|
|
||||||
console.log('Added label is not an integration label, skipping duplicate detection');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Integration label added: ${addedLabel.name}`);
|
|
||||||
|
|
||||||
let currentIssue;
|
|
||||||
let integrationLabels = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const issue = await github.rest.issues.get({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIssue = issue.data;
|
|
||||||
|
|
||||||
// Check if potential-duplicate label already exists
|
|
||||||
const hasPotentialDuplicateLabel = currentIssue.labels
|
|
||||||
.some(label => label.name === 'potential-duplicate');
|
|
||||||
|
|
||||||
if (hasPotentialDuplicateLabel) {
|
|
||||||
console.log('Issue already has potential-duplicate label, skipping duplicate detection');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
integrationLabels = currentIssue.labels
|
|
||||||
.filter(label => label.name.startsWith('integration:'))
|
|
||||||
.map(label => label.name);
|
|
||||||
} catch (error) {
|
|
||||||
core.error(`Failed to fetch issue #${context.payload.issue.number}:`, error.message);
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've already posted a duplicate detection comment recently
|
|
||||||
let comments;
|
|
||||||
try {
|
|
||||||
comments = await github.rest.issues.listComments({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number,
|
|
||||||
per_page: 10
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
core.error('Failed to fetch comments:', error.message);
|
|
||||||
// Continue anyway, worst case we might post a duplicate comment
|
|
||||||
comments = { data: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've already posted a duplicate detection comment
|
|
||||||
const recentDuplicateComment = comments.data.find(comment =>
|
|
||||||
comment.user && comment.user.login === 'github-actions[bot]' &&
|
|
||||||
comment.body.includes('<!-- workflow: detect-duplicate-issues -->')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recentDuplicateComment) {
|
|
||||||
console.log('Already posted duplicate detection comment, skipping');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput('should_continue', 'true');
|
|
||||||
core.setOutput('current_number', currentIssue.number);
|
|
||||||
core.setOutput('current_title', currentIssue.title);
|
|
||||||
core.setOutput('current_body', currentIssue.body);
|
|
||||||
core.setOutput('current_url', currentIssue.html_url);
|
|
||||||
core.setOutput('integration_labels', JSON.stringify(integrationLabels));
|
|
||||||
|
|
||||||
console.log(`Current issue: #${currentIssue.number}`);
|
|
||||||
console.log(`Integration labels: ${integrationLabels.join(', ')}`);
|
|
||||||
|
|
||||||
- name: Fetch similar issues
|
|
||||||
id: fetch_similar
|
|
||||||
if: steps.extract.outputs.should_continue == 'true'
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
env:
|
|
||||||
INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }}
|
|
||||||
CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const integrationLabels = JSON.parse(process.env.INTEGRATION_LABELS);
|
|
||||||
const currentNumber = parseInt(process.env.CURRENT_NUMBER);
|
|
||||||
|
|
||||||
if (integrationLabels.length === 0) {
|
|
||||||
console.log('No integration labels found, skipping duplicate detection');
|
|
||||||
core.setOutput('has_similar', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use GitHub search API to find issues with matching integration labels
|
|
||||||
console.log(`Searching for issues with integration labels: ${integrationLabels.join(', ')}`);
|
|
||||||
|
|
||||||
// Build search query for issues with any of the current integration labels
|
|
||||||
const labelQueries = integrationLabels.map(label => `label:"${label}"`);
|
|
||||||
|
|
||||||
// Calculate date 6 months ago
|
|
||||||
const sixMonthsAgo = new Date();
|
|
||||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
|
||||||
const dateFilter = `created:>=${sixMonthsAgo.toISOString().split('T')[0]}`;
|
|
||||||
|
|
||||||
let searchQuery;
|
|
||||||
|
|
||||||
if (labelQueries.length === 1) {
|
|
||||||
searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue ${labelQueries[0]} ${dateFilter}`;
|
|
||||||
} else {
|
|
||||||
searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue (${labelQueries.join(' OR ')}) ${dateFilter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Search query: ${searchQuery}`);
|
|
||||||
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await github.rest.search.issuesAndPullRequests({
|
|
||||||
q: searchQuery,
|
|
||||||
per_page: 15,
|
|
||||||
sort: 'updated',
|
|
||||||
order: 'desc'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
core.error('Failed to search for similar issues:', error.message);
|
|
||||||
if (error.status === 403 && error.message.includes('rate limit')) {
|
|
||||||
core.error('GitHub API rate limit exceeded');
|
|
||||||
}
|
|
||||||
core.setOutput('has_similar', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out the current issue, pull requests, and newer issues (higher numbers)
|
|
||||||
const similarIssues = result.data.items
|
|
||||||
.filter(item =>
|
|
||||||
item.number !== currentNumber &&
|
|
||||||
!item.pull_request &&
|
|
||||||
item.number < currentNumber // Only include older issues (lower numbers)
|
|
||||||
)
|
|
||||||
.map(item => ({
|
|
||||||
number: item.number,
|
|
||||||
title: item.title,
|
|
||||||
body: item.body,
|
|
||||||
url: item.html_url,
|
|
||||||
state: item.state,
|
|
||||||
createdAt: item.created_at,
|
|
||||||
updatedAt: item.updated_at,
|
|
||||||
comments: item.comments,
|
|
||||||
labels: item.labels.map(l => l.name)
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`Found ${similarIssues.length} issues with matching integration labels`);
|
|
||||||
console.log('Raw similar issues:', JSON.stringify(similarIssues.slice(0, 3), null, 2));
|
|
||||||
|
|
||||||
if (similarIssues.length === 0) {
|
|
||||||
console.log('No similar issues found, setting has_similar to false');
|
|
||||||
core.setOutput('has_similar', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Similar issues found, setting has_similar to true');
|
|
||||||
core.setOutput('has_similar', 'true');
|
|
||||||
|
|
||||||
// Clean the issue data to prevent JSON parsing issues
|
|
||||||
const cleanedIssues = similarIssues.slice(0, 15).map(item => {
|
|
||||||
// Handle body with improved truncation and null handling
|
|
||||||
let cleanBody = '';
|
|
||||||
if (item.body && typeof item.body === 'string') {
|
|
||||||
// Remove control characters
|
|
||||||
const cleaned = item.body.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
||||||
// Truncate to 1000 characters and add ellipsis if needed
|
|
||||||
cleanBody = cleaned.length > 1000
|
|
||||||
? cleaned.substring(0, 1000) + '...'
|
|
||||||
: cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
number: item.number,
|
|
||||||
title: item.title.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''), // Remove control characters
|
|
||||||
body: cleanBody,
|
|
||||||
url: item.url,
|
|
||||||
state: item.state,
|
|
||||||
createdAt: item.createdAt,
|
|
||||||
updatedAt: item.updatedAt,
|
|
||||||
comments: item.comments,
|
|
||||||
labels: item.labels
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Cleaned issues count: ${cleanedIssues.length}`);
|
|
||||||
console.log('First cleaned issue:', JSON.stringify(cleanedIssues[0], null, 2));
|
|
||||||
|
|
||||||
core.setOutput('similar_issues', JSON.stringify(cleanedIssues));
|
|
||||||
|
|
||||||
- name: Detect duplicates using AI
|
|
||||||
id: ai_detection
|
|
||||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
|
||||||
uses: actions/ai-inference@v1.1.0
|
|
||||||
with:
|
|
||||||
model: openai/gpt-4o
|
|
||||||
system-prompt: |
|
|
||||||
You are a Home Assistant issue duplicate detector. Your task is to identify TRUE DUPLICATES - issues that report the EXACT SAME problem, not just similar or related issues.
|
|
||||||
|
|
||||||
CRITICAL: An issue is ONLY a duplicate if:
|
|
||||||
- It describes the SAME problem with the SAME root cause
|
|
||||||
- Issues about the same integration but different problems are NOT duplicates
|
|
||||||
- Issues with similar symptoms but different causes are NOT duplicates
|
|
||||||
|
|
||||||
Important considerations:
|
|
||||||
- Open issues are more relevant than closed ones for duplicate detection
|
|
||||||
- Recently updated issues may indicate ongoing work or discussion
|
|
||||||
- Issues with more comments are generally more relevant and active
|
|
||||||
- Older closed issues might be resolved differently than newer approaches
|
|
||||||
- Consider the time between issues - very old issues may have different contexts
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. ONLY mark as duplicate if the issues describe IDENTICAL problems
|
|
||||||
2. Look for issues that report the same problem or request the same functionality
|
|
||||||
3. Different error messages = NOT a duplicate (even if same integration)
|
|
||||||
4. For CLOSED issues, only mark as duplicate if they describe the EXACT same problem
|
|
||||||
5. For OPEN issues, use a lower threshold (90%+ similarity)
|
|
||||||
6. Prioritize issues with higher comment counts as they indicate more activity/relevance
|
|
||||||
7. When in doubt, do NOT mark as duplicate
|
|
||||||
8. Return ONLY a JSON array of issue numbers that are duplicates
|
|
||||||
9. If no duplicates are found, return an empty array: []
|
|
||||||
10. Maximum 5 potential duplicates, prioritize open issues with comments
|
|
||||||
11. Consider the age of issues - prefer recent duplicates over very old ones
|
|
||||||
|
|
||||||
Example response format:
|
|
||||||
[1234, 5678, 9012]
|
|
||||||
|
|
||||||
prompt: |
|
|
||||||
Current issue (just created):
|
|
||||||
Title: ${{ steps.extract.outputs.current_title }}
|
|
||||||
Body: ${{ steps.extract.outputs.current_body }}
|
|
||||||
|
|
||||||
Other issues to compare against (each includes state, creation date, last update, and comment count):
|
|
||||||
${{ steps.fetch_similar.outputs.similar_issues }}
|
|
||||||
|
|
||||||
Analyze these issues and identify which ones describe IDENTICAL problems and thus are duplicates of the current issue. When sorting them, consider their state (open/closed), how recently they were updated, and their comment count (higher = more relevant).
|
|
||||||
|
|
||||||
max-tokens: 100
|
|
||||||
|
|
||||||
- name: Post duplicate detection results
|
|
||||||
id: post_results
|
|
||||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
env:
|
|
||||||
AI_RESPONSE: ${{ steps.ai_detection.outputs.response }}
|
|
||||||
SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const aiResponse = process.env.AI_RESPONSE;
|
|
||||||
|
|
||||||
console.log('Raw AI response:', JSON.stringify(aiResponse));
|
|
||||||
|
|
||||||
let duplicateNumbers = [];
|
|
||||||
try {
|
|
||||||
// Clean the response of any potential control characters
|
|
||||||
const cleanResponse = aiResponse.trim().replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
||||||
console.log('Cleaned AI response:', cleanResponse);
|
|
||||||
|
|
||||||
duplicateNumbers = JSON.parse(cleanResponse);
|
|
||||||
|
|
||||||
// Ensure it's an array and contains only numbers
|
|
||||||
if (!Array.isArray(duplicateNumbers)) {
|
|
||||||
console.log('AI response is not an array, trying to extract numbers');
|
|
||||||
const numberMatches = cleanResponse.match(/\d+/g);
|
|
||||||
duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to only valid numbers
|
|
||||||
duplicateNumbers = duplicateNumbers.filter(n => typeof n === 'number' && !isNaN(n));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to parse AI response as JSON:', error.message);
|
|
||||||
console.log('Raw response:', aiResponse);
|
|
||||||
|
|
||||||
// Fallback: try to extract numbers from the response
|
|
||||||
const numberMatches = aiResponse.match(/\d+/g);
|
|
||||||
duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
|
|
||||||
console.log('Extracted numbers as fallback:', duplicateNumbers);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(duplicateNumbers) || duplicateNumbers.length === 0) {
|
|
||||||
console.log('No duplicates detected by AI');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`AI detected ${duplicateNumbers.length} potential duplicates: ${duplicateNumbers.join(', ')}`);
|
|
||||||
|
|
||||||
// Get details of detected duplicates
|
|
||||||
const similarIssues = JSON.parse(process.env.SIMILAR_ISSUES);
|
|
||||||
const duplicates = similarIssues.filter(issue => duplicateNumbers.includes(issue.number));
|
|
||||||
|
|
||||||
if (duplicates.length === 0) {
|
|
||||||
console.log('No matching issues found for detected numbers');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create comment with duplicate detection results
|
|
||||||
const duplicateLinks = duplicates.map(issue => `- [#${issue.number}: ${issue.title}](${issue.url})`).join('\n');
|
|
||||||
|
|
||||||
const commentBody = [
|
|
||||||
'<!-- workflow: detect-duplicate-issues -->',
|
|
||||||
'### 🔍 **Potential duplicate detection**',
|
|
||||||
'',
|
|
||||||
'I\'ve analyzed similar issues and found the following potential duplicates:',
|
|
||||||
'',
|
|
||||||
duplicateLinks,
|
|
||||||
'',
|
|
||||||
'**What to do next:**',
|
|
||||||
'1. Please review these issues to see if they match your issue',
|
|
||||||
'2. If you find an existing issue that covers your problem:',
|
|
||||||
' - Consider closing this issue',
|
|
||||||
' - Add your findings or 👍 on the existing issue instead',
|
|
||||||
'3. If your issue is different or adds new aspects, please clarify how it differs',
|
|
||||||
'',
|
|
||||||
'This helps keep our issues organized and ensures similar issues are consolidated for better visibility.',
|
|
||||||
'',
|
|
||||||
'*This message was generated automatically by our duplicate detection system.*'
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number,
|
|
||||||
body: commentBody
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Posted duplicate detection comment with ${duplicates.length} potential duplicates`);
|
|
||||||
|
|
||||||
// Add the potential-duplicate label
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number,
|
|
||||||
labels: ['potential-duplicate']
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Added potential-duplicate label to the issue');
|
|
||||||
} catch (error) {
|
|
||||||
core.error('Failed to post duplicate detection comment or add label:', error.message);
|
|
||||||
if (error.status === 403) {
|
|
||||||
core.error('Permission denied or rate limit exceeded');
|
|
||||||
}
|
|
||||||
// Don't throw - we've done the analysis, just couldn't post the result
|
|
||||||
}
|
|
|
@ -1,193 +0,0 @@
|
||||||
name: Auto-detect non-English issues
|
|
||||||
|
|
||||||
# yamllint disable-line rule:truthy
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [opened]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
models: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
detect-language:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check issue language
|
|
||||||
id: detect_language
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
env:
|
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
||||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
|
||||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
|
||||||
ISSUE_USER_TYPE: ${{ github.event.issue.user.type }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
// Get the issue details from environment variables
|
|
||||||
const issueNumber = process.env.ISSUE_NUMBER;
|
|
||||||
const issueTitle = process.env.ISSUE_TITLE || '';
|
|
||||||
const issueBody = process.env.ISSUE_BODY || '';
|
|
||||||
const userType = process.env.ISSUE_USER_TYPE;
|
|
||||||
|
|
||||||
// Skip language detection for bot users
|
|
||||||
if (userType === 'Bot') {
|
|
||||||
console.log('Skipping language detection for bot user');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Checking language for issue #${issueNumber}`);
|
|
||||||
console.log(`Title: ${issueTitle}`);
|
|
||||||
|
|
||||||
// Combine title and body for language detection
|
|
||||||
const fullText = `${issueTitle}\n\n${issueBody}`;
|
|
||||||
|
|
||||||
// Check if the text is too short to reliably detect language
|
|
||||||
if (fullText.trim().length < 20) {
|
|
||||||
console.log('Text too short for reliable language detection');
|
|
||||||
core.setOutput('should_continue', 'false'); // Skip processing for very short text
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput('issue_number', issueNumber);
|
|
||||||
core.setOutput('issue_text', fullText);
|
|
||||||
core.setOutput('should_continue', 'true');
|
|
||||||
|
|
||||||
- name: Detect language using AI
|
|
||||||
id: ai_language_detection
|
|
||||||
if: steps.detect_language.outputs.should_continue == 'true'
|
|
||||||
uses: actions/ai-inference@v1.1.0
|
|
||||||
with:
|
|
||||||
model: openai/gpt-4o-mini
|
|
||||||
system-prompt: |
|
|
||||||
You are a language detection system. Your task is to determine if the provided text is written in English or another language.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. Analyze the text and determine the primary language of the USER'S DESCRIPTION only
|
|
||||||
2. IGNORE markdown headers (lines starting with #, ##, ###, etc.) as these are from issue templates, not user input
|
|
||||||
3. IGNORE all code blocks (text between ``` or ` markers) as they may contain system-generated error messages in other languages
|
|
||||||
4. IGNORE error messages, logs, and system output even if not in code blocks - these often appear in the user's system language
|
|
||||||
5. Consider technical terms, code snippets, URLs, and file paths as neutral (they don't indicate non-English)
|
|
||||||
6. Focus ONLY on the actual sentences and descriptions written by the user explaining their issue
|
|
||||||
7. If the user's explanation/description is in English but includes non-English error messages or logs, consider it ENGLISH
|
|
||||||
8. Return ONLY a JSON object with two fields:
|
|
||||||
- "is_english": boolean (true if the user's description is primarily in English, false otherwise)
|
|
||||||
- "detected_language": string (the name of the detected language, e.g., "English", "Spanish", "Chinese", etc.)
|
|
||||||
9. Be lenient - if the user's explanation is in English with non-English system output, it's still English
|
|
||||||
10. Common programming terms, error messages, and technical jargon should not be considered as non-English
|
|
||||||
11. If you cannot reliably determine the language, set detected_language to "undefined"
|
|
||||||
|
|
||||||
Example response:
|
|
||||||
{"is_english": false, "detected_language": "Spanish"}
|
|
||||||
|
|
||||||
prompt: |
|
|
||||||
Please analyze the following issue text and determine if it is written in English:
|
|
||||||
|
|
||||||
${{ steps.detect_language.outputs.issue_text }}
|
|
||||||
|
|
||||||
max-tokens: 50
|
|
||||||
|
|
||||||
- name: Process non-English issues
|
|
||||||
if: steps.detect_language.outputs.should_continue == 'true'
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
env:
|
|
||||||
AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }}
|
|
||||||
ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
|
|
||||||
const aiResponse = process.env.AI_RESPONSE;
|
|
||||||
|
|
||||||
console.log('AI language detection response:', aiResponse);
|
|
||||||
|
|
||||||
let languageResult;
|
|
||||||
try {
|
|
||||||
languageResult = JSON.parse(aiResponse.trim());
|
|
||||||
|
|
||||||
// Validate the response structure
|
|
||||||
if (!languageResult || typeof languageResult.is_english !== 'boolean') {
|
|
||||||
throw new Error('Invalid response structure');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
core.error(`Failed to parse AI response: ${error.message}`);
|
|
||||||
console.log('Raw AI response:', aiResponse);
|
|
||||||
|
|
||||||
// Log more details for debugging
|
|
||||||
core.warning('Defaulting to English due to parsing error');
|
|
||||||
|
|
||||||
// Default to English if we can't parse the response
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (languageResult.is_english) {
|
|
||||||
console.log('Issue is in English, no action needed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If language is undefined or not detected, skip processing
|
|
||||||
if (!languageResult.detected_language || languageResult.detected_language === 'undefined') {
|
|
||||||
console.log('Language could not be determined, skipping processing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Issue detected as non-English: ${languageResult.detected_language}`);
|
|
||||||
|
|
||||||
// Post comment explaining the language requirement
|
|
||||||
const commentBody = [
|
|
||||||
'<!-- workflow: detect-non-english-issues -->',
|
|
||||||
'### 🌐 Non-English issue detected',
|
|
||||||
'',
|
|
||||||
`This issue appears to be written in **${languageResult.detected_language}** rather than English.`,
|
|
||||||
'',
|
|
||||||
'The Home Assistant project uses English as the primary language for issues to ensure that everyone in our international community can participate and help resolve issues. This allows any of our thousands of contributors to jump in and provide assistance.',
|
|
||||||
'',
|
|
||||||
'**What to do:**',
|
|
||||||
'1. Re-create the issue using the English language',
|
|
||||||
'2. If you need help with translation, consider using:',
|
|
||||||
' - Translation tools like Google Translate',
|
|
||||||
' - AI assistants like ChatGPT or Claude',
|
|
||||||
'',
|
|
||||||
'This helps our community provide the best possible support and ensures your issue gets the attention it deserves from our global contributor base.',
|
|
||||||
'',
|
|
||||||
'Thank you for your understanding! 🙏'
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Add comment
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
body: commentBody
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Posted language requirement comment');
|
|
||||||
|
|
||||||
// Add non-english label
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
labels: ['non-english']
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Added non-english label');
|
|
||||||
|
|
||||||
// Close the issue
|
|
||||||
await github.rest.issues.update({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
state: 'closed',
|
|
||||||
state_reason: 'not_planned'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Closed the issue');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
core.error('Failed to process non-English issue:', error.message);
|
|
||||||
if (error.status === 403) {
|
|
||||||
core.error('Permission denied or rate limit exceeded');
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +1,8 @@
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.12.0
|
rev: v0.11.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff
|
||||||
args:
|
args:
|
||||||
- --fix
|
- --fix
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
@ -30,7 +30,7 @@ repos:
|
||||||
- --branch=master
|
- --branch=master
|
||||||
- --branch=rc
|
- --branch=rc
|
||||||
- repo: https://github.com/adrienverge/yamllint.git
|
- repo: https://github.com/adrienverge/yamllint.git
|
||||||
rev: v1.37.1
|
rev: v1.35.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: yamllint
|
- id: yamllint
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
|
|
|
@ -65,9 +65,8 @@ homeassistant.components.aladdin_connect.*
|
||||||
homeassistant.components.alarm_control_panel.*
|
homeassistant.components.alarm_control_panel.*
|
||||||
homeassistant.components.alert.*
|
homeassistant.components.alert.*
|
||||||
homeassistant.components.alexa.*
|
homeassistant.components.alexa.*
|
||||||
homeassistant.components.alexa_devices.*
|
|
||||||
homeassistant.components.alpha_vantage.*
|
homeassistant.components.alpha_vantage.*
|
||||||
homeassistant.components.altruist.*
|
homeassistant.components.amazon_devices.*
|
||||||
homeassistant.components.amazon_polly.*
|
homeassistant.components.amazon_polly.*
|
||||||
homeassistant.components.amberelectric.*
|
homeassistant.components.amberelectric.*
|
||||||
homeassistant.components.ambient_network.*
|
homeassistant.components.ambient_network.*
|
||||||
|
@ -503,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.*
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
{
|
{
|
||||||
"label": "Ruff",
|
"label": "Ruff",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "pre-commit run ruff-check --all-files",
|
"command": "pre-commit run ruff --all-files",
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "test",
|
"kind": "test",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
|
|
|
@ -57,8 +57,6 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/aemet/ @Noltari
|
/tests/components/aemet/ @Noltari
|
||||||
/homeassistant/components/agent_dvr/ @ispysoftware
|
/homeassistant/components/agent_dvr/ @ispysoftware
|
||||||
/tests/components/agent_dvr/ @ispysoftware
|
/tests/components/agent_dvr/ @ispysoftware
|
||||||
/homeassistant/components/ai_task/ @home-assistant/core
|
|
||||||
/tests/components/ai_task/ @home-assistant/core
|
|
||||||
/homeassistant/components/air_quality/ @home-assistant/core
|
/homeassistant/components/air_quality/ @home-assistant/core
|
||||||
/tests/components/air_quality/ @home-assistant/core
|
/tests/components/air_quality/ @home-assistant/core
|
||||||
/homeassistant/components/airgradient/ @airgradienthq @joostlek
|
/homeassistant/components/airgradient/ @airgradienthq @joostlek
|
||||||
|
@ -91,10 +89,8 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/alert/ @home-assistant/core @frenck
|
/tests/components/alert/ @home-assistant/core @frenck
|
||||||
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||||
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
|
||||||
/homeassistant/components/alexa_devices/ @chemelli74
|
/homeassistant/components/amazon_devices/ @chemelli74
|
||||||
/tests/components/alexa_devices/ @chemelli74
|
/tests/components/amazon_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 +327,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
|
||||||
|
@ -788,6 +784,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
|
||||||
|
@ -1169,8 +1167,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
|
||||||
|
@ -1278,8 +1274,8 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/rehlko/ @bdraco @peterager
|
/tests/components/rehlko/ @bdraco @peterager
|
||||||
/homeassistant/components/remote/ @home-assistant/core
|
/homeassistant/components/remote/ @home-assistant/core
|
||||||
/tests/components/remote/ @home-assistant/core
|
/tests/components/remote/ @home-assistant/core
|
||||||
/homeassistant/components/remote_calendar/ @Thomas55555 @allenporter
|
/homeassistant/components/remote_calendar/ @Thomas55555
|
||||||
/tests/components/remote_calendar/ @Thomas55555 @allenporter
|
/tests/components/remote_calendar/ @Thomas55555
|
||||||
/homeassistant/components/renault/ @epenet
|
/homeassistant/components/renault/ @epenet
|
||||||
/tests/components/renault/ @epenet
|
/tests/components/renault/ @epenet
|
||||||
/homeassistant/components/renson/ @jimmyd-be
|
/homeassistant/components/renson/ @jimmyd-be
|
||||||
|
@ -1582,8 +1578,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
|
||||||
|
@ -1672,8 +1666,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
|
||||||
|
|
|
@ -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] = {}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Enum backports from standard lib.
|
||||||
|
|
||||||
|
This file contained the backport of the StrEnum of Python 3.11.
|
||||||
|
|
||||||
|
Since we have dropped support for Python 3.10, we can remove this backport.
|
||||||
|
This file is kept for now to avoid breaking custom components that might
|
||||||
|
import it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum as _StrEnum
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from homeassistant.helpers.deprecation import (
|
||||||
|
DeprecatedAlias,
|
||||||
|
all_with_deprecated_constants,
|
||||||
|
check_if_deprecated_constant,
|
||||||
|
dir_with_deprecated_constants,
|
||||||
|
)
|
||||||
|
|
||||||
|
# StrEnum deprecated as of 2024.5 use enum.StrEnum instead.
|
||||||
|
_DEPRECATED_StrEnum = DeprecatedAlias(_StrEnum, "enum.StrEnum", "2025.5")
|
||||||
|
|
||||||
|
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||||
|
__dir__ = partial(
|
||||||
|
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||||
|
)
|
||||||
|
__all__ = all_with_deprecated_constants(globals())
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Functools backports from standard lib.
|
||||||
|
|
||||||
|
This file contained the backport of the cached_property implementation of Python 3.12.
|
||||||
|
|
||||||
|
Since we have dropped support for Python 3.11, we can remove this backport.
|
||||||
|
This file is kept for now to avoid breaking custom components that might
|
||||||
|
import it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# pylint: disable-next=hass-deprecated-import
|
||||||
|
from functools import cached_property as _cached_property, partial
|
||||||
|
|
||||||
|
from homeassistant.helpers.deprecation import (
|
||||||
|
DeprecatedAlias,
|
||||||
|
all_with_deprecated_constants,
|
||||||
|
check_if_deprecated_constant,
|
||||||
|
dir_with_deprecated_constants,
|
||||||
|
)
|
||||||
|
|
||||||
|
# cached_property deprecated as of 2024.5 use functools.cached_property instead.
|
||||||
|
_DEPRECATED_cached_property = DeprecatedAlias(
|
||||||
|
_cached_property, "functools.cached_property", "2025.5"
|
||||||
|
)
|
||||||
|
|
||||||
|
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||||
|
__dir__ = partial(
|
||||||
|
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||||
|
)
|
||||||
|
__all__ = all_with_deprecated_constants(globals())
|
|
@ -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
|
||||||
|
@ -172,6 +171,8 @@ FRONTEND_INTEGRATIONS = {
|
||||||
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
||||||
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
||||||
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
|
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
|
||||||
|
# The substages preceding it should also have no timeout, until we ensure that the recorder
|
||||||
|
# is not accidentally promoted as a dependency of any of the integrations in them.
|
||||||
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
|
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
|
||||||
STAGE_0_INTEGRATIONS = (
|
STAGE_0_INTEGRATIONS = (
|
||||||
# Load logging and http deps as soon as possible
|
# Load logging and http deps as soon as possible
|
||||||
|
@ -395,7 +396,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")
|
||||||
|
@ -453,7 +454,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(trigger.async_setup(hass)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -563,7 +563,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.
|
||||||
|
@ -607,7 +608,7 @@ async def async_enable_logging(
|
||||||
)
|
)
|
||||||
threading.excepthook = lambda args: logging.getLogger().exception(
|
threading.excepthook = lambda args: logging.getLogger().exception(
|
||||||
"Uncaught thread exception",
|
"Uncaught thread exception",
|
||||||
exc_info=( # type: ignore[arg-type] # noqa: LOG014
|
exc_info=( # type: ignore[arg-type]
|
||||||
args.exc_type,
|
args.exc_type,
|
||||||
args.exc_value,
|
args.exc_value,
|
||||||
args.exc_traceback,
|
args.exc_traceback,
|
||||||
|
@ -928,11 +929,7 @@ async def _async_set_up_integrations(
|
||||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
async with hass.timeout.async_timeout(
|
async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
|
||||||
timeout,
|
|
||||||
cool_down=COOLDOWN_TIME,
|
|
||||||
cancel_message=f"Bootstrap stage {name} timeout",
|
|
||||||
):
|
|
||||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
|
@ -944,11 +941,7 @@ async def _async_set_up_integrations(
|
||||||
# Wrap up startup
|
# Wrap up startup
|
||||||
_LOGGER.debug("Waiting for startup to wrap up")
|
_LOGGER.debug("Waiting for startup to wrap up")
|
||||||
try:
|
try:
|
||||||
async with hass.timeout.async_timeout(
|
async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME):
|
||||||
WRAP_UP_TIMEOUT,
|
|
||||||
cool_down=COOLDOWN_TIME,
|
|
||||||
cancel_message="Bootstrap startup wrap up timeout",
|
|
||||||
):
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
|
@ -1061,5 +1054,5 @@ async def _async_setup_multi_components(
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Error setting up integration %s - received exception",
|
"Error setting up integration %s - received exception",
|
||||||
domain,
|
domain,
|
||||||
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014
|
exc_info=(type(result), result, result.__traceback__),
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
"name": "Amazon",
|
"name": "Amazon",
|
||||||
"integrations": [
|
"integrations": [
|
||||||
"alexa",
|
"alexa",
|
||||||
"alexa_devices",
|
"amazon_devices",
|
||||||
"amazon_polly",
|
"amazon_polly",
|
||||||
"aws",
|
"aws",
|
||||||
"aws_s3",
|
"aws_s3",
|
||||||
|
|
|
@ -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"]
|
|
||||||
}
|
|
|
@ -14,24 +14,30 @@ from jaraco.abode.exceptions import (
|
||||||
)
|
)
|
||||||
from jaraco.abode.helpers.timeline import Groups as GROUPS
|
from jaraco.abode.helpers.timeline import Groups as GROUPS
|
||||||
from requests.exceptions import ConnectTimeout, HTTPError
|
from requests.exceptions import ConnectTimeout, HTTPError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_DATE,
|
ATTR_DATE,
|
||||||
ATTR_DEVICE_ID,
|
ATTR_DEVICE_ID,
|
||||||
|
ATTR_ENTITY_ID,
|
||||||
ATTR_TIME,
|
ATTR_TIME,
|
||||||
CONF_PASSWORD,
|
CONF_PASSWORD,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import CONF_POLLING, DOMAIN, LOGGER
|
from .const import CONF_POLLING, DOMAIN, LOGGER
|
||||||
from .services import async_setup_services
|
|
||||||
|
SERVICE_SETTINGS = "change_setting"
|
||||||
|
SERVICE_CAPTURE_IMAGE = "capture_image"
|
||||||
|
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
|
||||||
|
|
||||||
ATTR_DEVICE_NAME = "device_name"
|
ATTR_DEVICE_NAME = "device_name"
|
||||||
ATTR_DEVICE_TYPE = "device_type"
|
ATTR_DEVICE_TYPE = "device_type"
|
||||||
|
@ -39,12 +45,22 @@ ATTR_EVENT_CODE = "event_code"
|
||||||
ATTR_EVENT_NAME = "event_name"
|
ATTR_EVENT_NAME = "event_name"
|
||||||
ATTR_EVENT_TYPE = "event_type"
|
ATTR_EVENT_TYPE = "event_type"
|
||||||
ATTR_EVENT_UTC = "event_utc"
|
ATTR_EVENT_UTC = "event_utc"
|
||||||
|
ATTR_SETTING = "setting"
|
||||||
ATTR_USER_NAME = "user_name"
|
ATTR_USER_NAME = "user_name"
|
||||||
ATTR_APP_TYPE = "app_type"
|
ATTR_APP_TYPE = "app_type"
|
||||||
ATTR_EVENT_BY = "event_by"
|
ATTR_EVENT_BY = "event_by"
|
||||||
|
ATTR_VALUE = "value"
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||||
|
|
||||||
|
CHANGE_SETTING_SCHEMA = vol.Schema(
|
||||||
|
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
|
||||||
|
)
|
||||||
|
|
||||||
|
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
||||||
|
|
||||||
|
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.ALARM_CONTROL_PANEL,
|
Platform.ALARM_CONTROL_PANEL,
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
|
@ -69,7 +85,7 @@ class AbodeSystem:
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Abode component."""
|
"""Set up the Abode component."""
|
||||||
async_setup_services(hass)
|
setup_hass_services(hass)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -122,6 +138,60 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
def setup_hass_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Home Assistant services."""
|
||||||
|
|
||||||
|
def change_setting(call: ServiceCall) -> None:
|
||||||
|
"""Change an Abode system setting."""
|
||||||
|
setting = call.data[ATTR_SETTING]
|
||||||
|
value = call.data[ATTR_VALUE]
|
||||||
|
|
||||||
|
try:
|
||||||
|
hass.data[DOMAIN].abode.set_setting(setting, value)
|
||||||
|
except AbodeException as ex:
|
||||||
|
LOGGER.warning(ex)
|
||||||
|
|
||||||
|
def capture_image(call: ServiceCall) -> None:
|
||||||
|
"""Capture a new image."""
|
||||||
|
entity_ids = call.data[ATTR_ENTITY_ID]
|
||||||
|
|
||||||
|
target_entities = [
|
||||||
|
entity_id
|
||||||
|
for entity_id in hass.data[DOMAIN].entity_ids
|
||||||
|
if entity_id in entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
for entity_id in target_entities:
|
||||||
|
signal = f"abode_camera_capture_{entity_id}"
|
||||||
|
dispatcher_send(hass, signal)
|
||||||
|
|
||||||
|
def trigger_automation(call: ServiceCall) -> None:
|
||||||
|
"""Trigger an Abode automation."""
|
||||||
|
entity_ids = call.data[ATTR_ENTITY_ID]
|
||||||
|
|
||||||
|
target_entities = [
|
||||||
|
entity_id
|
||||||
|
for entity_id in hass.data[DOMAIN].entity_ids
|
||||||
|
if entity_id in entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
for entity_id in target_entities:
|
||||||
|
signal = f"abode_trigger_automation_{entity_id}"
|
||||||
|
dispatcher_send(hass, signal)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def setup_hass_events(hass: HomeAssistant) -> None:
|
async def setup_hass_events(hass: HomeAssistant) -> None:
|
||||||
"""Home Assistant start and stop callbacks."""
|
"""Home Assistant start and stop callbacks."""
|
||||||
|
|
||||||
|
|
|
@ -1,90 +0,0 @@
|
||||||
"""Support for the Abode Security System."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from jaraco.abode.exceptions import Exception as AbodeException
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
|
||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
|
||||||
|
|
||||||
SERVICE_SETTINGS = "change_setting"
|
|
||||||
SERVICE_CAPTURE_IMAGE = "capture_image"
|
|
||||||
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
|
|
||||||
|
|
||||||
ATTR_SETTING = "setting"
|
|
||||||
ATTR_VALUE = "value"
|
|
||||||
|
|
||||||
|
|
||||||
CHANGE_SETTING_SCHEMA = vol.Schema(
|
|
||||||
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
|
|
||||||
)
|
|
||||||
|
|
||||||
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
|
||||||
|
|
||||||
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
|
||||||
|
|
||||||
|
|
||||||
def _change_setting(call: ServiceCall) -> None:
|
|
||||||
"""Change an Abode system setting."""
|
|
||||||
setting = call.data[ATTR_SETTING]
|
|
||||||
value = call.data[ATTR_VALUE]
|
|
||||||
|
|
||||||
try:
|
|
||||||
call.hass.data[DOMAIN].abode.set_setting(setting, value)
|
|
||||||
except AbodeException as ex:
|
|
||||||
LOGGER.warning(ex)
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_image(call: ServiceCall) -> None:
|
|
||||||
"""Capture a new image."""
|
|
||||||
entity_ids = call.data[ATTR_ENTITY_ID]
|
|
||||||
|
|
||||||
target_entities = [
|
|
||||||
entity_id
|
|
||||||
for entity_id in call.hass.data[DOMAIN].entity_ids
|
|
||||||
if entity_id in entity_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
for entity_id in target_entities:
|
|
||||||
signal = f"abode_camera_capture_{entity_id}"
|
|
||||||
dispatcher_send(call.hass, signal)
|
|
||||||
|
|
||||||
|
|
||||||
def _trigger_automation(call: ServiceCall) -> None:
|
|
||||||
"""Trigger an Abode automation."""
|
|
||||||
entity_ids = call.data[ATTR_ENTITY_ID]
|
|
||||||
|
|
||||||
target_entities = [
|
|
||||||
entity_id
|
|
||||||
for entity_id in call.hass.data[DOMAIN].entity_ids
|
|
||||||
if entity_id in entity_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
for entity_id in target_entities:
|
|
||||||
signal = f"abode_trigger_automation_{entity_id}"
|
|
||||||
dispatcher_send(call.hass, signal)
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Home Assistant services."""
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_TRIGGER_AUTOMATION,
|
|
||||||
_trigger_automation,
|
|
||||||
schema=AUTOMATION_SCHEMA,
|
|
||||||
)
|
|
|
@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
|
||||||
from .const import CONNECTION_TYPE, LOCAL
|
from .const import CONNECTION_TYPE, LOCAL
|
||||||
from .coordinator import AdaxCloudCoordinator, AdaxConfigEntry, AdaxLocalCoordinator
|
from .coordinator import AdaxCloudCoordinator, AdaxConfigEntry, AdaxLocalCoordinator
|
||||||
|
|
||||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
PLATFORMS = [Platform.CLIMATE]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> bool:
|
||||||
|
|
|
@ -41,30 +41,7 @@ class AdaxCloudCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
|
||||||
"""Fetch data from the Adax."""
|
"""Fetch data from the Adax."""
|
||||||
try:
|
rooms = await self.adax_data_handler.get_rooms() or []
|
||||||
if hasattr(self.adax_data_handler, "fetch_rooms_info"):
|
|
||||||
rooms = await self.adax_data_handler.fetch_rooms_info() or []
|
|
||||||
_LOGGER.debug("fetch_rooms_info returned: %s", rooms)
|
|
||||||
else:
|
|
||||||
_LOGGER.debug("fetch_rooms_info method not available, using get_rooms")
|
|
||||||
rooms = []
|
|
||||||
|
|
||||||
if not rooms:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"No rooms from fetch_rooms_info, trying get_rooms as fallback"
|
|
||||||
)
|
|
||||||
rooms = await self.adax_data_handler.get_rooms() or []
|
|
||||||
_LOGGER.debug("get_rooms fallback returned: %s", rooms)
|
|
||||||
|
|
||||||
if not rooms:
|
|
||||||
raise UpdateFailed("No rooms available from Adax API")
|
|
||||||
|
|
||||||
except OSError as e:
|
|
||||||
raise UpdateFailed(f"Error communicating with API: {e}") from e
|
|
||||||
|
|
||||||
for room in rooms:
|
|
||||||
room["energyWh"] = int(room.get("energyWh", 0))
|
|
||||||
|
|
||||||
return {r["id"]: r for r in rooms}
|
return {r["id"]: r for r in rooms}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,77 +0,0 @@
|
||||||
"""Support for Adax energy sensors."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
|
||||||
SensorDeviceClass,
|
|
||||||
SensorEntity,
|
|
||||||
SensorStateClass,
|
|
||||||
)
|
|
||||||
from homeassistant.const import UnitOfEnergy
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
|
|
||||||
from . import AdaxConfigEntry
|
|
||||||
from .const import CONNECTION_TYPE, DOMAIN, LOCAL
|
|
||||||
from .coordinator import AdaxCloudCoordinator
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: AdaxConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up the Adax energy sensors with config flow."""
|
|
||||||
if entry.data.get(CONNECTION_TYPE) != LOCAL:
|
|
||||||
cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data)
|
|
||||||
|
|
||||||
# Create individual energy sensors for each device
|
|
||||||
async_add_entities(
|
|
||||||
AdaxEnergySensor(cloud_coordinator, device_id)
|
|
||||||
for device_id in cloud_coordinator.data
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AdaxEnergySensor(CoordinatorEntity[AdaxCloudCoordinator], SensorEntity):
|
|
||||||
"""Representation of an Adax energy sensor."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
_attr_translation_key = "energy"
|
|
||||||
_attr_device_class = SensorDeviceClass.ENERGY
|
|
||||||
_attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
|
|
||||||
_attr_suggested_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
|
||||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
|
||||||
_attr_suggested_display_precision = 3
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: AdaxCloudCoordinator,
|
|
||||||
device_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the energy sensor."""
|
|
||||||
super().__init__(coordinator)
|
|
||||||
self._device_id = device_id
|
|
||||||
room = coordinator.data[device_id]
|
|
||||||
|
|
||||||
self._attr_unique_id = f"{room['homeId']}_{device_id}_energy"
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, device_id)},
|
|
||||||
name=room["name"],
|
|
||||||
manufacturer="Adax",
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self) -> bool:
|
|
||||||
"""Return True if entity is available."""
|
|
||||||
return (
|
|
||||||
super().available and "energyWh" in self.coordinator.data[self._device_id]
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_value(self) -> int:
|
|
||||||
"""Return the native value of the sensor."""
|
|
||||||
return int(self.coordinator.data[self._device_id]["energyWh"])
|
|
|
@ -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,
|
||||||
|
|
|
@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import AgentDVRConfigEntry
|
from . import AgentDVRConfigEntry
|
||||||
from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN
|
from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN as AGENT_DOMAIN
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS)
|
SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS)
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ class AgentCamera(MjpegCamera):
|
||||||
still_image_url=f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", # noqa: SLF001
|
still_image_url=f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", # noqa: SLF001
|
||||||
)
|
)
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, self.unique_id)},
|
identifiers={(AGENT_DOMAIN, self.unique_id)},
|
||||||
manufacturer="Agent",
|
manufacturer="Agent",
|
||||||
model="Camera",
|
model="Camera",
|
||||||
name=f"{device.client.name} {device.name}",
|
name=f"{device.client.name} {device.name}",
|
||||||
|
|
|
@ -1,134 +0,0 @@
|
||||||
"""Integration to offer AI tasks to Home Assistant."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
|
||||||
from homeassistant.core import (
|
|
||||||
HassJobType,
|
|
||||||
HomeAssistant,
|
|
||||||
ServiceCall,
|
|
||||||
ServiceResponse,
|
|
||||||
SupportsResponse,
|
|
||||||
callback,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers import config_validation as cv, storage
|
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
|
||||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
|
||||||
|
|
||||||
from .const import (
|
|
||||||
ATTR_INSTRUCTIONS,
|
|
||||||
ATTR_TASK_NAME,
|
|
||||||
DATA_COMPONENT,
|
|
||||||
DATA_PREFERENCES,
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_GENERATE_DATA,
|
|
||||||
AITaskEntityFeature,
|
|
||||||
)
|
|
||||||
from .entity import AITaskEntity
|
|
||||||
from .http import async_setup as async_setup_http
|
|
||||||
from .task import GenDataTask, GenDataTaskResult, async_generate_data
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DOMAIN",
|
|
||||||
"AITaskEntity",
|
|
||||||
"AITaskEntityFeature",
|
|
||||||
"GenDataTask",
|
|
||||||
"GenDataTaskResult",
|
|
||||||
"async_generate_data",
|
|
||||||
"async_setup",
|
|
||||||
"async_setup_entry",
|
|
||||||
"async_unload_entry",
|
|
||||||
]
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
||||||
"""Register the process service."""
|
|
||||||
entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass)
|
|
||||||
hass.data[DATA_COMPONENT] = entity_component
|
|
||||||
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
|
|
||||||
await hass.data[DATA_PREFERENCES].async_load()
|
|
||||||
async_setup_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,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
supports_response=SupportsResponse.ONLY,
|
|
||||||
job_type=HassJobType.Coroutinefunction,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Set up a config entry."""
|
|
||||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
||||||
"""Unload a config 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:
|
|
||||||
"""AI Task preferences."""
|
|
||||||
|
|
||||||
KEYS = ("gen_data_entity_id",)
|
|
||||||
|
|
||||||
gen_data_entity_id: str | None = None
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
|
||||||
"""Initialize the preferences."""
|
|
||||||
self._store: storage.Store[dict[str, str | None]] = storage.Store(
|
|
||||||
hass, 1, DOMAIN
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_load(self) -> None:
|
|
||||||
"""Load the data from the store."""
|
|
||||||
data = await self._store.async_load()
|
|
||||||
if data is None:
|
|
||||||
return
|
|
||||||
for key in self.KEYS:
|
|
||||||
setattr(self, key, data[key])
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_set_preferences(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
gen_data_entity_id: str | None | UndefinedType = UNDEFINED,
|
|
||||||
) -> None:
|
|
||||||
"""Set the preferences."""
|
|
||||||
changed = False
|
|
||||||
for key, value in (("gen_data_entity_id", gen_data_entity_id),):
|
|
||||||
if value is not UNDEFINED:
|
|
||||||
if getattr(self, key) != value:
|
|
||||||
setattr(self, key, value)
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if not changed:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._store.async_delay_save(self.as_dict, 10)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def as_dict(self) -> dict[str, str | None]:
|
|
||||||
"""Get the current preferences."""
|
|
||||||
return {key: getattr(self, key) for key in self.KEYS}
|
|
|
@ -1,34 +0,0 @@
|
||||||
"""Constants for the AI Task integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import IntFlag
|
|
||||||
from typing import TYPE_CHECKING, Final
|
|
||||||
|
|
||||||
from homeassistant.util.hass_dict import HassKey
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
|
||||||
|
|
||||||
from . import AITaskPreferences
|
|
||||||
from .entity import AITaskEntity
|
|
||||||
|
|
||||||
DOMAIN = "ai_task"
|
|
||||||
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
|
|
||||||
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
|
|
||||||
|
|
||||||
SERVICE_GENERATE_DATA = "generate_data"
|
|
||||||
|
|
||||||
ATTR_INSTRUCTIONS: Final = "instructions"
|
|
||||||
ATTR_TASK_NAME: Final = "task_name"
|
|
||||||
|
|
||||||
DEFAULT_SYSTEM_PROMPT = (
|
|
||||||
"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."""
|
|
|
@ -1,103 +0,0 @@
|
||||||
"""Entity for the AI Task integration."""
|
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
import contextlib
|
|
||||||
from typing import final
|
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
|
|
||||||
from homeassistant.components.conversation import (
|
|
||||||
ChatLog,
|
|
||||||
UserContent,
|
|
||||||
async_get_chat_log,
|
|
||||||
)
|
|
||||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
|
||||||
from homeassistant.helpers import llm
|
|
||||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
|
||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
|
||||||
from homeassistant.util import dt as dt_util
|
|
||||||
|
|
||||||
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
|
|
||||||
from .task import GenDataTask, GenDataTaskResult
|
|
||||||
|
|
||||||
|
|
||||||
class AITaskEntity(RestoreEntity):
|
|
||||||
"""Entity that supports conversations."""
|
|
||||||
|
|
||||||
_attr_should_poll = False
|
|
||||||
_attr_supported_features = AITaskEntityFeature(0)
|
|
||||||
__last_activity: str | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
@final
|
|
||||||
def state(self) -> str | None:
|
|
||||||
"""Return the state of the entity."""
|
|
||||||
if self.__last_activity is None:
|
|
||||||
return None
|
|
||||||
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:
|
|
||||||
"""Call when the entity is added to hass."""
|
|
||||||
await super().async_internal_added_to_hass()
|
|
||||||
state = await self.async_get_last_state()
|
|
||||||
if (
|
|
||||||
state is not None
|
|
||||||
and state.state is not None
|
|
||||||
and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
|
||||||
):
|
|
||||||
self.__last_activity = state.state
|
|
||||||
|
|
||||||
@final
|
|
||||||
@contextlib.asynccontextmanager
|
|
||||||
async def _async_get_ai_task_chat_log(
|
|
||||||
self,
|
|
||||||
task: GenDataTask,
|
|
||||||
) -> AsyncGenerator[ChatLog]:
|
|
||||||
"""Context manager used to manage the ChatLog used during an AI Task."""
|
|
||||||
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
|
||||||
with (
|
|
||||||
async_get_chat_session(self.hass) as session,
|
|
||||||
async_get_chat_log(
|
|
||||||
self.hass,
|
|
||||||
session,
|
|
||||||
None,
|
|
||||||
) as chat_log,
|
|
||||||
):
|
|
||||||
await chat_log.async_provide_llm_data(
|
|
||||||
llm.LLMContext(
|
|
||||||
platform=self.platform.domain,
|
|
||||||
context=None,
|
|
||||||
language=None,
|
|
||||||
assistant=DOMAIN,
|
|
||||||
device_id=None,
|
|
||||||
),
|
|
||||||
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
|
||||||
)
|
|
||||||
|
|
||||||
chat_log.async_add_user_content(UserContent(task.instructions))
|
|
||||||
|
|
||||||
yield chat_log
|
|
||||||
|
|
||||||
@final
|
|
||||||
async def internal_async_generate_data(
|
|
||||||
self,
|
|
||||||
task: GenDataTask,
|
|
||||||
) -> GenDataTaskResult:
|
|
||||||
"""Run a gen data task."""
|
|
||||||
self.__last_activity = dt_util.utcnow().isoformat()
|
|
||||||
self.async_write_ha_state()
|
|
||||||
async with self._async_get_ai_task_chat_log(task) as chat_log:
|
|
||||||
return await self._async_generate_data(task, chat_log)
|
|
||||||
|
|
||||||
async def _async_generate_data(
|
|
||||||
self,
|
|
||||||
task: GenDataTask,
|
|
||||||
chat_log: ChatLog,
|
|
||||||
) -> GenDataTaskResult:
|
|
||||||
"""Handle a gen data task."""
|
|
||||||
raise NotImplementedError
|
|
|
@ -1,54 +0,0 @@
|
||||||
"""HTTP endpoint for AI Task integration."""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.components import websocket_api
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
|
|
||||||
from .const import DATA_PREFERENCES
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_setup(hass: HomeAssistant) -> None:
|
|
||||||
"""Set up the HTTP API for the conversation integration."""
|
|
||||||
websocket_api.async_register_command(hass, websocket_get_preferences)
|
|
||||||
websocket_api.async_register_command(hass, websocket_set_preferences)
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "ai_task/preferences/get",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@callback
|
|
||||||
def websocket_get_preferences(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: websocket_api.ActiveConnection,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Get AI task preferences."""
|
|
||||||
preferences = hass.data[DATA_PREFERENCES]
|
|
||||||
connection.send_result(msg["id"], preferences.as_dict())
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "ai_task/preferences/set",
|
|
||||||
vol.Optional("gen_data_entity_id"): vol.Any(str, None),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@websocket_api.require_admin
|
|
||||||
@callback
|
|
||||||
def websocket_set_preferences(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: websocket_api.ActiveConnection,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Set AI task preferences."""
|
|
||||||
preferences = hass.data[DATA_PREFERENCES]
|
|
||||||
msg.pop("type")
|
|
||||||
msg_id = msg.pop("id")
|
|
||||||
preferences.async_set_preferences(**msg)
|
|
||||||
connection.send_result(msg_id, preferences.as_dict())
|
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"services": {
|
|
||||||
"generate_data": {
|
|
||||||
"service": "mdi:file-star-four-points-outline"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"domain": "ai_task",
|
|
||||||
"name": "AI Task",
|
|
||||||
"codeowners": ["@home-assistant/core"],
|
|
||||||
"dependencies": ["conversation"],
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
|
||||||
"integration_type": "system",
|
|
||||||
"quality_scale": "internal"
|
|
||||||
}
|
|
|
@ -1,19 +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:
|
|
||||||
entity_id:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
entity:
|
|
||||||
domain: ai_task
|
|
||||||
supported_features:
|
|
||||||
- ai_task.AITaskEntityFeature.GENERATE_DATA
|
|
|
@ -1,22 +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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,75 +0,0 @@
|
||||||
"""AI tasks to be handled by agents."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
|
|
||||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
|
||||||
|
|
||||||
|
|
||||||
async def async_generate_data(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
*,
|
|
||||||
task_name: str,
|
|
||||||
entity_id: str | None = None,
|
|
||||||
instructions: str,
|
|
||||||
) -> GenDataTaskResult:
|
|
||||||
"""Run a task in the AI Task integration."""
|
|
||||||
if entity_id is None:
|
|
||||||
entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
|
|
||||||
|
|
||||||
if entity_id is None:
|
|
||||||
raise HomeAssistantError("No entity_id provided and no preferred entity set")
|
|
||||||
|
|
||||||
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
|
||||||
if entity is None:
|
|
||||||
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
|
|
||||||
|
|
||||||
if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"AI Task entity {entity_id} does not support generating data"
|
|
||||||
)
|
|
||||||
|
|
||||||
return await entity.internal_async_generate_data(
|
|
||||||
GenDataTask(
|
|
||||||
name=task_name,
|
|
||||||
instructions=instructions,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class GenDataTask:
|
|
||||||
"""Gen data task to be processed."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
"""Name of the task."""
|
|
||||||
|
|
||||||
instructions: str
|
|
||||||
"""Instructions on what needs to be done."""
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""Return task as a string."""
|
|
||||||
return f"<GenDataTask {self.name}: {id(self)}>"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class GenDataTaskResult:
|
|
||||||
"""Result of gen data task."""
|
|
||||||
|
|
||||||
conversation_id: str
|
|
||||||
"""Unique identifier for the conversation."""
|
|
||||||
|
|
||||||
data: Any
|
|
||||||
"""Data generated by the task."""
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
"""Return result as a dict."""
|
|
||||||
return {
|
|
||||||
"conversation_id": self.conversation_id,
|
|
||||||
"data": self.data,
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,5 +7,5 @@
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["aioairq"],
|
"loggers": ["aioairq"],
|
||||||
"requirements": ["aioairq==0.4.6"]
|
"requirements": ["aioairq==0.4.4"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,22 +5,23 @@ from __future__ import annotations
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from airthings import Airthings
|
from airthings import Airthings, AirthingsDevice, AirthingsError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_ID, Platform
|
from homeassistant.const import CONF_ID, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import CONF_SECRET
|
from .const import CONF_SECRET, DOMAIN
|
||||||
from .coordinator import AirthingsDataUpdateCoordinator
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
SCAN_INTERVAL = timedelta(minutes=6)
|
SCAN_INTERVAL = timedelta(minutes=6)
|
||||||
|
|
||||||
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
|
type AirthingsDataCoordinatorType = DataUpdateCoordinator[dict[str, AirthingsDevice]]
|
||||||
|
type AirthingsConfigEntry = ConfigEntry[AirthingsDataCoordinatorType]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
|
||||||
|
@ -31,8 +32,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
|
||||||
async_get_clientsession(hass),
|
async_get_clientsession(hass),
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator = AirthingsDataUpdateCoordinator(hass, airthings)
|
async def _update_method() -> dict[str, AirthingsDevice]:
|
||||||
|
"""Get the latest data from Airthings."""
|
||||||
|
try:
|
||||||
|
return await airthings.update_devices() # type: ignore[no-any-return]
|
||||||
|
except AirthingsError as err:
|
||||||
|
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||||
|
|
||||||
|
coordinator = DataUpdateCoordinator(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
config_entry=entry,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_method=_update_method,
|
||||||
|
update_interval=SCAN_INTERVAL,
|
||||||
|
)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
"""The Airthings integration."""
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from airthings import Airthings, AirthingsDevice, AirthingsError
|
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
SCAN_INTERVAL = timedelta(minutes=6)
|
|
||||||
|
|
||||||
|
|
||||||
class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]):
|
|
||||||
"""Coordinator for Airthings data updates."""
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None:
|
|
||||||
"""Initialize the coordinator."""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
name=DOMAIN,
|
|
||||||
update_method=self._update_method,
|
|
||||||
update_interval=SCAN_INTERVAL,
|
|
||||||
)
|
|
||||||
self.airthings = airthings
|
|
||||||
|
|
||||||
async def _update_method(self) -> dict[str, AirthingsDevice]:
|
|
||||||
"""Get the latest data from Airthings."""
|
|
||||||
try:
|
|
||||||
return await self.airthings.update_devices() # type: ignore[no-any-return]
|
|
||||||
except AirthingsError as err:
|
|
||||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
|
|
@ -19,7 +19,6 @@ from homeassistant.const import (
|
||||||
SIGNAL_STRENGTH_DECIBELS,
|
SIGNAL_STRENGTH_DECIBELS,
|
||||||
EntityCategory,
|
EntityCategory,
|
||||||
UnitOfPressure,
|
UnitOfPressure,
|
||||||
UnitOfSoundPressure,
|
|
||||||
UnitOfTemperature,
|
UnitOfTemperature,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -28,44 +27,32 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import AirthingsConfigEntry
|
from . import AirthingsConfigEntry, AirthingsDataCoordinatorType
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import AirthingsDataUpdateCoordinator
|
|
||||||
|
|
||||||
SENSORS: dict[str, SensorEntityDescription] = {
|
SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
"radonShortTermAvg": SensorEntityDescription(
|
"radonShortTermAvg": SensorEntityDescription(
|
||||||
key="radonShortTermAvg",
|
key="radonShortTermAvg",
|
||||||
native_unit_of_measurement="Bq/m³",
|
native_unit_of_measurement="Bq/m³",
|
||||||
translation_key="radon",
|
translation_key="radon",
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"temp": SensorEntityDescription(
|
"temp": SensorEntityDescription(
|
||||||
key="temp",
|
key="temp",
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=1,
|
|
||||||
),
|
),
|
||||||
"humidity": SensorEntityDescription(
|
"humidity": SensorEntityDescription(
|
||||||
key="humidity",
|
key="humidity",
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
device_class=SensorDeviceClass.HUMIDITY,
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"pressure": SensorEntityDescription(
|
"pressure": SensorEntityDescription(
|
||||||
key="pressure",
|
key="pressure",
|
||||||
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||||
native_unit_of_measurement=UnitOfPressure.MBAR,
|
native_unit_of_measurement=UnitOfPressure.MBAR,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=1,
|
|
||||||
),
|
|
||||||
"sla": SensorEntityDescription(
|
|
||||||
key="sla",
|
|
||||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
|
||||||
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"battery": SensorEntityDescription(
|
"battery": SensorEntityDescription(
|
||||||
key="battery",
|
key="battery",
|
||||||
|
@ -73,47 +60,40 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"co2": SensorEntityDescription(
|
"co2": SensorEntityDescription(
|
||||||
key="co2",
|
key="co2",
|
||||||
device_class=SensorDeviceClass.CO2,
|
device_class=SensorDeviceClass.CO2,
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"voc": SensorEntityDescription(
|
"voc": SensorEntityDescription(
|
||||||
key="voc",
|
key="voc",
|
||||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"light": SensorEntityDescription(
|
"light": SensorEntityDescription(
|
||||||
key="light",
|
key="light",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
translation_key="light",
|
translation_key="light",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"lux": SensorEntityDescription(
|
"lux": SensorEntityDescription(
|
||||||
key="lux",
|
key="lux",
|
||||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||||
native_unit_of_measurement=LIGHT_LUX,
|
native_unit_of_measurement=LIGHT_LUX,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"virusRisk": SensorEntityDescription(
|
"virusRisk": SensorEntityDescription(
|
||||||
key="virusRisk",
|
key="virusRisk",
|
||||||
translation_key="virus_risk",
|
translation_key="virus_risk",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"mold": SensorEntityDescription(
|
"mold": SensorEntityDescription(
|
||||||
key="mold",
|
key="mold",
|
||||||
translation_key="mold",
|
translation_key="mold",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"rssi": SensorEntityDescription(
|
"rssi": SensorEntityDescription(
|
||||||
key="rssi",
|
key="rssi",
|
||||||
|
@ -122,21 +102,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"pm1": SensorEntityDescription(
|
"pm1": SensorEntityDescription(
|
||||||
key="pm1",
|
key="pm1",
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
device_class=SensorDeviceClass.PM1,
|
device_class=SensorDeviceClass.PM1,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
"pm25": SensorEntityDescription(
|
"pm25": SensorEntityDescription(
|
||||||
key="pm25",
|
key="pm25",
|
||||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
device_class=SensorDeviceClass.PM25,
|
device_class=SensorDeviceClass.PM25,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
suggested_display_precision=0,
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,7 +140,7 @@ async def async_setup_entry(
|
||||||
|
|
||||||
|
|
||||||
class AirthingsHeaterEnergySensor(
|
class AirthingsHeaterEnergySensor(
|
||||||
CoordinatorEntity[AirthingsDataUpdateCoordinator], SensorEntity
|
CoordinatorEntity[AirthingsDataCoordinatorType], SensorEntity
|
||||||
):
|
):
|
||||||
"""Representation of a Airthings Sensor device."""
|
"""Representation of a Airthings Sensor device."""
|
||||||
|
|
||||||
|
@ -172,7 +149,7 @@ class AirthingsHeaterEnergySensor(
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: AirthingsDataUpdateCoordinator,
|
coordinator: AirthingsDataCoordinatorType,
|
||||||
airthings_device: AirthingsDevice,
|
airthings_device: AirthingsDevice,
|
||||||
entity_description: SensorEntityDescription,
|
entity_description: SensorEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
@ -6,5 +6,5 @@
|
||||||
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
|
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["airtouch5py"],
|
"loggers": ["airtouch5py"],
|
||||||
"requirements": ["airtouch5py==0.3.0"]
|
"requirements": ["airtouch5py==0.2.11"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,115 +0,0 @@
|
||||||
"""Support for binary sensors."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
|
||||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
|
||||||
BinarySensorDeviceClass,
|
|
||||||
BinarySensorEntity,
|
|
||||||
BinarySensorEntityDescription,
|
|
||||||
)
|
|
||||||
from homeassistant.const import EntityCategory
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
|
|
||||||
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 AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
|
||||||
"""Alexa Devices binary sensor entity description."""
|
|
||||||
|
|
||||||
is_on_fn: Callable[[AmazonDevice, str], bool]
|
|
||||||
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
|
|
||||||
|
|
||||||
|
|
||||||
BINARY_SENSORS: Final = (
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="online",
|
|
||||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
is_on_fn=lambda device, _: device.online,
|
|
||||||
),
|
|
||||||
AmazonBinarySensorEntityDescription(
|
|
||||||
key="bluetooth",
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
translation_key="bluetooth",
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: AmazonConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Alexa Devices binary sensors based on a config entry."""
|
|
||||||
|
|
||||||
coordinator = entry.runtime_data
|
|
||||||
|
|
||||||
async_add_entities(
|
|
||||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
|
||||||
for sensor_desc in BINARY_SENSORS
|
|
||||||
for serial_num in coordinator.data
|
|
||||||
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
|
||||||
"""Binary sensor device."""
|
|
||||||
|
|
||||||
entity_description: AmazonBinarySensorEntityDescription
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_on(self) -> bool:
|
|
||||||
"""Return True if the binary sensor is on."""
|
|
||||||
return self.entity_description.is_on_fn(
|
|
||||||
self.device, self.entity_description.key
|
|
||||||
)
|
|
|
@ -1,42 +0,0 @@
|
||||||
{
|
|
||||||
"entity": {
|
|
||||||
"binary_sensor": {
|
|
||||||
"bluetooth": {
|
|
||||||
"default": "mdi:bluetooth-off",
|
|
||||||
"state": {
|
|
||||||
"on": "mdi:bluetooth"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
"""Alexa Devices integration."""
|
"""Amazon Devices integration."""
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -8,13 +8,12 @@ from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.NOTIFY,
|
Platform.NOTIFY,
|
||||||
Platform.SENSOR,
|
|
||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||||
"""Set up Alexa Devices platform."""
|
"""Set up Amazon Devices platform."""
|
||||||
|
|
||||||
coordinator = AmazonDevicesCoordinator(hass, entry)
|
coordinator = AmazonDevicesCoordinator(hass, entry)
|
||||||
|
|
||||||
|
@ -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
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Support for binary 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.binary_sensor import (
|
||||||
|
BinarySensorDeviceClass,
|
||||||
|
BinarySensorEntity,
|
||||||
|
BinarySensorEntityDescription,
|
||||||
|
)
|
||||||
|
from homeassistant.const import EntityCategory
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
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 AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||||
|
"""Amazon Devices binary sensor entity description."""
|
||||||
|
|
||||||
|
is_on_fn: Callable[[AmazonDevice], bool]
|
||||||
|
|
||||||
|
|
||||||
|
BINARY_SENSORS: Final = (
|
||||||
|
AmazonBinarySensorEntityDescription(
|
||||||
|
key="online",
|
||||||
|
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
is_on_fn=lambda _device: _device.online,
|
||||||
|
),
|
||||||
|
AmazonBinarySensorEntityDescription(
|
||||||
|
key="bluetooth",
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
translation_key="bluetooth",
|
||||||
|
is_on_fn=lambda _device: _device.bluetooth_state,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: AmazonConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Amazon Devices binary sensors based on a config entry."""
|
||||||
|
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||||
|
for sensor_desc in BINARY_SENSORS
|
||||||
|
for serial_num in coordinator.data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||||
|
"""Binary sensor device."""
|
||||||
|
|
||||||
|
entity_description: AmazonBinarySensorEntityDescription
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return True if the binary sensor is on."""
|
||||||
|
return self.entity_description.is_on_fn(self.device)
|
|
@ -1,4 +1,4 @@
|
||||||
"""Config flow for Alexa Devices integration."""
|
"""Config flow for Amazon Devices integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ from .const import CONF_LOGIN_DATA, DOMAIN
|
||||||
|
|
||||||
|
|
||||||
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Alexa Devices."""
|
"""Handle a config flow for Amazon Devices."""
|
||||||
|
|
||||||
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
|
|
@ -1,8 +1,8 @@
|
||||||
"""Alexa Devices constants."""
|
"""Amazon Devices constants."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__package__)
|
_LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
DOMAIN = "alexa_devices"
|
DOMAIN = "amazon_devices"
|
||||||
CONF_LOGIN_DATA = "login_data"
|
CONF_LOGIN_DATA = "login_data"
|
|
@ -1,4 +1,4 @@
|
||||||
"""Support for Alexa Devices."""
|
"""Support for Amazon Devices."""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
|
||||||
|
|
||||||
|
|
||||||
class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||||
"""Base coordinator for Alexa Devices."""
|
"""Base coordinator for Amazon Devices."""
|
||||||
|
|
||||||
config_entry: AmazonConfigEntry
|
config_entry: AmazonConfigEntry
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
"""Diagnostics support for Alexa Devices integration."""
|
"""Diagnostics support for Amazon Devices integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
"""Defines a base Alexa Devices entity."""
|
"""Defines a base Amazon Devices entity."""
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice
|
from aioamazondevices.api import AmazonDevice
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_MODEL
|
from aioamazondevices.const import SPEAKER_GROUP_MODEL
|
||||||
|
@ -12,7 +12,7 @@ from .coordinator import AmazonDevicesCoordinator
|
||||||
|
|
||||||
|
|
||||||
class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
||||||
"""Defines a base Alexa Devices entity."""
|
"""Defines a base Amazon Devices entity."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"binary_sensor": {
|
||||||
|
"bluetooth": {
|
||||||
|
"default": "mdi:bluetooth",
|
||||||
|
"state": {
|
||||||
|
"off": "mdi:bluetooth-off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"domain": "alexa_devices",
|
"domain": "amazon_devices",
|
||||||
"name": "Alexa Devices",
|
"name": "Amazon Devices",
|
||||||
"codeowners": ["@chemelli74"],
|
"codeowners": ["@chemelli74"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/alexa_devices",
|
"documentation": "https://www.home-assistant.io/integrations/amazon_devices",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioamazondevices"],
|
"loggers": ["aioamazondevices"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["aioamazondevices==3.1.19"]
|
"requirements": ["aioamazondevices==3.0.6"]
|
||||||
}
|
}
|
|
@ -7,7 +7,6 @@ from dataclasses import dataclass
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
||||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
|
||||||
|
|
||||||
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -21,9 +20,8 @@ PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class AmazonNotifyEntityDescription(NotifyEntityDescription):
|
class AmazonNotifyEntityDescription(NotifyEntityDescription):
|
||||||
"""Alexa Devices notify entity description."""
|
"""Amazon Devices notify entity description."""
|
||||||
|
|
||||||
is_supported: Callable[[AmazonDevice], bool] = lambda _device: True
|
|
||||||
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
|
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
|
||||||
subkey: str
|
subkey: str
|
||||||
|
|
||||||
|
@ -33,7 +31,6 @@ NOTIFY: Final = (
|
||||||
key="speak",
|
key="speak",
|
||||||
translation_key="speak",
|
translation_key="speak",
|
||||||
subkey="AUDIO_PLAYER",
|
subkey="AUDIO_PLAYER",
|
||||||
is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY,
|
|
||||||
method=lambda api, device, message: api.call_alexa_speak(device, message),
|
method=lambda api, device, message: api.call_alexa_speak(device, message),
|
||||||
),
|
),
|
||||||
AmazonNotifyEntityDescription(
|
AmazonNotifyEntityDescription(
|
||||||
|
@ -52,7 +49,7 @@ async def async_setup_entry(
|
||||||
entry: AmazonConfigEntry,
|
entry: AmazonConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Alexa Devices notification entity based on a config entry."""
|
"""Set up Amazon Devices notification entity based on a config entry."""
|
||||||
|
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
|
@ -61,7 +58,6 @@ async def async_setup_entry(
|
||||||
for sensor_desc in NOTIFY
|
for sensor_desc in NOTIFY
|
||||||
for serial_num in coordinator.data
|
for serial_num in coordinator.data
|
||||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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,16 +12,16 @@
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"country": "[%key:common::config_flow::data::country%]",
|
"country": "[%key:component::amazon_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::amazon_devices::common::data_description_code%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
"country": "[%key:component::amazon_devices::common::data_description_country%]",
|
||||||
"username": "[%key:component::alexa_devices::common::data_description_username%]",
|
"username": "[%key:component::amazon_devices::common::data_description_username%]",
|
||||||
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
"password": "[%key:component::amazon_devices::common::data_description_password%]",
|
||||||
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
"code": "[%key:component::amazon_devices::common::data_description_code%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -40,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": {
|
|
@ -20,7 +20,7 @@ PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class AmazonSwitchEntityDescription(SwitchEntityDescription):
|
class AmazonSwitchEntityDescription(SwitchEntityDescription):
|
||||||
"""Alexa Devices switch entity description."""
|
"""Amazon Devices switch entity description."""
|
||||||
|
|
||||||
is_on_fn: Callable[[AmazonDevice], bool]
|
is_on_fn: Callable[[AmazonDevice], bool]
|
||||||
subkey: str
|
subkey: str
|
||||||
|
@ -43,7 +43,7 @@ async def async_setup_entry(
|
||||||
entry: AmazonConfigEntry,
|
entry: AmazonConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Alexa Devices switches based on a config entry."""
|
"""Set up Amazon Devices switches based on a config entry."""
|
||||||
|
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
|
@ -16,7 +16,10 @@ from amcrest import AmcrestError, ApiWrapper, LoginError
|
||||||
import httpx
|
import httpx
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.auth.models import User
|
||||||
|
from homeassistant.auth.permissions.const import POLICY_CONTROL
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
ATTR_ENTITY_ID,
|
||||||
CONF_AUTHENTICATION,
|
CONF_AUTHENTICATION,
|
||||||
CONF_BINARY_SENSORS,
|
CONF_BINARY_SENSORS,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
|
@ -27,17 +30,21 @@ from homeassistant.const import (
|
||||||
CONF_SENSORS,
|
CONF_SENSORS,
|
||||||
CONF_SWITCHES,
|
CONF_SWITCHES,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
|
ENTITY_MATCH_ALL,
|
||||||
|
ENTITY_MATCH_NONE,
|
||||||
HTTP_BASIC_AUTHENTICATION,
|
HTTP_BASIC_AUTHENTICATION,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
|
from homeassistant.exceptions import Unauthorized, UnknownUser
|
||||||
from homeassistant.helpers import config_validation as cv, discovery
|
from homeassistant.helpers import config_validation as cv, discovery
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
|
from homeassistant.helpers.service import async_extract_entity_ids
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors
|
from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors
|
||||||
from .camera import STREAM_SOURCE_LIST
|
from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST
|
||||||
from .const import (
|
from .const import (
|
||||||
CAMERAS,
|
CAMERAS,
|
||||||
COMM_RETRIES,
|
COMM_RETRIES,
|
||||||
|
@ -51,7 +58,6 @@ from .const import (
|
||||||
)
|
)
|
||||||
from .helpers import service_signal
|
from .helpers import service_signal
|
||||||
from .sensor import SENSOR_KEYS
|
from .sensor import SENSOR_KEYS
|
||||||
from .services import async_setup_services
|
|
||||||
from .switch import SWITCH_KEYS
|
from .switch import SWITCH_KEYS
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -449,7 +455,47 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
if not hass.data[DATA_AMCREST][DEVICES]:
|
if not hass.data[DATA_AMCREST][DEVICES]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async_setup_services(hass)
|
def have_permission(user: User | None, entity_id: str) -> bool:
|
||||||
|
return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL)
|
||||||
|
|
||||||
|
async def async_extract_from_service(call: ServiceCall) -> list[str]:
|
||||||
|
if call.context.user_id:
|
||||||
|
user = await hass.auth.async_get_user(call.context.user_id)
|
||||||
|
if user is None:
|
||||||
|
raise UnknownUser(context=call.context)
|
||||||
|
else:
|
||||||
|
user = None
|
||||||
|
|
||||||
|
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
|
||||||
|
# Return all entity_ids user has permission to control.
|
||||||
|
return [
|
||||||
|
entity_id
|
||||||
|
for entity_id in hass.data[DATA_AMCREST][CAMERAS]
|
||||||
|
if have_permission(user, entity_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
|
||||||
|
return []
|
||||||
|
|
||||||
|
call_ids = await async_extract_entity_ids(hass, call)
|
||||||
|
entity_ids = []
|
||||||
|
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
|
||||||
|
if entity_id not in call_ids:
|
||||||
|
continue
|
||||||
|
if not have_permission(user, entity_id):
|
||||||
|
raise Unauthorized(
|
||||||
|
context=call.context, entity_id=entity_id, permission=POLICY_CONTROL
|
||||||
|
)
|
||||||
|
entity_ids.append(entity_id)
|
||||||
|
return entity_ids
|
||||||
|
|
||||||
|
async def async_service_handler(call: ServiceCall) -> None:
|
||||||
|
args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]]
|
||||||
|
for entity_id in await async_extract_from_service(call):
|
||||||
|
async_dispatcher_send(hass, service_signal(call.service, entity_id), *args)
|
||||||
|
|
||||||
|
for service, params in CAMERA_SERVICES.items():
|
||||||
|
hass.services.async_register(DOMAIN, service, async_service_handler, params[0])
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
"""Support for Amcrest IP cameras."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.auth.models import User
|
|
||||||
from homeassistant.auth.permissions.const import POLICY_CONTROL
|
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
|
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
|
||||||
from homeassistant.exceptions import Unauthorized, UnknownUser
|
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
||||||
from homeassistant.helpers.service import async_extract_entity_ids
|
|
||||||
|
|
||||||
from .camera import CAMERA_SERVICES
|
|
||||||
from .const import CAMERAS, DATA_AMCREST, DOMAIN
|
|
||||||
from .helpers import service_signal
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Set up the Amcrest IP Camera services."""
|
|
||||||
|
|
||||||
def have_permission(user: User | None, entity_id: str) -> bool:
|
|
||||||
return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL)
|
|
||||||
|
|
||||||
async def async_extract_from_service(call: ServiceCall) -> list[str]:
|
|
||||||
if call.context.user_id:
|
|
||||||
user = await hass.auth.async_get_user(call.context.user_id)
|
|
||||||
if user is None:
|
|
||||||
raise UnknownUser(context=call.context)
|
|
||||||
else:
|
|
||||||
user = None
|
|
||||||
|
|
||||||
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
|
|
||||||
# Return all entity_ids user has permission to control.
|
|
||||||
return [
|
|
||||||
entity_id
|
|
||||||
for entity_id in hass.data[DATA_AMCREST][CAMERAS]
|
|
||||||
if have_permission(user, entity_id)
|
|
||||||
]
|
|
||||||
|
|
||||||
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
|
|
||||||
return []
|
|
||||||
|
|
||||||
call_ids = await async_extract_entity_ids(hass, call)
|
|
||||||
entity_ids = []
|
|
||||||
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
|
|
||||||
if entity_id not in call_ids:
|
|
||||||
continue
|
|
||||||
if not have_permission(user, entity_id):
|
|
||||||
raise Unauthorized(
|
|
||||||
context=call.context, entity_id=entity_id, permission=POLICY_CONTROL
|
|
||||||
)
|
|
||||||
entity_ids.append(entity_id)
|
|
||||||
return entity_ids
|
|
||||||
|
|
||||||
async def async_service_handler(call: ServiceCall) -> None:
|
|
||||||
args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]]
|
|
||||||
for entity_id in await async_extract_from_service(call):
|
|
||||||
async_dispatcher_send(hass, service_signal(call.service, entity_id), *args)
|
|
||||||
|
|
||||||
for service, params in CAMERA_SERVICES.items():
|
|
||||||
hass.services.async_register(DOMAIN, service, async_service_handler, params[0])
|
|
|
@ -24,7 +24,7 @@ from homeassistant.components.recorder import (
|
||||||
get_instance as get_recorder_instance,
|
get_instance as get_recorder_instance,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import SOURCE_IGNORE
|
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, __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 entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
@ -225,8 +225,7 @@ class Analytics:
|
||||||
LOGGER.error(err)
|
LOGGER.error(err)
|
||||||
return
|
return
|
||||||
|
|
||||||
configuration_set = _domains_from_yaml_config(yaml_configuration)
|
configuration_set = set(yaml_configuration)
|
||||||
|
|
||||||
er_platforms = {
|
er_platforms = {
|
||||||
entity.platform
|
entity.platform
|
||||||
for entity in ent_reg.entities.values()
|
for entity in ent_reg.entities.values()
|
||||||
|
@ -371,13 +370,3 @@ class Analytics:
|
||||||
for entry in entries
|
for entry in entries
|
||||||
if entry.source != SOURCE_IGNORE and entry.disabled_by is None
|
if entry.source != SOURCE_IGNORE and entry.disabled_by is None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
|
||||||
"""Extract domains from the YAML configuration."""
|
|
||||||
domains = set(yaml_configuration)
|
|
||||||
for platforms in conf_util.extract_platform_integrations(
|
|
||||||
yaml_configuration, BASE_PLATFORMS
|
|
||||||
).values():
|
|
||||||
domains.update(platforms)
|
|
||||||
return domains
|
|
||||||
|
|
|
@ -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",
|
|
||||||
)
|
|
||||||
|
|
|
@ -6,16 +6,11 @@ from functools import partial
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_API_KEY, Platform
|
from homeassistant.const import CONF_API_KEY, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import config_validation as cv
|
||||||
config_validation as cv,
|
|
||||||
device_registry as dr,
|
|
||||||
entity_registry as er,
|
|
||||||
)
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
|
|
||||||
from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
|
from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
|
||||||
|
|
||||||
|
@ -25,24 +20,13 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
||||||
"""Set up Anthropic."""
|
|
||||||
await async_migrate_integration(hass)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||||
"""Set up Anthropic from a config entry."""
|
"""Set up Anthropic from a config entry."""
|
||||||
client = await hass.async_add_executor_job(
|
client = await hass.async_add_executor_job(
|
||||||
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
|
partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Use model from first conversation subentry for validation
|
model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||||
subentries = list(entry.subentries.values())
|
|
||||||
if subentries:
|
|
||||||
model_id = subentries[0].data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
|
||||||
else:
|
|
||||||
model_id = RECOMMENDED_CHAT_MODEL
|
|
||||||
model = await client.models.retrieve(model_id=model_id, timeout=10.0)
|
model = await client.models.retrieve(model_id=model_id, timeout=10.0)
|
||||||
LOGGER.debug("Anthropic model: %s", model.display_name)
|
LOGGER.debug("Anthropic model: %s", model.display_name)
|
||||||
except anthropic.AuthenticationError as err:
|
except anthropic.AuthenticationError as err:
|
||||||
|
@ -61,68 +45,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload Anthropic."""
|
"""Unload Anthropic."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
|
||||||
"""Migrate integration entry structure."""
|
|
||||||
|
|
||||||
entries = hass.config_entries.async_entries(DOMAIN)
|
|
||||||
if not any(entry.version == 1 for entry in entries):
|
|
||||||
return
|
|
||||||
|
|
||||||
api_keys_entries: dict[str, ConfigEntry] = {}
|
|
||||||
entity_registry = er.async_get(hass)
|
|
||||||
device_registry = dr.async_get(hass)
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
use_existing = False
|
|
||||||
subentry = ConfigSubentry(
|
|
||||||
data=entry.options,
|
|
||||||
subentry_type="conversation",
|
|
||||||
title=entry.title,
|
|
||||||
unique_id=None,
|
|
||||||
)
|
|
||||||
if entry.data[CONF_API_KEY] not in api_keys_entries:
|
|
||||||
use_existing = True
|
|
||||||
api_keys_entries[entry.data[CONF_API_KEY]] = entry
|
|
||||||
|
|
||||||
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
|
|
||||||
|
|
||||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
|
||||||
conversation_entity = entity_registry.async_get_entity_id(
|
|
||||||
"conversation",
|
|
||||||
DOMAIN,
|
|
||||||
entry.entry_id,
|
|
||||||
)
|
|
||||||
if conversation_entity is not None:
|
|
||||||
entity_registry.async_update_entity(
|
|
||||||
conversation_entity,
|
|
||||||
config_entry_id=parent_entry.entry_id,
|
|
||||||
config_subentry_id=subentry.subentry_id,
|
|
||||||
new_unique_id=subentry.subentry_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
device = device_registry.async_get_device(
|
|
||||||
identifiers={(DOMAIN, entry.entry_id)}
|
|
||||||
)
|
|
||||||
if device is not None:
|
|
||||||
device_registry.async_update_device(
|
|
||||||
device.id,
|
|
||||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
|
||||||
add_config_subentry_id=subentry.subentry_id,
|
|
||||||
add_config_entry_id=parent_entry.entry_id,
|
|
||||||
)
|
|
||||||
if parent_entry.entry_id != entry.entry_id:
|
|
||||||
device_registry.async_update_device(
|
|
||||||
device.id,
|
|
||||||
remove_config_entry_id=entry.entry_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not use_existing:
|
|
||||||
await hass.config_entries.async_remove(entry.entry_id)
|
|
||||||
else:
|
|
||||||
hass.config_entries.async_update_entry(
|
|
||||||
entry,
|
|
||||||
options={},
|
|
||||||
version=2,
|
|
||||||
)
|
|
||||||
|
|
|
@ -5,21 +5,20 @@ from __future__ import annotations
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
ConfigEntryState,
|
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
ConfigSubentryFlow,
|
OptionsFlow,
|
||||||
SubentryFlowResult,
|
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
|
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import llm
|
from homeassistant.helpers import llm
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
NumberSelector,
|
NumberSelector,
|
||||||
|
@ -37,7 +36,6 @@ from .const import (
|
||||||
CONF_RECOMMENDED,
|
CONF_RECOMMENDED,
|
||||||
CONF_TEMPERATURE,
|
CONF_TEMPERATURE,
|
||||||
CONF_THINKING_BUDGET,
|
CONF_THINKING_BUDGET,
|
||||||
DEFAULT_CONVERSATION_NAME,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
RECOMMENDED_CHAT_MODEL,
|
RECOMMENDED_CHAT_MODEL,
|
||||||
RECOMMENDED_MAX_TOKENS,
|
RECOMMENDED_MAX_TOKENS,
|
||||||
|
@ -74,7 +72,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||||
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Anthropic."""
|
"""Handle a config flow for Anthropic."""
|
||||||
|
|
||||||
VERSION = 2
|
VERSION = 1
|
||||||
|
|
||||||
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
|
||||||
|
@ -83,7 +81,6 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._async_abort_entries_match(user_input)
|
|
||||||
try:
|
try:
|
||||||
await validate_input(self.hass, user_input)
|
await validate_input(self.hass, user_input)
|
||||||
except anthropic.APITimeoutError:
|
except anthropic.APITimeoutError:
|
||||||
|
@ -105,93 +102,57 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title="Claude",
|
title="Claude",
|
||||||
data=user_input,
|
data=user_input,
|
||||||
subentries=[
|
options=RECOMMENDED_OPTIONS,
|
||||||
{
|
|
||||||
"subentry_type": "conversation",
|
|
||||||
"data": RECOMMENDED_OPTIONS,
|
|
||||||
"title": DEFAULT_CONVERSATION_NAME,
|
|
||||||
"unique_id": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
@callback
|
def async_get_options_flow(
|
||||||
def async_get_supported_subentry_types(
|
config_entry: ConfigEntry,
|
||||||
cls, config_entry: ConfigEntry
|
) -> OptionsFlow:
|
||||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
"""Create the options flow."""
|
||||||
"""Return subentries supported by this integration."""
|
return AnthropicOptionsFlow(config_entry)
|
||||||
return {"conversation": ConversationSubentryFlowHandler}
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
class AnthropicOptionsFlow(OptionsFlow):
|
||||||
"""Flow for managing conversation subentries."""
|
"""Anthropic config flow options handler."""
|
||||||
|
|
||||||
last_rendered_recommended = False
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize options flow."""
|
||||||
|
self.last_rendered_recommended = config_entry.options.get(
|
||||||
|
CONF_RECOMMENDED, False
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
async def async_step_init(
|
||||||
def _is_new(self) -> bool:
|
|
||||||
"""Return if this is a new subentry."""
|
|
||||||
return self.source == "user"
|
|
||||||
|
|
||||||
async def async_step_set_options(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> SubentryFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Set conversation options."""
|
"""Manage the options."""
|
||||||
# abort if entry is not loaded
|
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
|
||||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
|
||||||
return self.async_abort(reason="entry_not_loaded")
|
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is None:
|
if user_input is not None:
|
||||||
if self._is_new:
|
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
||||||
options = RECOMMENDED_OPTIONS.copy()
|
if not user_input.get(CONF_LLM_HASS_API):
|
||||||
|
user_input.pop(CONF_LLM_HASS_API, None)
|
||||||
|
if user_input.get(
|
||||||
|
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
|
||||||
|
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
|
||||||
|
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
return self.async_create_entry(title="", data=user_input)
|
||||||
else:
|
else:
|
||||||
# If this is a reconfiguration, we need to copy the existing options
|
# Re-render the options again, now with the recommended options shown/hidden
|
||||||
# so that we can show the current values in the form.
|
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
||||||
options = self._get_reconfigure_subentry().data.copy()
|
|
||||||
|
|
||||||
self.last_rendered_recommended = cast(
|
options = {
|
||||||
bool, options.get(CONF_RECOMMENDED, False)
|
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
|
||||||
)
|
CONF_PROMPT: user_input[CONF_PROMPT],
|
||||||
|
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
|
||||||
elif user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
}
|
||||||
if not user_input.get(CONF_LLM_HASS_API):
|
|
||||||
user_input.pop(CONF_LLM_HASS_API, None)
|
|
||||||
if user_input.get(
|
|
||||||
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
|
|
||||||
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
|
|
||||||
errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
|
|
||||||
|
|
||||||
if not errors:
|
|
||||||
if self._is_new:
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=user_input.pop(CONF_NAME),
|
|
||||||
data=user_input,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_update_and_abort(
|
|
||||||
self._get_entry(),
|
|
||||||
self._get_reconfigure_subentry(),
|
|
||||||
data=user_input,
|
|
||||||
)
|
|
||||||
|
|
||||||
options = user_input
|
|
||||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
|
||||||
else:
|
|
||||||
# Re-render the options again, now with the recommended options shown/hidden
|
|
||||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
|
||||||
|
|
||||||
options = {
|
|
||||||
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
|
|
||||||
CONF_PROMPT: user_input[CONF_PROMPT],
|
|
||||||
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
|
|
||||||
}
|
|
||||||
|
|
||||||
suggested_values = options.copy()
|
suggested_values = options.copy()
|
||||||
if not suggested_values.get(CONF_PROMPT):
|
if not suggested_values.get(CONF_PROMPT):
|
||||||
|
@ -202,25 +163,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||||
suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis]
|
suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis]
|
||||||
|
|
||||||
schema = self.add_suggested_values_to_schema(
|
schema = self.add_suggested_values_to_schema(
|
||||||
vol.Schema(
|
vol.Schema(anthropic_config_option_schema(self.hass, options)),
|
||||||
anthropic_config_option_schema(self.hass, self._is_new, options)
|
|
||||||
),
|
|
||||||
suggested_values,
|
suggested_values,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="set_options",
|
step_id="init",
|
||||||
data_schema=schema,
|
data_schema=schema,
|
||||||
errors=errors or None,
|
errors=errors or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async_step_user = async_step_set_options
|
|
||||||
async_step_reconfigure = async_step_set_options
|
|
||||||
|
|
||||||
|
|
||||||
def anthropic_config_option_schema(
|
def anthropic_config_option_schema(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
is_new: bool,
|
|
||||||
options: Mapping[str, Any],
|
options: Mapping[str, Any],
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a schema for Anthropic completion options."""
|
"""Return a schema for Anthropic completion options."""
|
||||||
|
@ -232,24 +187,15 @@ def anthropic_config_option_schema(
|
||||||
for api in llm.async_get_apis(hass)
|
for api in llm.async_get_apis(hass)
|
||||||
]
|
]
|
||||||
|
|
||||||
if is_new:
|
schema = {
|
||||||
schema: dict[vol.Required | vol.Optional, Any] = {
|
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||||
vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str,
|
vol.Optional(
|
||||||
}
|
CONF_LLM_HASS_API,
|
||||||
else:
|
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
||||||
schema = {}
|
vol.Required(
|
||||||
|
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||||
schema.update(
|
): bool,
|
||||||
{
|
}
|
||||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
|
||||||
vol.Optional(
|
|
||||||
CONF_LLM_HASS_API,
|
|
||||||
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
|
||||||
vol.Required(
|
|
||||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
|
||||||
): bool,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if options.get(CONF_RECOMMENDED):
|
if options.get(CONF_RECOMMENDED):
|
||||||
return schema
|
return schema
|
||||||
|
|
|
@ -5,8 +5,6 @@ import logging
|
||||||
DOMAIN = "anthropic"
|
DOMAIN = "anthropic"
|
||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
|
||||||
|
|
||||||
CONF_RECOMMENDED = "recommended"
|
CONF_RECOMMENDED = "recommended"
|
||||||
CONF_PROMPT = "prompt"
|
CONF_PROMPT = "prompt"
|
||||||
CONF_CHAT_MODEL = "chat_model"
|
CONF_CHAT_MODEL = "chat_model"
|
||||||
|
|
|
@ -38,7 +38,7 @@ from anthropic.types import (
|
||||||
from voluptuous_openapi import convert
|
from voluptuous_openapi import convert
|
||||||
|
|
||||||
from homeassistant.components import conversation
|
from homeassistant.components import conversation
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
@ -72,14 +72,8 @@ async def async_setup_entry(
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up conversation entities."""
|
"""Set up conversation entities."""
|
||||||
for subentry in config_entry.subentries.values():
|
agent = AnthropicConversationEntity(config_entry)
|
||||||
if subentry.subentry_type != "conversation":
|
async_add_entities([agent])
|
||||||
continue
|
|
||||||
|
|
||||||
async_add_entities(
|
|
||||||
[AnthropicConversationEntity(config_entry, subentry)],
|
|
||||||
config_subentry_id=subentry.subentry_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_tool(
|
def _format_tool(
|
||||||
|
@ -332,22 +326,21 @@ class AnthropicConversationEntity(
|
||||||
):
|
):
|
||||||
"""Anthropic conversation agent."""
|
"""Anthropic conversation agent."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
_attr_supports_streaming = True
|
_attr_supports_streaming = True
|
||||||
|
|
||||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
def __init__(self, entry: AnthropicConfigEntry) -> None:
|
||||||
"""Initialize the agent."""
|
"""Initialize the agent."""
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.subentry = subentry
|
self._attr_unique_id = entry.entry_id
|
||||||
self._attr_name = subentry.title
|
|
||||||
self._attr_unique_id = subentry.subentry_id
|
|
||||||
self._attr_device_info = dr.DeviceInfo(
|
self._attr_device_info = dr.DeviceInfo(
|
||||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
identifiers={(DOMAIN, entry.entry_id)},
|
||||||
name=subentry.title,
|
|
||||||
manufacturer="Anthropic",
|
manufacturer="Anthropic",
|
||||||
model="Claude",
|
model="Claude",
|
||||||
entry_type=dr.DeviceEntryType.SERVICE,
|
entry_type=dr.DeviceEntryType.SERVICE,
|
||||||
)
|
)
|
||||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||||
self._attr_supported_features = (
|
self._attr_supported_features = (
|
||||||
conversation.ConversationEntityFeature.CONTROL
|
conversation.ConversationEntityFeature.CONTROL
|
||||||
)
|
)
|
||||||
|
@ -370,38 +363,18 @@ class AnthropicConversationEntity(
|
||||||
chat_log: conversation.ChatLog,
|
chat_log: conversation.ChatLog,
|
||||||
) -> conversation.ConversationResult:
|
) -> conversation.ConversationResult:
|
||||||
"""Call the API."""
|
"""Call the API."""
|
||||||
options = self.subentry.data
|
options = self.entry.options
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await chat_log.async_provide_llm_data(
|
await chat_log.async_update_llm_data(
|
||||||
user_input.as_llm_context(DOMAIN),
|
DOMAIN,
|
||||||
|
user_input,
|
||||||
options.get(CONF_LLM_HASS_API),
|
options.get(CONF_LLM_HASS_API),
|
||||||
options.get(CONF_PROMPT),
|
options.get(CONF_PROMPT),
|
||||||
user_input.extra_system_prompt,
|
|
||||||
)
|
)
|
||||||
except conversation.ConverseError as err:
|
except conversation.ConverseError as err:
|
||||||
return err.as_conversation_result()
|
return err.as_conversation_result()
|
||||||
|
|
||||||
await self._async_handle_chat_log(chat_log)
|
|
||||||
|
|
||||||
response_content = chat_log.content[-1]
|
|
||||||
if not isinstance(response_content, conversation.AssistantContent):
|
|
||||||
raise TypeError("Last message must be an assistant message")
|
|
||||||
intent_response = intent.IntentResponse(language=user_input.language)
|
|
||||||
intent_response.async_set_speech(response_content.content or "")
|
|
||||||
return conversation.ConversationResult(
|
|
||||||
response=intent_response,
|
|
||||||
conversation_id=chat_log.conversation_id,
|
|
||||||
continue_conversation=chat_log.continue_conversation,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _async_handle_chat_log(
|
|
||||||
self,
|
|
||||||
chat_log: conversation.ChatLog,
|
|
||||||
) -> None:
|
|
||||||
"""Generate an answer for the chat log."""
|
|
||||||
options = self.subentry.data
|
|
||||||
|
|
||||||
tools: list[ToolParam] | None = None
|
tools: list[ToolParam] | None = None
|
||||||
if chat_log.llm_api:
|
if chat_log.llm_api:
|
||||||
tools = [
|
tools = [
|
||||||
|
@ -451,7 +424,7 @@ class AnthropicConversationEntity(
|
||||||
[
|
[
|
||||||
content
|
content
|
||||||
async for content in chat_log.async_add_delta_content_stream(
|
async for content in chat_log.async_add_delta_content_stream(
|
||||||
self.entity_id,
|
user_input.agent_id,
|
||||||
_transform_stream(chat_log, stream, messages),
|
_transform_stream(chat_log, stream, messages),
|
||||||
)
|
)
|
||||||
if not isinstance(content, conversation.AssistantContent)
|
if not isinstance(content, conversation.AssistantContent)
|
||||||
|
@ -462,6 +435,17 @@ class AnthropicConversationEntity(
|
||||||
if not chat_log.unresponded_tool_results:
|
if not chat_log.unresponded_tool_results:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
response_content = chat_log.content[-1]
|
||||||
|
if not isinstance(response_content, conversation.AssistantContent):
|
||||||
|
raise TypeError("Last message must be an assistant message")
|
||||||
|
intent_response = intent.IntentResponse(language=user_input.language)
|
||||||
|
intent_response.async_set_speech(response_content.content or "")
|
||||||
|
return conversation.ConversationResult(
|
||||||
|
response=intent_response,
|
||||||
|
conversation_id=chat_log.conversation_id,
|
||||||
|
continue_conversation=chat_log.continue_conversation,
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_entry_update_listener(
|
async def _async_entry_update_listener(
|
||||||
self, hass: HomeAssistant, entry: ConfigEntry
|
self, hass: HomeAssistant, entry: ConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
@ -12,44 +12,28 @@
|
||||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||||
"authentication_error": "[%key:common::config_flow::error::invalid_auth%]",
|
"authentication_error": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"config_subentries": {
|
"options": {
|
||||||
"conversation": {
|
"step": {
|
||||||
"initiate_flow": {
|
"init": {
|
||||||
"user": "Add conversation agent",
|
"data": {
|
||||||
"reconfigure": "Reconfigure conversation agent"
|
"prompt": "Instructions",
|
||||||
},
|
"chat_model": "[%key:common::generic::model%]",
|
||||||
"entry_type": "Conversation agent",
|
"max_tokens": "Maximum tokens to return in response",
|
||||||
|
"temperature": "Temperature",
|
||||||
"step": {
|
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||||
"set_options": {
|
"recommended": "Recommended model settings",
|
||||||
"data": {
|
"thinking_budget_tokens": "Thinking budget"
|
||||||
"name": "[%key:common::config_flow::data::name%]",
|
},
|
||||||
"prompt": "Instructions",
|
"data_description": {
|
||||||
"chat_model": "[%key:common::generic::model%]",
|
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||||
"max_tokens": "Maximum tokens to return in response",
|
"thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking."
|
||||||
"temperature": "Temperature",
|
|
||||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
|
||||||
"recommended": "Recommended model settings",
|
|
||||||
"thinking_budget_tokens": "Thinking budget"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
|
||||||
"thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"abort": {
|
|
||||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
|
||||||
"entry_not_loaded": "Cannot add things while the configuration is disabled."
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
|
||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
EntityCategory,
|
|
||||||
UnitOfApparentPower,
|
UnitOfApparentPower,
|
||||||
UnitOfElectricCurrent,
|
UnitOfElectricCurrent,
|
||||||
UnitOfElectricPotential,
|
UnitOfElectricPotential,
|
||||||
|
@ -36,7 +35,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
"alarmdel": SensorEntityDescription(
|
"alarmdel": SensorEntityDescription(
|
||||||
key="alarmdel",
|
key="alarmdel",
|
||||||
translation_key="alarm_delay",
|
translation_key="alarm_delay",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"ambtemp": SensorEntityDescription(
|
"ambtemp": SensorEntityDescription(
|
||||||
key="ambtemp",
|
key="ambtemp",
|
||||||
|
@ -49,18 +47,15 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
key="apc",
|
key="apc",
|
||||||
translation_key="apc_status",
|
translation_key="apc_status",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"apcmodel": SensorEntityDescription(
|
"apcmodel": SensorEntityDescription(
|
||||||
key="apcmodel",
|
key="apcmodel",
|
||||||
translation_key="apc_model",
|
translation_key="apc_model",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"badbatts": SensorEntityDescription(
|
"badbatts": SensorEntityDescription(
|
||||||
key="badbatts",
|
key="badbatts",
|
||||||
translation_key="bad_batteries",
|
translation_key="bad_batteries",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"battdate": SensorEntityDescription(
|
"battdate": SensorEntityDescription(
|
||||||
key="battdate",
|
key="battdate",
|
||||||
|
@ -87,7 +82,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
key="cable",
|
key="cable",
|
||||||
translation_key="cable_type",
|
translation_key="cable_type",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"cumonbatt": SensorEntityDescription(
|
"cumonbatt": SensorEntityDescription(
|
||||||
key="cumonbatt",
|
key="cumonbatt",
|
||||||
|
@ -100,63 +94,52 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
key="date",
|
key="date",
|
||||||
translation_key="date",
|
translation_key="date",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"dipsw": SensorEntityDescription(
|
"dipsw": SensorEntityDescription(
|
||||||
key="dipsw",
|
key="dipsw",
|
||||||
translation_key="dip_switch_settings",
|
translation_key="dip_switch_settings",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"dlowbatt": SensorEntityDescription(
|
"dlowbatt": SensorEntityDescription(
|
||||||
key="dlowbatt",
|
key="dlowbatt",
|
||||||
translation_key="low_battery_signal",
|
translation_key="low_battery_signal",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"driver": SensorEntityDescription(
|
"driver": SensorEntityDescription(
|
||||||
key="driver",
|
key="driver",
|
||||||
translation_key="driver",
|
translation_key="driver",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"dshutd": SensorEntityDescription(
|
"dshutd": SensorEntityDescription(
|
||||||
key="dshutd",
|
key="dshutd",
|
||||||
translation_key="shutdown_delay",
|
translation_key="shutdown_delay",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"dwake": SensorEntityDescription(
|
"dwake": SensorEntityDescription(
|
||||||
key="dwake",
|
key="dwake",
|
||||||
translation_key="wake_delay",
|
translation_key="wake_delay",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"end apc": SensorEntityDescription(
|
"end apc": SensorEntityDescription(
|
||||||
key="end apc",
|
key="end apc",
|
||||||
translation_key="date_and_time",
|
translation_key="date_and_time",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"extbatts": SensorEntityDescription(
|
"extbatts": SensorEntityDescription(
|
||||||
key="extbatts",
|
key="extbatts",
|
||||||
translation_key="external_batteries",
|
translation_key="external_batteries",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"firmware": SensorEntityDescription(
|
"firmware": SensorEntityDescription(
|
||||||
key="firmware",
|
key="firmware",
|
||||||
translation_key="firmware_version",
|
translation_key="firmware_version",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"hitrans": SensorEntityDescription(
|
"hitrans": SensorEntityDescription(
|
||||||
key="hitrans",
|
key="hitrans",
|
||||||
translation_key="transfer_high",
|
translation_key="transfer_high",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"hostname": SensorEntityDescription(
|
"hostname": SensorEntityDescription(
|
||||||
key="hostname",
|
key="hostname",
|
||||||
translation_key="hostname",
|
translation_key="hostname",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"humidity": SensorEntityDescription(
|
"humidity": SensorEntityDescription(
|
||||||
key="humidity",
|
key="humidity",
|
||||||
|
@ -180,12 +163,10 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
key="lastxfer",
|
key="lastxfer",
|
||||||
translation_key="last_transfer",
|
translation_key="last_transfer",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"linefail": SensorEntityDescription(
|
"linefail": SensorEntityDescription(
|
||||||
key="linefail",
|
key="linefail",
|
||||||
translation_key="line_failure",
|
translation_key="line_failure",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"linefreq": SensorEntityDescription(
|
"linefreq": SensorEntityDescription(
|
||||||
key="linefreq",
|
key="linefreq",
|
||||||
|
@ -217,18 +198,15 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
translation_key="transfer_low",
|
translation_key="transfer_low",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"mandate": SensorEntityDescription(
|
"mandate": SensorEntityDescription(
|
||||||
key="mandate",
|
key="mandate",
|
||||||
translation_key="manufacture_date",
|
translation_key="manufacture_date",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"masterupd": SensorEntityDescription(
|
"masterupd": SensorEntityDescription(
|
||||||
key="masterupd",
|
key="masterupd",
|
||||||
translation_key="master_update",
|
translation_key="master_update",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"maxlinev": SensorEntityDescription(
|
"maxlinev": SensorEntityDescription(
|
||||||
key="maxlinev",
|
key="maxlinev",
|
||||||
|
@ -239,13 +217,11 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
"maxtime": SensorEntityDescription(
|
"maxtime": SensorEntityDescription(
|
||||||
key="maxtime",
|
key="maxtime",
|
||||||
translation_key="max_time",
|
translation_key="max_time",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"mbattchg": SensorEntityDescription(
|
"mbattchg": SensorEntityDescription(
|
||||||
key="mbattchg",
|
key="mbattchg",
|
||||||
translation_key="max_battery_charge",
|
translation_key="max_battery_charge",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"minlinev": SensorEntityDescription(
|
"minlinev": SensorEntityDescription(
|
||||||
key="minlinev",
|
key="minlinev",
|
||||||
|
@ -256,48 +232,41 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
"mintimel": SensorEntityDescription(
|
"mintimel": SensorEntityDescription(
|
||||||
key="mintimel",
|
key="mintimel",
|
||||||
translation_key="min_time",
|
translation_key="min_time",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"model": SensorEntityDescription(
|
"model": SensorEntityDescription(
|
||||||
key="model",
|
key="model",
|
||||||
translation_key="model",
|
translation_key="model",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nombattv": SensorEntityDescription(
|
"nombattv": SensorEntityDescription(
|
||||||
key="nombattv",
|
key="nombattv",
|
||||||
translation_key="battery_nominal_voltage",
|
translation_key="battery_nominal_voltage",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nominv": SensorEntityDescription(
|
"nominv": SensorEntityDescription(
|
||||||
key="nominv",
|
key="nominv",
|
||||||
translation_key="nominal_input_voltage",
|
translation_key="nominal_input_voltage",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nomoutv": SensorEntityDescription(
|
"nomoutv": SensorEntityDescription(
|
||||||
key="nomoutv",
|
key="nomoutv",
|
||||||
translation_key="nominal_output_voltage",
|
translation_key="nominal_output_voltage",
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nompower": SensorEntityDescription(
|
"nompower": SensorEntityDescription(
|
||||||
key="nompower",
|
key="nompower",
|
||||||
translation_key="nominal_output_power",
|
translation_key="nominal_output_power",
|
||||||
native_unit_of_measurement=UnitOfPower.WATT,
|
native_unit_of_measurement=UnitOfPower.WATT,
|
||||||
device_class=SensorDeviceClass.POWER,
|
device_class=SensorDeviceClass.POWER,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"nomapnt": SensorEntityDescription(
|
"nomapnt": SensorEntityDescription(
|
||||||
key="nomapnt",
|
key="nomapnt",
|
||||||
translation_key="nominal_apparent_power",
|
translation_key="nominal_apparent_power",
|
||||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"numxfers": SensorEntityDescription(
|
"numxfers": SensorEntityDescription(
|
||||||
key="numxfers",
|
key="numxfers",
|
||||||
|
@ -322,25 +291,21 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
key="reg1",
|
key="reg1",
|
||||||
translation_key="register_1_fault",
|
translation_key="register_1_fault",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"reg2": SensorEntityDescription(
|
"reg2": SensorEntityDescription(
|
||||||
key="reg2",
|
key="reg2",
|
||||||
translation_key="register_2_fault",
|
translation_key="register_2_fault",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"reg3": SensorEntityDescription(
|
"reg3": SensorEntityDescription(
|
||||||
key="reg3",
|
key="reg3",
|
||||||
translation_key="register_3_fault",
|
translation_key="register_3_fault",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"retpct": SensorEntityDescription(
|
"retpct": SensorEntityDescription(
|
||||||
key="retpct",
|
key="retpct",
|
||||||
translation_key="restore_capacity",
|
translation_key="restore_capacity",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"selftest": SensorEntityDescription(
|
"selftest": SensorEntityDescription(
|
||||||
key="selftest",
|
key="selftest",
|
||||||
|
@ -350,24 +315,20 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
key="sense",
|
key="sense",
|
||||||
translation_key="sensitivity",
|
translation_key="sensitivity",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"serialno": SensorEntityDescription(
|
"serialno": SensorEntityDescription(
|
||||||
key="serialno",
|
key="serialno",
|
||||||
translation_key="serial_number",
|
translation_key="serial_number",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"starttime": SensorEntityDescription(
|
"starttime": SensorEntityDescription(
|
||||||
key="starttime",
|
key="starttime",
|
||||||
translation_key="startup_time",
|
translation_key="startup_time",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"statflag": SensorEntityDescription(
|
"statflag": SensorEntityDescription(
|
||||||
key="statflag",
|
key="statflag",
|
||||||
translation_key="online_status",
|
translation_key="online_status",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"status": SensorEntityDescription(
|
"status": SensorEntityDescription(
|
||||||
key="status",
|
key="status",
|
||||||
|
@ -376,7 +337,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
"stesti": SensorEntityDescription(
|
"stesti": SensorEntityDescription(
|
||||||
key="stesti",
|
key="stesti",
|
||||||
translation_key="self_test_interval",
|
translation_key="self_test_interval",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"timeleft": SensorEntityDescription(
|
"timeleft": SensorEntityDescription(
|
||||||
key="timeleft",
|
key="timeleft",
|
||||||
|
@ -400,28 +360,23 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||||
key="upsname",
|
key="upsname",
|
||||||
translation_key="ups_name",
|
translation_key="ups_name",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"version": SensorEntityDescription(
|
"version": SensorEntityDescription(
|
||||||
key="version",
|
key="version",
|
||||||
translation_key="version",
|
translation_key="version",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"xoffbat": SensorEntityDescription(
|
"xoffbat": SensorEntityDescription(
|
||||||
key="xoffbat",
|
key="xoffbat",
|
||||||
translation_key="transfer_from_battery",
|
translation_key="transfer_from_battery",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"xoffbatt": SensorEntityDescription(
|
"xoffbatt": SensorEntityDescription(
|
||||||
key="xoffbatt",
|
key="xoffbatt",
|
||||||
translation_key="transfer_from_battery",
|
translation_key="transfer_from_battery",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
"xonbatt": SensorEntityDescription(
|
"xonbatt": SensorEntityDescription(
|
||||||
key="xonbatt",
|
key="xonbatt",
|
||||||
translation_key="transfer_to_battery",
|
translation_key="transfer_to_battery",
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -260,18 +260,11 @@ class APIEntityStateView(HomeAssistantView):
|
||||||
if not user.is_admin:
|
if not user.is_admin:
|
||||||
raise Unauthorized(entity_id=entity_id)
|
raise Unauthorized(entity_id=entity_id)
|
||||||
hass = request.app[KEY_HASS]
|
hass = request.app[KEY_HASS]
|
||||||
|
|
||||||
body = await request.text()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data: Any = json_loads(body) if body else None
|
data = await request.json()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return self.json_message(
|
|
||||||
"State data should be a JSON object.", HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
|
||||||
if (new_state := data.get("state")) is None:
|
if (new_state := data.get("state")) is None:
|
||||||
return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST)
|
return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
@ -484,19 +477,9 @@ class APITemplateView(HomeAssistantView):
|
||||||
@require_admin
|
@require_admin
|
||||||
async def post(self, request: web.Request) -> web.Response:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
"""Render a template."""
|
"""Render a template."""
|
||||||
body = await request.text()
|
|
||||||
|
|
||||||
try:
|
|
||||||
data: Any = json_loads(body) if body else None
|
|
||||||
except ValueError:
|
|
||||||
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return self.json_message(
|
|
||||||
"Template data should be a JSON object.", HTTPStatus.BAD_REQUEST
|
|
||||||
)
|
|
||||||
tpl = _cached_template(data["template"], request.app[KEY_HASS])
|
|
||||||
try:
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
tpl = _cached_template(data["template"], request.app[KEY_HASS])
|
||||||
return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return]
|
return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return]
|
||||||
except (ValueError, TemplateError) as ex:
|
except (ValueError, TemplateError) as ex:
|
||||||
return self.json_message(
|
return self.json_message(
|
||||||
|
|
|
@ -89,7 +89,7 @@ class ArubaDeviceScanner(DeviceScanner):
|
||||||
def get_aruba_data(self) -> dict[str, dict[str, str]] | None:
|
def get_aruba_data(self) -> dict[str, dict[str, str]] | None:
|
||||||
"""Retrieve data from Aruba Access Point and return parsed result."""
|
"""Retrieve data from Aruba Access Point and return parsed result."""
|
||||||
|
|
||||||
connect = f"ssh {self.username}@{self.host}"
|
connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa"
|
||||||
ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8")
|
ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8")
|
||||||
query = ssh.expect(
|
query = ssh.expect(
|
||||||
[
|
[
|
||||||
|
|
|
@ -1119,7 +1119,6 @@ class PipelineRun:
|
||||||
) is not None:
|
) is not None:
|
||||||
# Sentence trigger matched
|
# Sentence trigger matched
|
||||||
agent_id = "sentence_trigger"
|
agent_id = "sentence_trigger"
|
||||||
processed_locally = True
|
|
||||||
intent_response = intent.IntentResponse(
|
intent_response = intent.IntentResponse(
|
||||||
self.pipeline.conversation_language
|
self.pipeline.conversation_language
|
||||||
)
|
)
|
||||||
|
@ -1208,15 +1207,6 @@ class PipelineRun:
|
||||||
|
|
||||||
self._streamed_response_text = True
|
self._streamed_response_text = True
|
||||||
|
|
||||||
self.process_event(
|
|
||||||
PipelineEvent(
|
|
||||||
PipelineEventType.INTENT_PROGRESS,
|
|
||||||
{
|
|
||||||
"tts_start_streaming": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def tts_input_stream_generator() -> AsyncGenerator[str]:
|
async def tts_input_stream_generator() -> AsyncGenerator[str]:
|
||||||
"""Yield TTS input stream."""
|
"""Yield TTS input stream."""
|
||||||
while (tts_input := await tts_input_stream.get()) is not None:
|
while (tts_input := await tts_input_stream.get()) is not None:
|
||||||
|
|
|
@ -1,23 +1,13 @@
|
||||||
"""Base class for assist satellite entities."""
|
"""Base class for assist satellite entities."""
|
||||||
|
|
||||||
from dataclasses import asdict
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from hassil.util import (
|
|
||||||
PUNCTUATION_END,
|
|
||||||
PUNCTUATION_END_WORD,
|
|
||||||
PUNCTUATION_START,
|
|
||||||
PUNCTUATION_START_WORD,
|
|
||||||
)
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.http import StaticPathConfig
|
from homeassistant.components.http import StaticPathConfig
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
@ -33,7 +23,6 @@ from .const import (
|
||||||
)
|
)
|
||||||
from .entity import (
|
from .entity import (
|
||||||
AssistSatelliteAnnouncement,
|
AssistSatelliteAnnouncement,
|
||||||
AssistSatelliteAnswer,
|
|
||||||
AssistSatelliteConfiguration,
|
AssistSatelliteConfiguration,
|
||||||
AssistSatelliteEntity,
|
AssistSatelliteEntity,
|
||||||
AssistSatelliteEntityDescription,
|
AssistSatelliteEntityDescription,
|
||||||
|
@ -45,7 +34,6 @@ from .websocket_api import async_register_websocket_api
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
"AssistSatelliteAnnouncement",
|
"AssistSatelliteAnnouncement",
|
||||||
"AssistSatelliteAnswer",
|
|
||||||
"AssistSatelliteConfiguration",
|
"AssistSatelliteConfiguration",
|
||||||
"AssistSatelliteEntity",
|
"AssistSatelliteEntity",
|
||||||
"AssistSatelliteEntityDescription",
|
"AssistSatelliteEntityDescription",
|
||||||
|
@ -98,62 +86,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"async_internal_start_conversation",
|
"async_internal_start_conversation",
|
||||||
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_ask_question(call: ServiceCall) -> dict[str, Any]:
|
|
||||||
"""Handle a Show View service call."""
|
|
||||||
satellite_entity_id: str = call.data[ATTR_ENTITY_ID]
|
|
||||||
satellite_entity: AssistSatelliteEntity | None = component.get_entity(
|
|
||||||
satellite_entity_id
|
|
||||||
)
|
|
||||||
if satellite_entity is None:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
f"Invalid Assist satellite entity id: {satellite_entity_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
ask_question_args = {
|
|
||||||
"question": call.data.get("question"),
|
|
||||||
"question_media_id": call.data.get("question_media_id"),
|
|
||||||
"preannounce": call.data.get("preannounce", False),
|
|
||||||
"answers": call.data.get("answers"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if preannounce_media_id := call.data.get("preannounce_media_id"):
|
|
||||||
ask_question_args["preannounce_media_id"] = preannounce_media_id
|
|
||||||
|
|
||||||
answer = await satellite_entity.async_internal_ask_question(**ask_question_args)
|
|
||||||
|
|
||||||
if answer is None:
|
|
||||||
raise HomeAssistantError("No answer from satellite")
|
|
||||||
|
|
||||||
return asdict(answer)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
domain=DOMAIN,
|
|
||||||
service="ask_question",
|
|
||||||
service_func=handle_ask_question,
|
|
||||||
schema=vol.All(
|
|
||||||
{
|
|
||||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
|
||||||
vol.Optional("question"): str,
|
|
||||||
vol.Optional("question_media_id"): str,
|
|
||||||
vol.Optional("preannounce"): bool,
|
|
||||||
vol.Optional("preannounce_media_id"): str,
|
|
||||||
vol.Optional("answers"): [
|
|
||||||
{
|
|
||||||
vol.Required("id"): str,
|
|
||||||
vol.Required("sentences"): vol.All(
|
|
||||||
cv.ensure_list,
|
|
||||||
[cv.string],
|
|
||||||
has_one_non_empty_item,
|
|
||||||
has_no_punctuation,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
cv.has_at_least_one_key("question", "question_media_id"),
|
|
||||||
),
|
|
||||||
supports_response=SupportsResponse.ONLY,
|
|
||||||
)
|
|
||||||
hass.data[CONNECTION_TEST_DATA] = {}
|
hass.data[CONNECTION_TEST_DATA] = {}
|
||||||
async_register_websocket_api(hass)
|
async_register_websocket_api(hass)
|
||||||
hass.http.register_view(ConnectionTestView())
|
hass.http.register_view(ConnectionTestView())
|
||||||
|
@ -178,29 +110,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
|
||||||
"""Validate result does not contain punctuation."""
|
|
||||||
for sentence in value:
|
|
||||||
if (
|
|
||||||
PUNCTUATION_START.search(sentence)
|
|
||||||
or PUNCTUATION_END.search(sentence)
|
|
||||||
or PUNCTUATION_START_WORD.search(sentence)
|
|
||||||
or PUNCTUATION_END_WORD.search(sentence)
|
|
||||||
):
|
|
||||||
raise vol.Invalid("sentence should not contain punctuation")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def has_one_non_empty_item(value: list[str]) -> list[str]:
|
|
||||||
"""Validate result has at least one item."""
|
|
||||||
if len(value) < 1:
|
|
||||||
raise vol.Invalid("at least one sentence is required")
|
|
||||||
|
|
||||||
for sentence in value:
|
|
||||||
if not sentence:
|
|
||||||
raise vol.Invalid("sentences cannot be empty")
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
|
@ -4,16 +4,12 @@ from abc import abstractmethod
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterable
|
from collections.abc import AsyncIterable
|
||||||
import contextlib
|
import contextlib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any, Literal, final
|
from typing import Any, Literal, final
|
||||||
|
|
||||||
from hassil import Intents, recognize
|
|
||||||
from hassil.expression import Expression, ListReference, Sequence
|
|
||||||
from hassil.intents import WildcardSlotList
|
|
||||||
|
|
||||||
from homeassistant.components import conversation, media_source, stt, tts
|
from homeassistant.components import conversation, media_source, stt, tts
|
||||||
from homeassistant.components.assist_pipeline import (
|
from homeassistant.components.assist_pipeline import (
|
||||||
OPTION_PREFERRED,
|
OPTION_PREFERRED,
|
||||||
|
@ -109,20 +105,6 @@ class AssistSatelliteAnnouncement:
|
||||||
"""Media ID to be played before announcement."""
|
"""Media ID to be played before announcement."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AssistSatelliteAnswer:
|
|
||||||
"""Answer to a question."""
|
|
||||||
|
|
||||||
id: str | None
|
|
||||||
"""Matched answer id or None if no answer was matched."""
|
|
||||||
|
|
||||||
sentence: str
|
|
||||||
"""Raw sentence text from user response."""
|
|
||||||
|
|
||||||
slots: dict[str, Any] = field(default_factory=dict)
|
|
||||||
"""Matched slots from answer."""
|
|
||||||
|
|
||||||
|
|
||||||
class AssistSatelliteEntity(entity.Entity):
|
class AssistSatelliteEntity(entity.Entity):
|
||||||
"""Entity encapsulating the state and functionality of an Assist satellite."""
|
"""Entity encapsulating the state and functionality of an Assist satellite."""
|
||||||
|
|
||||||
|
@ -140,7 +122,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||||
_attr_tts_options: dict[str, Any] | None = None
|
_attr_tts_options: dict[str, Any] | None = None
|
||||||
_pipeline_task: asyncio.Task | None = None
|
_pipeline_task: asyncio.Task | None = None
|
||||||
_ask_question_future: asyncio.Future[str | None] | None = None
|
|
||||||
|
|
||||||
__assist_satellite_state = AssistSatelliteState.IDLE
|
__assist_satellite_state = AssistSatelliteState.IDLE
|
||||||
|
|
||||||
|
@ -328,112 +309,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||||
"""Start a conversation from the satellite."""
|
"""Start a conversation from the satellite."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def async_internal_ask_question(
|
|
||||||
self,
|
|
||||||
question: str | None = None,
|
|
||||||
question_media_id: str | None = None,
|
|
||||||
preannounce: bool = True,
|
|
||||||
preannounce_media_id: str = PREANNOUNCE_URL,
|
|
||||||
answers: list[dict[str, Any]] | None = None,
|
|
||||||
) -> AssistSatelliteAnswer | None:
|
|
||||||
"""Ask a question and get a user's response from the satellite.
|
|
||||||
|
|
||||||
If question_media_id is not provided, question is synthesized to audio
|
|
||||||
with the selected pipeline.
|
|
||||||
|
|
||||||
If question_media_id is provided, it is played directly. It is possible
|
|
||||||
to omit the message and the satellite will not show any text.
|
|
||||||
|
|
||||||
If preannounce is True, a sound is played before the start message or media.
|
|
||||||
If preannounce_media_id is provided, it overrides the default sound.
|
|
||||||
|
|
||||||
Calls async_start_conversation.
|
|
||||||
"""
|
|
||||||
await self._cancel_running_pipeline()
|
|
||||||
|
|
||||||
if question is None:
|
|
||||||
question = ""
|
|
||||||
|
|
||||||
announcement = await self._resolve_announcement_media_id(
|
|
||||||
question,
|
|
||||||
question_media_id,
|
|
||||||
preannounce_media_id=preannounce_media_id if preannounce else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._is_announcing:
|
|
||||||
raise SatelliteBusyError
|
|
||||||
|
|
||||||
self._is_announcing = True
|
|
||||||
self._set_state(AssistSatelliteState.RESPONDING)
|
|
||||||
self._ask_question_future = asyncio.Future()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Wait for announcement to finish
|
|
||||||
await self.async_start_conversation(announcement)
|
|
||||||
|
|
||||||
# Wait for response text
|
|
||||||
response_text = await self._ask_question_future
|
|
||||||
if response_text is None:
|
|
||||||
raise HomeAssistantError("No answer from question")
|
|
||||||
|
|
||||||
if not answers:
|
|
||||||
return AssistSatelliteAnswer(id=None, sentence=response_text)
|
|
||||||
|
|
||||||
return self._question_response_to_answer(response_text, answers)
|
|
||||||
finally:
|
|
||||||
self._is_announcing = False
|
|
||||||
self._set_state(AssistSatelliteState.IDLE)
|
|
||||||
self._ask_question_future = None
|
|
||||||
|
|
||||||
def _question_response_to_answer(
|
|
||||||
self, response_text: str, answers: list[dict[str, Any]]
|
|
||||||
) -> AssistSatelliteAnswer:
|
|
||||||
"""Match text to a pre-defined set of answers."""
|
|
||||||
|
|
||||||
# Build intents and match
|
|
||||||
intents = Intents.from_dict(
|
|
||||||
{
|
|
||||||
"language": self.hass.config.language,
|
|
||||||
"intents": {
|
|
||||||
"QuestionIntent": {
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"sentences": answer["sentences"],
|
|
||||||
"metadata": {"answer_id": answer["id"]},
|
|
||||||
}
|
|
||||||
for answer in answers
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assume slot list references are wildcards
|
|
||||||
wildcard_names: set[str] = set()
|
|
||||||
for intent in intents.intents.values():
|
|
||||||
for intent_data in intent.data:
|
|
||||||
for sentence in intent_data.sentences:
|
|
||||||
_collect_list_references(sentence, wildcard_names)
|
|
||||||
|
|
||||||
for wildcard_name in wildcard_names:
|
|
||||||
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
|
|
||||||
|
|
||||||
# Match response text
|
|
||||||
result = recognize(response_text, intents)
|
|
||||||
if result is None:
|
|
||||||
# No match
|
|
||||||
return AssistSatelliteAnswer(id=None, sentence=response_text)
|
|
||||||
|
|
||||||
assert result.intent_metadata
|
|
||||||
return AssistSatelliteAnswer(
|
|
||||||
id=result.intent_metadata["answer_id"],
|
|
||||||
sentence=response_text,
|
|
||||||
slots={
|
|
||||||
entity_name: entity.value
|
|
||||||
for entity_name, entity in result.entities.items()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_accept_pipeline_from_satellite(
|
async def async_accept_pipeline_from_satellite(
|
||||||
self,
|
self,
|
||||||
audio_stream: AsyncIterable[bytes],
|
audio_stream: AsyncIterable[bytes],
|
||||||
|
@ -476,11 +351,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||||
self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END))
|
self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END))
|
||||||
return
|
return
|
||||||
|
|
||||||
if (self._ask_question_future is not None) and (
|
|
||||||
start_stage == PipelineStage.STT
|
|
||||||
):
|
|
||||||
end_stage = PipelineStage.STT
|
|
||||||
|
|
||||||
device_id = self.registry_entry.device_id if self.registry_entry else None
|
device_id = self.registry_entry.device_id if self.registry_entry else None
|
||||||
|
|
||||||
# Refresh context if necessary
|
# Refresh context if necessary
|
||||||
|
@ -563,16 +433,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||||
self._set_state(AssistSatelliteState.IDLE)
|
self._set_state(AssistSatelliteState.IDLE)
|
||||||
elif event.type is PipelineEventType.STT_START:
|
elif event.type is PipelineEventType.STT_START:
|
||||||
self._set_state(AssistSatelliteState.LISTENING)
|
self._set_state(AssistSatelliteState.LISTENING)
|
||||||
elif event.type is PipelineEventType.STT_END:
|
|
||||||
# Intercepting text for ask question
|
|
||||||
if (
|
|
||||||
(self._ask_question_future is not None)
|
|
||||||
and (not self._ask_question_future.done())
|
|
||||||
and event.data
|
|
||||||
):
|
|
||||||
self._ask_question_future.set_result(
|
|
||||||
event.data.get("stt_output", {}).get("text")
|
|
||||||
)
|
|
||||||
elif event.type is PipelineEventType.INTENT_START:
|
elif event.type is PipelineEventType.INTENT_START:
|
||||||
self._set_state(AssistSatelliteState.PROCESSING)
|
self._set_state(AssistSatelliteState.PROCESSING)
|
||||||
elif event.type is PipelineEventType.TTS_START:
|
elif event.type is PipelineEventType.TTS_START:
|
||||||
|
@ -583,12 +443,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||||
if not self._run_has_tts:
|
if not self._run_has_tts:
|
||||||
self._set_state(AssistSatelliteState.IDLE)
|
self._set_state(AssistSatelliteState.IDLE)
|
||||||
|
|
||||||
if (self._ask_question_future is not None) and (
|
|
||||||
not self._ask_question_future.done()
|
|
||||||
):
|
|
||||||
# No text for ask question
|
|
||||||
self._ask_question_future.set_result(None)
|
|
||||||
|
|
||||||
self.on_pipeline_event(event)
|
self.on_pipeline_event(event)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
@ -723,15 +577,3 @@ class AssistSatelliteEntity(entity.Entity):
|
||||||
media_id_source=media_id_source,
|
media_id_source=media_id_source,
|
||||||
preannounce_media_id=preannounce_media_id,
|
preannounce_media_id=preannounce_media_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
|
|
||||||
"""Collect list reference names recursively."""
|
|
||||||
if isinstance(expression, Sequence):
|
|
||||||
seq: Sequence = expression
|
|
||||||
for item in seq.items:
|
|
||||||
_collect_list_references(item, list_names)
|
|
||||||
elif isinstance(expression, ListReference):
|
|
||||||
# {list}
|
|
||||||
list_ref: ListReference = expression
|
|
||||||
list_names.add(list_ref.slot_name)
|
|
||||||
|
|
|
@ -10,9 +10,6 @@
|
||||||
},
|
},
|
||||||
"start_conversation": {
|
"start_conversation": {
|
||||||
"service": "mdi:forum"
|
"service": "mdi:forum"
|
||||||
},
|
|
||||||
"ask_question": {
|
|
||||||
"service": "mdi:microphone-question"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,5 @@
|
||||||
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
|
"dependencies": ["assist_pipeline", "http", "stt", "tts"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||||
"integration_type": "entity",
|
"integration_type": "entity",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal"
|
||||||
"requirements": ["hassil==2.2.3"]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,49 +54,3 @@ start_conversation:
|
||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
ask_question:
|
|
||||||
fields:
|
|
||||||
entity_id:
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
entity:
|
|
||||||
domain: assist_satellite
|
|
||||||
supported_features:
|
|
||||||
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
|
||||||
question:
|
|
||||||
required: false
|
|
||||||
example: "What kind of music would you like to play?"
|
|
||||||
default: ""
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
question_media_id:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
preannounce:
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
selector:
|
|
||||||
boolean:
|
|
||||||
preannounce_media_id:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
answers:
|
|
||||||
required: false
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
label_field: sentences
|
|
||||||
description_field: id
|
|
||||||
multiple: true
|
|
||||||
translation_key: answers
|
|
||||||
fields:
|
|
||||||
id:
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
sentences:
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
multiple: true
|
|
||||||
|
|
|
@ -59,44 +59,6 @@
|
||||||
"description": "Custom media ID to play before the start message or media."
|
"description": "Custom media ID to play before the start message or media."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"ask_question": {
|
|
||||||
"name": "Ask question",
|
|
||||||
"description": "Asks a question and gets the user's response.",
|
|
||||||
"fields": {
|
|
||||||
"entity_id": {
|
|
||||||
"name": "Entity",
|
|
||||||
"description": "Assist satellite entity to ask the question on."
|
|
||||||
},
|
|
||||||
"question": {
|
|
||||||
"name": "Question",
|
|
||||||
"description": "The question to ask."
|
|
||||||
},
|
|
||||||
"question_media_id": {
|
|
||||||
"name": "Question media ID",
|
|
||||||
"description": "The media ID of the question to use instead of text-to-speech."
|
|
||||||
},
|
|
||||||
"preannounce": {
|
|
||||||
"name": "Preannounce",
|
|
||||||
"description": "Play a sound before the start message or media."
|
|
||||||
},
|
|
||||||
"preannounce_media_id": {
|
|
||||||
"name": "Preannounce media ID",
|
|
||||||
"description": "Custom media ID to play before the start message or media."
|
|
||||||
},
|
|
||||||
"answers": {
|
|
||||||
"name": "Answers",
|
|
||||||
"description": "Possible answers to the question."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"selector": {
|
|
||||||
"answers": {
|
|
||||||
"fields": {
|
|
||||||
"id": "Answer ID",
|
|
||||||
"sentences": "Sentences"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ from homeassistant.components.water_heater import (
|
||||||
STATE_ECO,
|
STATE_ECO,
|
||||||
STATE_PERFORMANCE,
|
STATE_PERFORMANCE,
|
||||||
WaterHeaterEntity,
|
WaterHeaterEntity,
|
||||||
WaterHeaterEntityFeature,
|
|
||||||
)
|
)
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, Platform, UnitOfTemperature
|
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, Platform, UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -33,7 +32,6 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity):
|
||||||
"""Representation of an ATAG water heater."""
|
"""Representation of an ATAG water heater."""
|
||||||
|
|
||||||
_attr_operation_list = OPERATION_LIST
|
_attr_operation_list = OPERATION_LIST
|
||||||
_attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "automation_blueprints"
|
||||||
|
|
||||||
def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
|
def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool:
|
||||||
"""Return True if any automation references the blueprint."""
|
"""Return True if any automation references the blueprint."""
|
||||||
from . import automations_with_blueprint # noqa: PLC0415
|
from . import automations_with_blueprint # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return len(automations_with_blueprint(hass, blueprint_path)) > 0
|
return len(automations_with_blueprint(hass, blueprint_path)) > 0
|
||||||
|
|
||||||
|
@ -28,7 +28,8 @@ async def _reload_blueprint_automations(
|
||||||
@callback
|
@callback
|
||||||
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
|
def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
|
||||||
"""Get automation blueprints."""
|
"""Get automation blueprints."""
|
||||||
from .config import AUTOMATION_BLUEPRINT_SCHEMA # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from .config import AUTOMATION_BLUEPRINT_SCHEMA
|
||||||
|
|
||||||
return blueprint.DomainBlueprints(
|
return blueprint.DomainBlueprints(
|
||||||
hass,
|
hass,
|
||||||
|
|
|
@ -11,7 +11,7 @@ from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
|
|
||||||
from ..const import ATTR_MANUFACTURER, DOMAIN
|
from ..const import ATTR_MANUFACTURER, DOMAIN as AXIS_DOMAIN
|
||||||
from .config import AxisConfig
|
from .config import AxisConfig
|
||||||
from .entity_loader import AxisEntityLoader
|
from .entity_loader import AxisEntityLoader
|
||||||
from .event_source import AxisEventSource
|
from .event_source import AxisEventSource
|
||||||
|
@ -79,7 +79,7 @@ class AxisHub:
|
||||||
config_entry_id=self.config.entry.entry_id,
|
config_entry_id=self.config.entry.entry_id,
|
||||||
configuration_url=self.api.config.url,
|
configuration_url=self.api.config.url,
|
||||||
connections={(CONNECTION_NETWORK_MAC, self.unique_id)},
|
connections={(CONNECTION_NETWORK_MAC, self.unique_id)},
|
||||||
identifiers={(DOMAIN, self.unique_id)},
|
identifiers={(AXIS_DOMAIN, self.unique_id)},
|
||||||
manufacturer=ATTR_MANUFACTURER,
|
manufacturer=ATTR_MANUFACTURER,
|
||||||
model=f"{self.config.model} {self.product_type}",
|
model=f"{self.config.model} {self.product_type}",
|
||||||
name=self.config.name,
|
name=self.config.name,
|
||||||
|
|
|
@ -94,10 +94,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
if not with_hassio:
|
if not with_hassio:
|
||||||
reader_writer = CoreBackupReaderWriter(hass)
|
reader_writer = CoreBackupReaderWriter(hass)
|
||||||
else:
|
else:
|
||||||
# pylint: disable-next=hass-component-root-import
|
# pylint: disable-next=import-outside-toplevel, hass-component-root-import
|
||||||
from homeassistant.components.hassio.backup import ( # noqa: PLC0415
|
from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter
|
||||||
SupervisorBackupReaderWriter,
|
|
||||||
)
|
|
||||||
|
|
||||||
reader_writer = SupervisorBackupReaderWriter(hass)
|
reader_writer = SupervisorBackupReaderWriter(hass)
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
|
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
|
||||||
from .coordinator import BlinkConfigEntry, BlinkUpdateCoordinator
|
from .coordinator import BlinkConfigEntry, BlinkUpdateCoordinator
|
||||||
from .services import async_setup_services
|
from .services import setup_services
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -72,7 +72,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BlinkConfigEntry) -> b
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up Blink."""
|
"""Set up Blink."""
|
||||||
|
|
||||||
async_setup_services(hass)
|
setup_services(hass)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.const import CONF_PIN
|
from homeassistant.const import CONF_PIN
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
|
@ -21,36 +21,34 @@ SERVICE_SEND_PIN_SCHEMA = vol.Schema(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _send_pin(call: ServiceCall) -> None:
|
def setup_services(hass: HomeAssistant) -> None:
|
||||||
"""Call blink to send new pin."""
|
|
||||||
config_entry: BlinkConfigEntry | None
|
|
||||||
for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]:
|
|
||||||
if not (config_entry := call.hass.config_entries.async_get_entry(entry_id)):
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="integration_not_found",
|
|
||||||
translation_placeholders={"target": DOMAIN},
|
|
||||||
)
|
|
||||||
if config_entry.state != ConfigEntryState.LOADED:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="not_loaded",
|
|
||||||
translation_placeholders={"target": config_entry.title},
|
|
||||||
)
|
|
||||||
coordinator = config_entry.runtime_data
|
|
||||||
await coordinator.api.auth.send_auth_key(
|
|
||||||
coordinator.api,
|
|
||||||
call.data[CONF_PIN],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Set up the services for the Blink integration."""
|
"""Set up the services for the Blink integration."""
|
||||||
|
|
||||||
|
async def send_pin(call: ServiceCall):
|
||||||
|
"""Call blink to send new pin."""
|
||||||
|
config_entry: BlinkConfigEntry | None
|
||||||
|
for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]:
|
||||||
|
if not (config_entry := hass.config_entries.async_get_entry(entry_id)):
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="integration_not_found",
|
||||||
|
translation_placeholders={"target": DOMAIN},
|
||||||
|
)
|
||||||
|
if config_entry.state != ConfigEntryState.LOADED:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="not_loaded",
|
||||||
|
translation_placeholders={"target": config_entry.title},
|
||||||
|
)
|
||||||
|
coordinator = config_entry.runtime_data
|
||||||
|
await coordinator.api.auth.send_auth_key(
|
||||||
|
coordinator.api,
|
||||||
|
call.data[CONF_PIN],
|
||||||
|
)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_SEND_PIN,
|
SERVICE_SEND_PIN,
|
||||||
_send_pin,
|
send_pin,
|
||||||
schema=SERVICE_SEND_PIN_SCHEMA,
|
schema=SERVICE_SEND_PIN_SCHEMA,
|
||||||
)
|
)
|
||||||
|
|
|
@ -11,6 +11,6 @@
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
|
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,41 +28,38 @@ rules:
|
||||||
# Silver
|
# Silver
|
||||||
action-exceptions: done
|
action-exceptions: done
|
||||||
config-entry-unloading: done
|
config-entry-unloading: done
|
||||||
docs-configuration-parameters:
|
docs-configuration-parameters: todo
|
||||||
status: exempt
|
docs-installation-parameters: todo
|
||||||
comment: |
|
entity-unavailable: todo
|
||||||
No options flow is provided.
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: done
|
|
||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: done
|
log-when-unavailable: todo
|
||||||
parallel-updates: done
|
parallel-updates: done
|
||||||
reauthentication-flow: done
|
reauthentication-flow: done
|
||||||
test-coverage: done
|
test-coverage: done
|
||||||
|
|
||||||
# Gold
|
# Gold
|
||||||
devices: done
|
devices: done
|
||||||
diagnostics: done
|
diagnostics: todo
|
||||||
discovery-update-info: done
|
discovery-update-info: done
|
||||||
discovery: done
|
discovery: done
|
||||||
docs-data-update: done
|
docs-data-update: todo
|
||||||
docs-examples: done
|
docs-examples: todo
|
||||||
docs-known-limitations: done
|
docs-known-limitations: todo
|
||||||
docs-supported-devices: done
|
docs-supported-devices: todo
|
||||||
docs-supported-functions: done
|
docs-supported-functions: todo
|
||||||
docs-troubleshooting: done
|
docs-troubleshooting: todo
|
||||||
docs-use-cases: done
|
docs-use-cases: todo
|
||||||
dynamic-devices:
|
dynamic-devices:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
Device type integration
|
Device type integration
|
||||||
entity-category: done
|
entity-category: todo
|
||||||
entity-device-class: done
|
entity-device-class: todo
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: todo
|
||||||
entity-translations: done
|
entity-translations: done
|
||||||
exception-translations: done
|
exception-translations: todo
|
||||||
icon-translations: done
|
icon-translations: done
|
||||||
reconfiguration-flow: done
|
reconfiguration-flow: todo
|
||||||
repair-issues:
|
repair-issues:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
|
|
|
@ -7,5 +7,5 @@
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["bsblan"],
|
"loggers": ["bsblan"],
|
||||||
"requirements": ["python-bsblan==2.1.0"]
|
"requirements": ["python-bsblan==1.2.1"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,5 +20,5 @@
|
||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["bthome-ble==3.13.1"]
|
"requirements": ["bthome-ble==3.12.4"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -240,10 +240,6 @@ async def _async_get_stream_image(
|
||||||
height: int | None = None,
|
height: int | None = None,
|
||||||
wait_for_next_keyframe: bool = False,
|
wait_for_next_keyframe: bool = False,
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
if (provider := camera._webrtc_provider) and ( # noqa: SLF001
|
|
||||||
image := await provider.async_get_image(camera, width=width, height=height)
|
|
||||||
) is not None:
|
|
||||||
return image
|
|
||||||
if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features:
|
if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features:
|
||||||
camera.stream = await camera.async_create_stream()
|
camera.stream = await camera.async_create_stream()
|
||||||
if camera.stream:
|
if camera.stream:
|
||||||
|
@ -498,6 +494,19 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
"""Flag supported features."""
|
"""Flag supported features."""
|
||||||
return self._attr_supported_features
|
return self._attr_supported_features
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_features_compat(self) -> CameraEntityFeature:
|
||||||
|
"""Return the supported features as CameraEntityFeature.
|
||||||
|
|
||||||
|
Remove this compatibility shim in 2025.1 or later.
|
||||||
|
"""
|
||||||
|
features = self.supported_features
|
||||||
|
if type(features) is int:
|
||||||
|
new_features = CameraEntityFeature(features)
|
||||||
|
self._report_deprecated_supported_features_values(new_features)
|
||||||
|
return new_features
|
||||||
|
return features
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_recording(self) -> bool:
|
def is_recording(self) -> bool:
|
||||||
"""Return true if the device is recording."""
|
"""Return true if the device is recording."""
|
||||||
|
@ -691,7 +700,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
async def async_internal_added_to_hass(self) -> None:
|
async def async_internal_added_to_hass(self) -> None:
|
||||||
"""Run when entity about to be added to hass."""
|
"""Run when entity about to be added to hass."""
|
||||||
await super().async_internal_added_to_hass()
|
await super().async_internal_added_to_hass()
|
||||||
self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM
|
self.__supports_stream = (
|
||||||
|
self.supported_features_compat & CameraEntityFeature.STREAM
|
||||||
|
)
|
||||||
await self.async_refresh_providers(write_state=False)
|
await self.async_refresh_providers(write_state=False)
|
||||||
|
|
||||||
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
||||||
|
@ -720,7 +731,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
|
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
|
||||||
) -> _T | None:
|
) -> _T | None:
|
||||||
"""Get first provider that supports this camera."""
|
"""Get first provider that supports this camera."""
|
||||||
if CameraEntityFeature.STREAM not in self.supported_features:
|
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return await fn(self.hass, self)
|
return await fn(self.hass, self)
|
||||||
|
@ -770,7 +781,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
def camera_capabilities(self) -> CameraCapabilities:
|
def camera_capabilities(self) -> CameraCapabilities:
|
||||||
"""Return the camera capabilities."""
|
"""Return the camera capabilities."""
|
||||||
frontend_stream_types = set()
|
frontend_stream_types = set()
|
||||||
if CameraEntityFeature.STREAM in self.supported_features:
|
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||||
if self._supports_native_async_webrtc:
|
if self._supports_native_async_webrtc:
|
||||||
# The camera has a native WebRTC implementation
|
# The camera has a native WebRTC implementation
|
||||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||||
|
@ -790,7 +801,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||||
"""
|
"""
|
||||||
super().async_write_ha_state()
|
super().async_write_ha_state()
|
||||||
if self.__supports_stream != (
|
if self.__supports_stream != (
|
||||||
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
supports_stream := self.supported_features_compat
|
||||||
|
& CameraEntityFeature.STREAM
|
||||||
):
|
):
|
||||||
self.__supports_stream = supports_stream
|
self.__supports_stream = supports_stream
|
||||||
self._invalidate_camera_capabilities_cache()
|
self._invalidate_camera_capabilities_cache()
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue