forked from home-assistant/core
Merge branch 'dev' into immich/add-upload_file-action
This commit is contained in:
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -1,15 +1,14 @@
|
|||||||
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 use the [feature request][fr] section of our [Community Forum][fr].
|
If you have a feature or enhancement request, please [request them here instead][fr].
|
||||||
|
|
||||||
[fr]: https://community.home-assistant.io/c/feature-requests
|
[fr]: https://github.com/orgs/home-assistant/discussions
|
||||||
- type: textarea
|
- type: textarea
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -10,8 +10,8 @@ contact_links:
|
|||||||
url: https://www.home-assistant.io/help
|
url: https://www.home-assistant.io/help
|
||||||
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||||
- name: Feature Request
|
- name: Feature Request
|
||||||
url: https://community.home-assistant.io/c/feature-requests
|
url: https://github.com/orgs/home-assistant/discussions
|
||||||
about: Please use our Community Forum for making feature requests.
|
about: Please use this link to request new features or enhancements to existing features.
|
||||||
- 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!
|
||||||
|
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@ -94,7 +94,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Download nightly wheels of frontend
|
- name: Download nightly wheels of frontend
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
uses: dawidd6/action-download-artifact@v10
|
uses: dawidd6/action-download-artifact@v11
|
||||||
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@v10
|
uses: dawidd6/action-download-artifact@v11
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: home-assistant/intents-package
|
repo: OHF-Voice/intents-package
|
||||||
branch: main
|
branch: main
|
||||||
workflow: nightly.yaml
|
workflow: nightly.yaml
|
||||||
workflow_conclusion: success
|
workflow_conclusion: success
|
||||||
@ -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@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
|
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.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 }}
|
||||||
|
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@ -37,7 +37,7 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CACHE_VERSION: 2
|
CACHE_VERSION: 3
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 1
|
MYPY_CACHE_VERSION: 1
|
||||||
HA_SHORT_VERSION: "2025.7"
|
HA_SHORT_VERSION: "2025.7"
|
||||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.28.19
|
uses: github/codeql-action/init@v3.29.0
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.28.19
|
uses: github/codeql-action/analyze@v3.29.0
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
385
.github/workflows/detect-duplicate-issues.yml
vendored
Normal file
385
.github/workflows/detect-duplicate-issues.yml
vendored
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
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
|
||||||
|
}
|
193
.github/workflows/detect-non-english-issues.yml
vendored
Normal file
193
.github/workflows/detect-non-english-issues.yml
vendored
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
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,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.11.12
|
rev: v0.12.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
args:
|
args:
|
||||||
|
@ -65,8 +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.amazon_devices.*
|
|
||||||
homeassistant.components.amazon_polly.*
|
homeassistant.components.amazon_polly.*
|
||||||
homeassistant.components.amberelectric.*
|
homeassistant.components.amberelectric.*
|
||||||
homeassistant.components.ambient_network.*
|
homeassistant.components.ambient_network.*
|
||||||
|
10
CODEOWNERS
generated
10
CODEOWNERS
generated
@ -57,6 +57,8 @@ 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
|
||||||
@ -89,8 +91,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/amazon_devices/ @chemelli74
|
/homeassistant/components/alexa_devices/ @chemelli74
|
||||||
/tests/components/amazon_devices/ @chemelli74
|
/tests/components/alexa_devices/ @chemelli74
|
||||||
/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
|
||||||
@ -1274,8 +1276,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
|
/homeassistant/components/remote_calendar/ @Thomas55555 @allenporter
|
||||||
/tests/components/remote_calendar/ @Thomas55555
|
/tests/components/remote_calendar/ @Thomas55555 @allenporter
|
||||||
/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
|
||||||
|
@ -38,8 +38,7 @@ 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."""
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from . import config as config_util # noqa: PLC0415
|
||||||
from . import config as config_util
|
|
||||||
|
|
||||||
lib_dir = os.path.join(config_dir, "deps")
|
lib_dir = os.path.join(config_dir, "deps")
|
||||||
|
|
||||||
@ -80,8 +79,7 @@ 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."""
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from . import config as config_util # noqa: PLC0415
|
||||||
from . import config as config_util
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Home Assistant: Observe, Control, Automate.",
|
description="Home Assistant: Observe, Control, Automate.",
|
||||||
@ -177,8 +175,7 @@ def main() -> int:
|
|||||||
validate_os()
|
validate_os()
|
||||||
|
|
||||||
if args.script is not None:
|
if args.script is not None:
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from . import scripts # noqa: PLC0415
|
||||||
from . import scripts
|
|
||||||
|
|
||||||
return scripts.run(args.script)
|
return scripts.run(args.script)
|
||||||
|
|
||||||
@ -188,8 +185,7 @@ def main() -> int:
|
|||||||
|
|
||||||
ensure_config_path(config_dir)
|
ensure_config_path(config_dir)
|
||||||
|
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from . import config, runner # noqa: PLC0415
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyqrcode # noqa: PLC0415
|
||||||
|
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
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 # pylint: disable=import-outside-toplevel
|
import pyotp # noqa: PLC0415
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
"""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())
|
|
@ -1,31 +0,0 @@
|
|||||||
"""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())
|
|
@ -394,7 +394,7 @@ async def async_setup_hass(
|
|||||||
|
|
||||||
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
def open_hass_ui(hass: core.HomeAssistant) -> None:
|
||||||
"""Open the UI."""
|
"""Open the UI."""
|
||||||
import webbrowser # pylint: disable=import-outside-toplevel
|
import webbrowser # noqa: PLC0415
|
||||||
|
|
||||||
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")
|
||||||
@ -561,8 +561,7 @@ async def async_enable_logging(
|
|||||||
|
|
||||||
if not log_no_color:
|
if not log_no_color:
|
||||||
try:
|
try:
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from colorlog import ColoredFormatter # noqa: PLC0415
|
||||||
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.
|
||||||
@ -606,7 +605,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]
|
exc_info=( # type: ignore[arg-type] # noqa: LOG014
|
||||||
args.exc_type,
|
args.exc_type,
|
||||||
args.exc_value,
|
args.exc_value,
|
||||||
args.exc_traceback,
|
args.exc_traceback,
|
||||||
@ -1060,5 +1059,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__),
|
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014
|
||||||
)
|
)
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "Amazon",
|
"name": "Amazon",
|
||||||
"integrations": [
|
"integrations": [
|
||||||
"alexa",
|
"alexa",
|
||||||
"amazon_devices",
|
"alexa_devices",
|
||||||
"amazon_polly",
|
"amazon_polly",
|
||||||
"aws",
|
"aws",
|
||||||
"aws_s3",
|
"aws_s3",
|
||||||
|
@ -6,7 +6,7 @@ from jaraco.abode.exceptions import Exception as AbodeException
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
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.dispatcher import dispatcher_send
|
||||||
|
|
||||||
@ -70,6 +70,7 @@ def _trigger_automation(call: ServiceCall) -> None:
|
|||||||
dispatcher_send(call.hass, signal)
|
dispatcher_send(call.hass, signal)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
"""Home Assistant services."""
|
"""Home Assistant services."""
|
||||||
|
|
||||||
|
125
homeassistant/components/ai_task/__init__.py
Normal file
125
homeassistant/components/ai_task/__init__.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""Integration to offer AI tasks to Home Assistant."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
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 DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature
|
||||||
|
from .entity import AITaskEntity
|
||||||
|
from .http import async_setup as async_setup_conversation_http
|
||||||
|
from .task import GenTextTask, GenTextTaskResult, async_generate_text
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DOMAIN",
|
||||||
|
"AITaskEntity",
|
||||||
|
"AITaskEntityFeature",
|
||||||
|
"GenTextTask",
|
||||||
|
"GenTextTaskResult",
|
||||||
|
"async_generate_text",
|
||||||
|
"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_conversation_http(hass)
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"generate_text",
|
||||||
|
async_service_generate_text,
|
||||||
|
schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("task_name"): cv.string,
|
||||||
|
vol.Optional("entity_id"): cv.entity_id,
|
||||||
|
vol.Required("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_text(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Run the run task service."""
|
||||||
|
result = await async_generate_text(hass=call.hass, **call.data)
|
||||||
|
return result.as_dict() # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
class AITaskPreferences:
|
||||||
|
"""AI Task preferences."""
|
||||||
|
|
||||||
|
KEYS = ("gen_text_entity_id",)
|
||||||
|
|
||||||
|
gen_text_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_text_entity_id: str | None | UndefinedType = UNDEFINED,
|
||||||
|
) -> None:
|
||||||
|
"""Set the preferences."""
|
||||||
|
changed = False
|
||||||
|
for key, value in (("gen_text_entity_id", gen_text_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}
|
29
homeassistant/components/ai_task/const.py
Normal file
29
homeassistant/components/ai_task/const.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Constants for the AI Task integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import IntFlag
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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_TEXT = 1
|
||||||
|
"""Generate text based on instructions."""
|
103
homeassistant/components/ai_task/entity.py
Normal file
103
homeassistant/components/ai_task/entity.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"""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 GenTextTask, GenTextTaskResult
|
||||||
|
|
||||||
|
|
||||||
|
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: GenTextTask,
|
||||||
|
) -> 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_text(
|
||||||
|
self,
|
||||||
|
task: GenTextTask,
|
||||||
|
) -> GenTextTaskResult:
|
||||||
|
"""Run a gen text 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_text(task, chat_log)
|
||||||
|
|
||||||
|
async def _async_generate_text(
|
||||||
|
self,
|
||||||
|
task: GenTextTask,
|
||||||
|
chat_log: ChatLog,
|
||||||
|
) -> GenTextTaskResult:
|
||||||
|
"""Handle a gen text task."""
|
||||||
|
raise NotImplementedError
|
54
homeassistant/components/ai_task/http.py
Normal file
54
homeassistant/components/ai_task/http.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""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_text_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())
|
7
homeassistant/components/ai_task/icons.json
Normal file
7
homeassistant/components/ai_task/icons.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"generate_text": {
|
||||||
|
"service": "mdi:file-star-four-points-outline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
homeassistant/components/ai_task/manifest.json
Normal file
9
homeassistant/components/ai_task/manifest.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
19
homeassistant/components/ai_task/services.yaml
Normal file
19
homeassistant/components/ai_task/services.yaml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
generate_text:
|
||||||
|
fields:
|
||||||
|
task_name:
|
||||||
|
example: "home summary"
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
instructions:
|
||||||
|
example: "Generate a funny notification that garage door was left open"
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
entity_id:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
domain: ai_task
|
||||||
|
supported_features:
|
||||||
|
- ai_task.AITaskEntityFeature.GENERATE_TEXT
|
22
homeassistant/components/ai_task/strings.json
Normal file
22
homeassistant/components/ai_task/strings.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"generate_text": {
|
||||||
|
"name": "Generate text",
|
||||||
|
"description": "Use AI to run a task that generates text.",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
71
homeassistant/components/ai_task/task.py
Normal file
71
homeassistant/components/ai_task/task.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""AI tasks to be handled by agents."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
||||||
|
|
||||||
|
|
||||||
|
async def async_generate_text(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
*,
|
||||||
|
task_name: str,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
instructions: str,
|
||||||
|
) -> GenTextTaskResult:
|
||||||
|
"""Run a task in the AI Task integration."""
|
||||||
|
if entity_id is None:
|
||||||
|
entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id
|
||||||
|
|
||||||
|
if entity_id is None:
|
||||||
|
raise ValueError("No entity_id provided and no preferred entity set")
|
||||||
|
|
||||||
|
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise ValueError(f"AI Task entity {entity_id} not found")
|
||||||
|
|
||||||
|
if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features:
|
||||||
|
raise ValueError(f"AI Task entity {entity_id} does not support generating text")
|
||||||
|
|
||||||
|
return await entity.internal_async_generate_text(
|
||||||
|
GenTextTask(
|
||||||
|
name=task_name,
|
||||||
|
instructions=instructions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GenTextTask:
|
||||||
|
"""Gen text 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"<GenTextTask {self.name}: {id(self)}>"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GenTextTaskResult:
|
||||||
|
"""Result of gen text task."""
|
||||||
|
|
||||||
|
conversation_id: str
|
||||||
|
"""Unique identifier for the conversation."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
"""Generated text."""
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
"""Return result as a dict."""
|
||||||
|
return {
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"text": self.text,
|
||||||
|
}
|
@ -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 test_location(
|
location_point_valid = await check_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 test_location(
|
location_nearest_valid = await check_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 test_location(
|
async def check_location(
|
||||||
client: ClientSession,
|
client: ClientSession,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
latitude: float,
|
latitude: float,
|
||||||
|
@ -37,30 +37,35 @@ SENSORS: dict[str, 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(
|
"sla": SensorEntityDescription(
|
||||||
key="sla",
|
key="sla",
|
||||||
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
device_class=SensorDeviceClass.SOUND_PRESSURE,
|
||||||
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
|
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=0,
|
||||||
),
|
),
|
||||||
"battery": SensorEntityDescription(
|
"battery": SensorEntityDescription(
|
||||||
key="battery",
|
key="battery",
|
||||||
@ -68,40 +73,47 @@ 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",
|
||||||
@ -110,18 +122,21 @@ 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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
"""Amazon Devices integration."""
|
"""Alexa Devices integration."""
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -13,7 +13,7 @@ PLATFORMS = [
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||||
"""Set up Amazon Devices platform."""
|
"""Set up Alexa Devices platform."""
|
||||||
|
|
||||||
coordinator = AmazonDevicesCoordinator(hass, entry)
|
coordinator = AmazonDevicesCoordinator(hass, entry)
|
||||||
|
|
@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0
|
|||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||||
"""Amazon Devices binary sensor entity description."""
|
"""Alexa Devices binary sensor entity description."""
|
||||||
|
|
||||||
is_on_fn: Callable[[AmazonDevice], bool]
|
is_on_fn: Callable[[AmazonDevice], bool]
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ async def async_setup_entry(
|
|||||||
entry: AmazonConfigEntry,
|
entry: AmazonConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Amazon Devices binary sensors based on a config entry."""
|
"""Set up Alexa Devices binary sensors based on a config entry."""
|
||||||
|
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
"""Config flow for Amazon Devices integration."""
|
"""Config flow for Alexa 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 Amazon Devices."""
|
"""Handle a config flow for Alexa 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 @@
|
|||||||
"""Amazon Devices constants."""
|
"""Alexa Devices constants."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__package__)
|
_LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
DOMAIN = "amazon_devices"
|
DOMAIN = "alexa_devices"
|
||||||
CONF_LOGIN_DATA = "login_data"
|
CONF_LOGIN_DATA = "login_data"
|
@ -1,4 +1,4 @@
|
|||||||
"""Support for Amazon Devices."""
|
"""Support for Alexa 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 Amazon Devices."""
|
"""Base coordinator for Alexa Devices."""
|
||||||
|
|
||||||
config_entry: AmazonConfigEntry
|
config_entry: AmazonConfigEntry
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
"""Diagnostics support for Amazon Devices integration."""
|
"""Diagnostics support for Alexa Devices integration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -1,4 +1,4 @@
|
|||||||
"""Defines a base Amazon Devices entity."""
|
"""Defines a base Alexa 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 Amazon Devices entity."""
|
"""Defines a base Alexa Devices entity."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
|
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"domain": "amazon_devices",
|
"domain": "alexa_devices",
|
||||||
"name": "Amazon Devices",
|
"name": "Alexa Devices",
|
||||||
"codeowners": ["@chemelli74"],
|
"codeowners": ["@chemelli74"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/amazon_devices",
|
"documentation": "https://www.home-assistant.io/integrations/alexa_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.0.6"]
|
"requirements": ["aioamazondevices==3.1.14"]
|
||||||
}
|
}
|
@ -7,6 +7,7 @@ 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
|
||||||
@ -20,8 +21,9 @@ PARALLEL_UPDATES = 1
|
|||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class AmazonNotifyEntityDescription(NotifyEntityDescription):
|
class AmazonNotifyEntityDescription(NotifyEntityDescription):
|
||||||
"""Amazon Devices notify entity description."""
|
"""Alexa 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
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ 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(
|
||||||
@ -49,7 +52,7 @@ async def async_setup_entry(
|
|||||||
entry: AmazonConfigEntry,
|
entry: AmazonConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Amazon Devices notification entity based on a config entry."""
|
"""Set up Alexa Devices notification entity based on a config entry."""
|
||||||
|
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
@ -58,6 +61,7 @@ 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])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -12,16 +12,16 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"data": {
|
"data": {
|
||||||
"country": "[%key:component::amazon_devices::common::data_country%]",
|
"country": "[%key:component::alexa_devices::common::data_country%]",
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
"code": "[%key:component::amazon_devices::common::data_description_code%]"
|
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"country": "[%key:component::amazon_devices::common::data_description_country%]",
|
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
||||||
"username": "[%key:component::amazon_devices::common::data_description_username%]",
|
"username": "[%key:component::alexa_devices::common::data_description_username%]",
|
||||||
"password": "[%key:component::amazon_devices::common::data_description_password%]",
|
"password": "[%key:component::alexa_devices::common::data_description_password%]",
|
||||||
"code": "[%key:component::amazon_devices::common::data_description_code%]"
|
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
@ -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):
|
||||||
"""Amazon Devices switch entity description."""
|
"""Alexa 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 Amazon Devices switches based on a config entry."""
|
"""Set up Alexa Devices switches based on a config entry."""
|
||||||
|
|
||||||
coordinator = entry.runtime_data
|
coordinator = entry.runtime_data
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from homeassistant.auth.models import User
|
from homeassistant.auth.models import User
|
||||||
from homeassistant.auth.permissions.const import POLICY_CONTROL
|
from homeassistant.auth.permissions.const import POLICY_CONTROL
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
|
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.exceptions import Unauthorized, UnknownUser
|
from homeassistant.exceptions import Unauthorized, UnknownUser
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
from homeassistant.helpers.service import async_extract_entity_ids
|
from homeassistant.helpers.service import async_extract_entity_ids
|
||||||
@ -15,6 +15,7 @@ from .const import CAMERAS, DATA_AMCREST, DOMAIN
|
|||||||
from .helpers import service_signal
|
from .helpers import service_signal
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
"""Set up the Amcrest IP Camera services."""
|
"""Set up the Amcrest IP Camera services."""
|
||||||
|
|
||||||
|
@ -366,15 +366,35 @@ class AnthropicConversationEntity(
|
|||||||
options = self.entry.options
|
options = self.entry.options
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await chat_log.async_update_llm_data(
|
await chat_log.async_provide_llm_data(
|
||||||
DOMAIN,
|
user_input.as_llm_context(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.entry.options
|
||||||
|
|
||||||
tools: list[ToolParam] | None = None
|
tools: list[ToolParam] | None = None
|
||||||
if chat_log.llm_api:
|
if chat_log.llm_api:
|
||||||
tools = [
|
tools = [
|
||||||
@ -424,7 +444,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(
|
||||||
user_input.agent_id,
|
self.entity_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)
|
||||||
@ -435,17 +455,6 @@ 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,6 +12,7 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
|
EntityCategory,
|
||||||
UnitOfApparentPower,
|
UnitOfApparentPower,
|
||||||
UnitOfElectricCurrent,
|
UnitOfElectricCurrent,
|
||||||
UnitOfElectricPotential,
|
UnitOfElectricPotential,
|
||||||
@ -35,6 +36,7 @@ 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",
|
||||||
@ -47,15 +49,18 @@ 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",
|
||||||
@ -82,6 +87,7 @@ 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",
|
||||||
@ -94,52 +100,63 @@ 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",
|
||||||
@ -163,10 +180,12 @@ 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",
|
||||||
@ -198,15 +217,18 @@ 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",
|
||||||
@ -217,11 +239,13 @@ 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",
|
||||||
@ -232,41 +256,48 @@ 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",
|
||||||
@ -291,21 +322,25 @@ 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",
|
||||||
@ -315,20 +350,24 @@ 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",
|
||||||
@ -337,6 +376,7 @@ 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",
|
||||||
@ -360,23 +400,28 @@ 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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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} -o HostKeyAlgorithms=ssh-rsa"
|
connect = f"ssh {self.username}@{self.host}"
|
||||||
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(
|
||||||
[
|
[
|
||||||
|
@ -1,13 +1,23 @@
|
|||||||
"""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.core import HomeAssistant
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
|
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
|
||||||
@ -23,6 +33,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .entity import (
|
from .entity import (
|
||||||
AssistSatelliteAnnouncement,
|
AssistSatelliteAnnouncement,
|
||||||
|
AssistSatelliteAnswer,
|
||||||
AssistSatelliteConfiguration,
|
AssistSatelliteConfiguration,
|
||||||
AssistSatelliteEntity,
|
AssistSatelliteEntity,
|
||||||
AssistSatelliteEntityDescription,
|
AssistSatelliteEntityDescription,
|
||||||
@ -34,6 +45,7 @@ from .websocket_api import async_register_websocket_api
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
"AssistSatelliteAnnouncement",
|
"AssistSatelliteAnnouncement",
|
||||||
|
"AssistSatelliteAnswer",
|
||||||
"AssistSatelliteConfiguration",
|
"AssistSatelliteConfiguration",
|
||||||
"AssistSatelliteEntity",
|
"AssistSatelliteEntity",
|
||||||
"AssistSatelliteEntityDescription",
|
"AssistSatelliteEntityDescription",
|
||||||
@ -86,6 +98,62 @@ 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())
|
||||||
@ -110,3 +178,29 @@ 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,12 +4,16 @@ 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
|
from dataclasses import dataclass, field
|
||||||
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,
|
||||||
@ -105,6 +109,20 @@ 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."""
|
||||||
|
|
||||||
@ -122,6 +140,7 @@ 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
|
||||||
|
|
||||||
@ -309,6 +328,112 @@ 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],
|
||||||
@ -351,6 +476,11 @@ 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
|
||||||
@ -433,6 +563,16 @@ 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:
|
||||||
@ -443,6 +583,12 @@ 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
|
||||||
@ -577,3 +723,15 @@ 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,6 +10,9 @@
|
|||||||
},
|
},
|
||||||
"start_conversation": {
|
"start_conversation": {
|
||||||
"service": "mdi:forum"
|
"service": "mdi:forum"
|
||||||
|
},
|
||||||
|
"ask_question": {
|
||||||
|
"service": "mdi:microphone-question"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,5 +5,6 @@
|
|||||||
"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,3 +54,35 @@ 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:
|
||||||
|
@ -59,6 +59,36 @@
|
|||||||
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 # pylint: disable=import-outside-toplevel
|
from . import automations_with_blueprint # noqa: PLC0415
|
||||||
|
|
||||||
return len(automations_with_blueprint(hass, blueprint_path)) > 0
|
return len(automations_with_blueprint(hass, blueprint_path)) > 0
|
||||||
|
|
||||||
@ -28,8 +28,7 @@ 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."""
|
||||||
# pylint: disable-next=import-outside-toplevel
|
from .config import AUTOMATION_BLUEPRINT_SCHEMA # noqa: PLC0415
|
||||||
from .config import AUTOMATION_BLUEPRINT_SCHEMA
|
|
||||||
|
|
||||||
return blueprint.DomainBlueprints(
|
return blueprint.DomainBlueprints(
|
||||||
hass,
|
hass,
|
||||||
|
@ -94,8 +94,10 @@ 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=import-outside-toplevel, hass-component-root-import
|
# pylint: disable-next=hass-component-root-import
|
||||||
from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter
|
from homeassistant.components.hassio.backup import ( # noqa: PLC0415
|
||||||
|
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 setup_services
|
from .services import async_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."""
|
||||||
|
|
||||||
setup_services(hass)
|
async_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
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
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,34 +21,36 @@ SERVICE_SEND_PIN_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_services(hass: HomeAssistant) -> None:
|
async def _send_pin(call: ServiceCall) -> None:
|
||||||
"""Set up the services for the Blink integration."""
|
"""Call blink to send new pin."""
|
||||||
|
config_entry: BlinkConfigEntry | None
|
||||||
async def send_pin(call: ServiceCall):
|
for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]:
|
||||||
"""Call blink to send new pin."""
|
if not (config_entry := call.hass.config_entries.async_get_entry(entry_id)):
|
||||||
config_entry: BlinkConfigEntry | None
|
raise ServiceValidationError(
|
||||||
for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]:
|
translation_domain=DOMAIN,
|
||||||
if not (config_entry := hass.config_entries.async_get_entry(entry_id)):
|
translation_key="integration_not_found",
|
||||||
raise ServiceValidationError(
|
translation_placeholders={"target": DOMAIN},
|
||||||
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],
|
|
||||||
)
|
)
|
||||||
|
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."""
|
||||||
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
@ -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.12.4"]
|
"requirements": ["bthome-ble==3.13.1"]
|
||||||
}
|
}
|
||||||
|
@ -240,6 +240,10 @@ 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:
|
||||||
@ -494,19 +498,6 @@ 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."""
|
||||||
@ -700,9 +691,7 @@ 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.__supports_stream = self.supported_features & CameraEntityFeature.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:
|
||||||
@ -731,7 +720,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_compat:
|
if CameraEntityFeature.STREAM not in self.supported_features:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return await fn(self.hass, self)
|
return await fn(self.hass, self)
|
||||||
@ -781,7 +770,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_compat:
|
if CameraEntityFeature.STREAM in self.supported_features:
|
||||||
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)
|
||||||
@ -801,8 +790,7 @@ 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_compat
|
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
||||||
& CameraEntityFeature.STREAM
|
|
||||||
):
|
):
|
||||||
self.__supports_stream = supports_stream
|
self.__supports_stream = supports_stream
|
||||||
self._invalidate_camera_capabilities_cache()
|
self._invalidate_camera_capabilities_cache()
|
||||||
|
@ -156,6 +156,15 @@ class CameraWebRTCProvider(ABC):
|
|||||||
"""Close the session."""
|
"""Close the session."""
|
||||||
return ## This is an optional method so we need a default here.
|
return ## This is an optional method so we need a default here.
|
||||||
|
|
||||||
|
async def async_get_image(
|
||||||
|
self,
|
||||||
|
camera: Camera,
|
||||||
|
width: int | None = None,
|
||||||
|
height: int | None = None,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Get an image from the camera."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_register_webrtc_provider(
|
def async_register_webrtc_provider(
|
||||||
|
@ -27,7 +27,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription
|
|||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.temperature import display_temp as show_temp
|
from homeassistant.helpers.temperature import display_temp as show_temp
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.loader import async_suggest_report_issue
|
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||||
|
|
||||||
@ -106,11 +105,6 @@ DEFAULT_MAX_HUMIDITY = 99
|
|||||||
|
|
||||||
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH]
|
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH]
|
||||||
|
|
||||||
# Can be removed in 2025.1 after deprecation period of the new feature flags
|
|
||||||
CHECK_TURN_ON_OFF_FEATURE_FLAG = (
|
|
||||||
ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
|
||||||
)
|
|
||||||
|
|
||||||
SET_TEMPERATURE_SCHEMA = vol.All(
|
SET_TEMPERATURE_SCHEMA = vol.All(
|
||||||
cv.has_at_least_one_key(
|
cv.has_at_least_one_key(
|
||||||
ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW
|
ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW
|
||||||
@ -535,26 +529,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
return
|
return
|
||||||
modes_str: str = ", ".join(modes) if modes else ""
|
modes_str: str = ", ".join(modes) if modes else ""
|
||||||
translation_key = f"not_valid_{mode_type}_mode"
|
translation_key = f"not_valid_{mode_type}_mode"
|
||||||
if mode_type == "hvac":
|
|
||||||
report_issue = async_suggest_report_issue(
|
|
||||||
self.hass,
|
|
||||||
integration_domain=self.platform.platform_name,
|
|
||||||
module=type(self).__module__,
|
|
||||||
)
|
|
||||||
_LOGGER.warning(
|
|
||||||
(
|
|
||||||
"%s::%s sets the hvac_mode %s which is not "
|
|
||||||
"valid for this entity with modes: %s. "
|
|
||||||
"This will stop working in 2025.4 and raise an error instead. "
|
|
||||||
"Please %s"
|
|
||||||
),
|
|
||||||
self.platform.platform_name,
|
|
||||||
self.__class__.__name__,
|
|
||||||
mode,
|
|
||||||
modes_str,
|
|
||||||
report_issue,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
raise ServiceValidationError(
|
raise ServiceValidationError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key=translation_key,
|
translation_key=translation_key,
|
||||||
|
@ -258,6 +258,9 @@
|
|||||||
"not_valid_preset_mode": {
|
"not_valid_preset_mode": {
|
||||||
"message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}."
|
"message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}."
|
||||||
},
|
},
|
||||||
|
"not_valid_hvac_mode": {
|
||||||
|
"message": "HVAC mode {mode} is not valid. Valid HVAC modes are: {modes}."
|
||||||
|
},
|
||||||
"not_valid_swing_mode": {
|
"not_valid_swing_mode": {
|
||||||
"message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}."
|
"message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}."
|
||||||
},
|
},
|
||||||
|
@ -13,6 +13,6 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||||
"requirements": ["hass-nabucasa==0.101.0"],
|
"requirements": ["hass-nabucasa==0.103.0"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
@ -9,12 +9,11 @@ from typing import Any
|
|||||||
from homeassistant.components.notify import BaseNotificationService
|
from homeassistant.components.notify import BaseNotificationService
|
||||||
from homeassistant.const import CONF_COMMAND
|
from homeassistant.const import CONF_COMMAND
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import TemplateError
|
|
||||||
from homeassistant.helpers.template import Template
|
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util.process import kill_subprocess
|
from homeassistant.util.process import kill_subprocess
|
||||||
|
|
||||||
from .const import CONF_COMMAND_TIMEOUT, LOGGER
|
from .const import CONF_COMMAND_TIMEOUT, LOGGER
|
||||||
|
from .utils import render_template_args
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -45,28 +44,10 @@ class CommandLineNotificationService(BaseNotificationService):
|
|||||||
|
|
||||||
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||||
"""Send a message to a command line."""
|
"""Send a message to a command line."""
|
||||||
command = self.command
|
if not (command := render_template_args(self.hass, self.command)):
|
||||||
if " " not in command:
|
return
|
||||||
prog = command
|
|
||||||
args = None
|
|
||||||
args_compiled = None
|
|
||||||
else:
|
|
||||||
prog, args = command.split(" ", 1)
|
|
||||||
args_compiled = Template(args, self.hass)
|
|
||||||
|
|
||||||
rendered_args = None
|
LOGGER.debug("Running with message: %s", message)
|
||||||
if args_compiled:
|
|
||||||
args_to_render = {"arguments": args}
|
|
||||||
try:
|
|
||||||
rendered_args = args_compiled.async_render(args_to_render)
|
|
||||||
except TemplateError as ex:
|
|
||||||
LOGGER.exception("Error rendering command template: %s", ex)
|
|
||||||
return
|
|
||||||
|
|
||||||
if rendered_args != args:
|
|
||||||
command = f"{prog} {rendered_args}"
|
|
||||||
|
|
||||||
LOGGER.debug("Running command: %s, with message: %s", command, message)
|
|
||||||
|
|
||||||
with subprocess.Popen( # noqa: S602 # shell by design
|
with subprocess.Popen( # noqa: S602 # shell by design
|
||||||
command,
|
command,
|
||||||
|
@ -19,7 +19,6 @@ from homeassistant.const import (
|
|||||||
CONF_VALUE_TEMPLATE,
|
CONF_VALUE_TEMPLATE,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import TemplateError
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
from homeassistant.helpers.template import Template
|
from homeassistant.helpers.template import Template
|
||||||
@ -37,7 +36,7 @@ from .const import (
|
|||||||
LOGGER,
|
LOGGER,
|
||||||
TRIGGER_ENTITY_OPTIONS,
|
TRIGGER_ENTITY_OPTIONS,
|
||||||
)
|
)
|
||||||
from .utils import async_check_output_or_log
|
from .utils import async_check_output_or_log, render_template_args
|
||||||
|
|
||||||
DEFAULT_NAME = "Command Sensor"
|
DEFAULT_NAME = "Command Sensor"
|
||||||
|
|
||||||
@ -222,32 +221,6 @@ class CommandSensorData:
|
|||||||
|
|
||||||
async def async_update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Get the latest data with a shell command."""
|
"""Get the latest data with a shell command."""
|
||||||
command = self.command
|
if not (command := render_template_args(self.hass, self.command)):
|
||||||
|
return
|
||||||
if " " not in command:
|
|
||||||
prog = command
|
|
||||||
args = None
|
|
||||||
args_compiled = None
|
|
||||||
else:
|
|
||||||
prog, args = command.split(" ", 1)
|
|
||||||
args_compiled = Template(args, self.hass)
|
|
||||||
|
|
||||||
if args_compiled:
|
|
||||||
try:
|
|
||||||
args_to_render = {"arguments": args}
|
|
||||||
rendered_args = args_compiled.async_render(args_to_render)
|
|
||||||
except TemplateError as ex:
|
|
||||||
LOGGER.exception("Error rendering command template: %s", ex)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
rendered_args = None
|
|
||||||
|
|
||||||
if rendered_args == args:
|
|
||||||
# No template used. default behavior
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Template used. Construct the string used in the shell
|
|
||||||
command = f"{prog} {rendered_args}"
|
|
||||||
|
|
||||||
LOGGER.debug("Running command: %s", command)
|
|
||||||
self.value = await async_check_output_or_log(command, self.timeout)
|
self.value = await async_check_output_or_log(command, self.timeout)
|
||||||
|
@ -3,9 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import TemplateError
|
||||||
|
from homeassistant.helpers.template import Template
|
||||||
|
|
||||||
|
from .const import LOGGER
|
||||||
|
|
||||||
_EXEC_FAILED_CODE = 127
|
_EXEC_FAILED_CODE = 127
|
||||||
|
|
||||||
|
|
||||||
@ -18,7 +22,7 @@ async def async_call_shell_with_timeout(
|
|||||||
return code is returned.
|
return code is returned.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_LOGGER.debug("Running command: %s", command)
|
LOGGER.debug("Running command: %s", command)
|
||||||
proc = await asyncio.create_subprocess_shell( # shell by design
|
proc = await asyncio.create_subprocess_shell( # shell by design
|
||||||
command,
|
command,
|
||||||
close_fds=False, # required for posix_spawn
|
close_fds=False, # required for posix_spawn
|
||||||
@ -26,14 +30,14 @@ async def async_call_shell_with_timeout(
|
|||||||
async with asyncio.timeout(timeout):
|
async with asyncio.timeout(timeout):
|
||||||
await proc.communicate()
|
await proc.communicate()
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.error("Timeout for command: %s", command)
|
LOGGER.error("Timeout for command: %s", command)
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
return_code = proc.returncode
|
return_code = proc.returncode
|
||||||
if return_code == _EXEC_FAILED_CODE:
|
if return_code == _EXEC_FAILED_CODE:
|
||||||
_LOGGER.error("Error trying to exec command: %s", command)
|
LOGGER.error("Error trying to exec command: %s", command)
|
||||||
elif log_return_code and return_code != 0:
|
elif log_return_code and return_code != 0:
|
||||||
_LOGGER.error(
|
LOGGER.error(
|
||||||
"Command failed (with return code %s): %s",
|
"Command failed (with return code %s): %s",
|
||||||
proc.returncode,
|
proc.returncode,
|
||||||
command,
|
command,
|
||||||
@ -53,12 +57,39 @@ async def async_check_output_or_log(command: str, timeout: int) -> str | None:
|
|||||||
stdout, _ = await proc.communicate()
|
stdout, _ = await proc.communicate()
|
||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
_LOGGER.error(
|
LOGGER.error(
|
||||||
"Command failed (with return code %s): %s", proc.returncode, command
|
"Command failed (with return code %s): %s", proc.returncode, command
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return stdout.strip().decode("utf-8")
|
return stdout.strip().decode("utf-8")
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
_LOGGER.error("Timeout for command: %s", command)
|
LOGGER.error("Timeout for command: %s", command)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def render_template_args(hass: HomeAssistant, command: str) -> str | None:
|
||||||
|
"""Render template arguments for command line utilities."""
|
||||||
|
if " " not in command:
|
||||||
|
prog = command
|
||||||
|
args = None
|
||||||
|
args_compiled = None
|
||||||
|
else:
|
||||||
|
prog, args = command.split(" ", 1)
|
||||||
|
args_compiled = Template(args, hass)
|
||||||
|
|
||||||
|
rendered_args = None
|
||||||
|
if args_compiled:
|
||||||
|
args_to_render = {"arguments": args}
|
||||||
|
try:
|
||||||
|
rendered_args = args_compiled.async_render(args_to_render)
|
||||||
|
except TemplateError as ex:
|
||||||
|
LOGGER.exception("Error rendering command template: %s", ex)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if rendered_args != args:
|
||||||
|
command = f"{prog} {rendered_args}"
|
||||||
|
|
||||||
|
LOGGER.debug("Running command: %s", command)
|
||||||
|
|
||||||
|
return command
|
||||||
|
@ -54,10 +54,10 @@ class Control4RuntimeData:
|
|||||||
type Control4ConfigEntry = ConfigEntry[Control4RuntimeData]
|
type Control4ConfigEntry = ConfigEntry[Control4RuntimeData]
|
||||||
|
|
||||||
|
|
||||||
async def call_c4_api_retry(func, *func_args):
|
async def call_c4_api_retry(func, *func_args): # noqa: RET503
|
||||||
"""Call C4 API function and retry on failure."""
|
"""Call C4 API function and retry on failure."""
|
||||||
# Ruff doesn't understand this loop - the exception is always raised after the retries
|
# Ruff doesn't understand this loop - the exception is always raised after the retries
|
||||||
for i in range(API_RETRY_TIMES): # noqa: RET503
|
for i in range(API_RETRY_TIMES):
|
||||||
try:
|
try:
|
||||||
return await func(*func_args)
|
return await func(*func_args)
|
||||||
except client_exceptions.ClientError as exception:
|
except client_exceptions.ClientError as exception:
|
||||||
|
@ -271,7 +271,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Temporary migration. We can remove this in 2024.10
|
# Temporary migration. We can remove this in 2024.10
|
||||||
from homeassistant.components.assist_pipeline import ( # pylint: disable=import-outside-toplevel
|
from homeassistant.components.assist_pipeline import ( # noqa: PLC0415
|
||||||
async_migrate_engine,
|
async_migrate_engine,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -14,12 +14,11 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||||
from homeassistant.helpers import chat_session, intent, llm, template
|
from homeassistant.helpers import chat_session, frame, intent, llm, template
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
from homeassistant.util.json import JsonObjectType
|
from homeassistant.util.json import JsonObjectType
|
||||||
|
|
||||||
from . import trace
|
from . import trace
|
||||||
from .const import DOMAIN
|
|
||||||
from .models import ConversationInput, ConversationResult
|
from .models import ConversationInput, ConversationResult
|
||||||
|
|
||||||
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
||||||
@ -359,7 +358,7 @@ class ChatLog:
|
|||||||
self,
|
self,
|
||||||
llm_context: llm.LLMContext,
|
llm_context: llm.LLMContext,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
language: str,
|
language: str | None,
|
||||||
user_name: str | None = None,
|
user_name: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
try:
|
try:
|
||||||
@ -373,7 +372,7 @@ class ChatLog:
|
|||||||
)
|
)
|
||||||
except TemplateError as err:
|
except TemplateError as err:
|
||||||
LOGGER.error("Error rendering prompt: %s", err)
|
LOGGER.error("Error rendering prompt: %s", err)
|
||||||
intent_response = intent.IntentResponse(language=language)
|
intent_response = intent.IntentResponse(language=language or "")
|
||||||
intent_response.async_set_error(
|
intent_response.async_set_error(
|
||||||
intent.IntentResponseErrorCode.UNKNOWN,
|
intent.IntentResponseErrorCode.UNKNOWN,
|
||||||
"Sorry, I had a problem with my template",
|
"Sorry, I had a problem with my template",
|
||||||
@ -392,15 +391,25 @@ class ChatLog:
|
|||||||
user_llm_prompt: str | None = None,
|
user_llm_prompt: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the LLM system prompt."""
|
"""Set the LLM system prompt."""
|
||||||
llm_context = llm.LLMContext(
|
frame.report_usage(
|
||||||
platform=conversing_domain,
|
"ChatLog.async_update_llm_data",
|
||||||
context=user_input.context,
|
breaks_in_ha_version="2026.1",
|
||||||
user_prompt=user_input.text,
|
)
|
||||||
language=user_input.language,
|
return await self.async_provide_llm_data(
|
||||||
assistant=DOMAIN,
|
llm_context=user_input.as_llm_context(conversing_domain),
|
||||||
device_id=user_input.device_id,
|
user_llm_hass_api=user_llm_hass_api,
|
||||||
|
user_llm_prompt=user_llm_prompt,
|
||||||
|
user_extra_system_prompt=user_input.extra_system_prompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_provide_llm_data(
|
||||||
|
self,
|
||||||
|
llm_context: llm.LLMContext,
|
||||||
|
user_llm_hass_api: str | list[str] | None = None,
|
||||||
|
user_llm_prompt: str | None = None,
|
||||||
|
user_extra_system_prompt: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set the LLM system prompt."""
|
||||||
llm_api: llm.APIInstance | None = None
|
llm_api: llm.APIInstance | None = None
|
||||||
|
|
||||||
if user_llm_hass_api:
|
if user_llm_hass_api:
|
||||||
@ -414,10 +423,12 @@ class ChatLog:
|
|||||||
LOGGER.error(
|
LOGGER.error(
|
||||||
"Error getting LLM API %s for %s: %s",
|
"Error getting LLM API %s for %s: %s",
|
||||||
user_llm_hass_api,
|
user_llm_hass_api,
|
||||||
conversing_domain,
|
llm_context.platform,
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
intent_response = intent.IntentResponse(language=user_input.language)
|
intent_response = intent.IntentResponse(
|
||||||
|
language=llm_context.language or ""
|
||||||
|
)
|
||||||
intent_response.async_set_error(
|
intent_response.async_set_error(
|
||||||
intent.IntentResponseErrorCode.UNKNOWN,
|
intent.IntentResponseErrorCode.UNKNOWN,
|
||||||
"Error preparing LLM API",
|
"Error preparing LLM API",
|
||||||
@ -431,10 +442,10 @@ class ChatLog:
|
|||||||
user_name: str | None = None
|
user_name: str | None = None
|
||||||
|
|
||||||
if (
|
if (
|
||||||
user_input.context
|
llm_context.context
|
||||||
and user_input.context.user_id
|
and llm_context.context.user_id
|
||||||
and (
|
and (
|
||||||
user := await self.hass.auth.async_get_user(user_input.context.user_id)
|
user := await self.hass.auth.async_get_user(llm_context.context.user_id)
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
user_name = user.name
|
user_name = user.name
|
||||||
@ -444,7 +455,7 @@ class ChatLog:
|
|||||||
await self._async_expand_prompt_template(
|
await self._async_expand_prompt_template(
|
||||||
llm_context,
|
llm_context,
|
||||||
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||||
user_input.language,
|
llm_context.language,
|
||||||
user_name,
|
user_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -456,14 +467,14 @@ class ChatLog:
|
|||||||
await self._async_expand_prompt_template(
|
await self._async_expand_prompt_template(
|
||||||
llm_context,
|
llm_context,
|
||||||
llm.BASE_PROMPT,
|
llm.BASE_PROMPT,
|
||||||
user_input.language,
|
llm_context.language,
|
||||||
user_name,
|
user_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if extra_system_prompt := (
|
if extra_system_prompt := (
|
||||||
# Take new system prompt if one was given
|
# Take new system prompt if one was given
|
||||||
user_input.extra_system_prompt or self.extra_system_prompt
|
user_extra_system_prompt or self.extra_system_prompt
|
||||||
):
|
):
|
||||||
prompt_parts.append(extra_system_prompt)
|
prompt_parts.append(extra_system_prompt)
|
||||||
|
|
||||||
|
@ -7,7 +7,9 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from homeassistant.core import Context
|
from homeassistant.core import Context
|
||||||
from homeassistant.helpers import intent
|
from homeassistant.helpers import intent, llm
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -56,6 +58,16 @@ class ConversationInput:
|
|||||||
"extra_system_prompt": self.extra_system_prompt,
|
"extra_system_prompt": self.extra_system_prompt,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def as_llm_context(self, conversing_domain: str) -> llm.LLMContext:
|
||||||
|
"""Return input as an LLM context."""
|
||||||
|
return llm.LLMContext(
|
||||||
|
platform=conversing_domain,
|
||||||
|
context=self.context,
|
||||||
|
language=self.language,
|
||||||
|
assistant=DOMAIN,
|
||||||
|
device_id=self.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ConversationResult:
|
class ConversationResult:
|
||||||
|
@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
|||||||
def supported_features(self) -> CoverEntityFeature:
|
def supported_features(self) -> CoverEntityFeature:
|
||||||
"""Flag supported features."""
|
"""Flag supported features."""
|
||||||
if (features := self._attr_supported_features) is not None:
|
if (features := self._attr_supported_features) is not None:
|
||||||
if type(features) is int:
|
|
||||||
new_features = CoverEntityFeature(features)
|
|
||||||
self._report_deprecated_supported_features_values(new_features)
|
|
||||||
return new_features
|
|
||||||
return features
|
return features
|
||||||
|
|
||||||
supported_features = (
|
supported_features = (
|
||||||
|
@ -164,8 +164,6 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity):
|
|||||||
|
|
||||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
"""Set new target hvac mode."""
|
"""Set new target hvac mode."""
|
||||||
if hvac_mode not in self._attr_hvac_modes:
|
|
||||||
raise ValueError(f"Unsupported HVAC mode {hvac_mode}")
|
|
||||||
|
|
||||||
if len(self._attr_hvac_modes) == 2: # Only allow turn on and off thermostat
|
if len(self._attr_hvac_modes) == 2: # Only allow turn on and off thermostat
|
||||||
await self.hub.api.sensors.thermostat.set_config(
|
await self.hub.api.sensors.thermostat.set_config(
|
||||||
|
@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant
|
|||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers.device_registry import DeviceEntry
|
from homeassistant.helpers.device_registry import DeviceEntry
|
||||||
|
|
||||||
from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS
|
from .const import DOMAIN, GATEWAY_SERIAL_PATTERN, PLATFORMS
|
||||||
|
|
||||||
type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]]
|
type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]]
|
||||||
|
|
||||||
@ -32,10 +32,16 @@ async def async_setup_entry(
|
|||||||
credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid)
|
credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid)
|
||||||
|
|
||||||
if not credentials_valid:
|
if not credentials_valid:
|
||||||
raise ConfigEntryAuthFailed
|
raise ConfigEntryAuthFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="invalid_auth",
|
||||||
|
)
|
||||||
|
|
||||||
if await hass.async_add_executor_job(mydevolo.maintenance):
|
if await hass.async_add_executor_job(mydevolo.maintenance):
|
||||||
raise ConfigEntryNotReady
|
raise ConfigEntryNotReady(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="maintenance",
|
||||||
|
)
|
||||||
|
|
||||||
gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids)
|
gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids)
|
||||||
|
|
||||||
@ -69,7 +75,11 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except GatewayOfflineError as err:
|
except GatewayOfflineError as err:
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="connection_failed",
|
||||||
|
translation_placeholders={"gateway_id": gateway_id},
|
||||||
|
) from err
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
@ -91,7 +101,9 @@ async def async_unload_entry(
|
|||||||
|
|
||||||
|
|
||||||
async def async_remove_config_entry_device(
|
async def async_remove_config_entry_device(
|
||||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
|
hass: HomeAssistant,
|
||||||
|
config_entry: DevoloHomeControlConfigEntry,
|
||||||
|
device_entry: DeviceEntry,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Remove a config entry from a device."""
|
"""Remove a config entry from a device."""
|
||||||
return True
|
return True
|
||||||
|
@ -45,5 +45,16 @@
|
|||||||
"name": "Brightness"
|
"name": "Brightness"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"connection_failed": {
|
||||||
|
"message": "Failed to connect to devolo Home Control central unit {gateway_id}."
|
||||||
|
},
|
||||||
|
"invalid_auth": {
|
||||||
|
"message": "Authentication failed. Please re-authenticaticate with your mydevolo account."
|
||||||
|
},
|
||||||
|
"maintenance": {
|
||||||
|
"message": "devolo Home Control is currently in maintenance mode."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,6 +87,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
|
|||||||
):
|
):
|
||||||
"""Representation of a devolo device tracker."""
|
"""Representation of a devolo device tracker."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
_attr_translation_key = "device_tracker"
|
_attr_translation_key = "device_tracker"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -99,6 +100,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
|
|||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
self._device = device
|
self._device = device
|
||||||
self._attr_mac_address = mac
|
self._attr_mac_address = mac
|
||||||
|
self._attr_name = mac
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, str]:
|
def extra_state_attributes(self) -> dict[str, str]:
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["discord"],
|
"loggers": ["discord"],
|
||||||
"requirements": ["nextcord==2.6.0"]
|
"requirements": ["nextcord==3.1.0"]
|
||||||
}
|
}
|
||||||
|
@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/dnsip",
|
"documentation": "https://www.home-assistant.io/integrations/dnsip",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["aiodns==3.4.0"]
|
"requirements": ["aiodns==3.5.0"]
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import threading
|
|||||||
import requests
|
import requests
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.service import async_register_admin_service
|
from homeassistant.helpers.service import async_register_admin_service
|
||||||
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
|
||||||
@ -108,8 +108,7 @@ def download_file(service: ServiceCall) -> None:
|
|||||||
_LOGGER.debug("%s -> %s", url, final_path)
|
_LOGGER.debug("%s -> %s", url, final_path)
|
||||||
|
|
||||||
with open(final_path, "wb") as fil:
|
with open(final_path, "wb") as fil:
|
||||||
for chunk in req.iter_content(1024):
|
fil.writelines(req.iter_content(1024))
|
||||||
fil.write(chunk)
|
|
||||||
|
|
||||||
_LOGGER.debug("Downloading of %s done", url)
|
_LOGGER.debug("Downloading of %s done", url)
|
||||||
service.hass.bus.fire(
|
service.hass.bus.fire(
|
||||||
@ -141,6 +140,7 @@ def download_file(service: ServiceCall) -> None:
|
|||||||
threading.Thread(target=do_download).start()
|
threading.Thread(target=do_download).start()
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
"""Register the services for the downloader component."""
|
"""Register the services for the downloader component."""
|
||||||
async_register_admin_service(
|
async_register_admin_service(
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Generic
|
|
||||||
|
|
||||||
from deebot_client.capabilities import CapabilityEvent
|
from deebot_client.capabilities import CapabilityEvent
|
||||||
|
from deebot_client.events.base import Event
|
||||||
from deebot_client.events.water_info import MopAttachedEvent
|
from deebot_client.events.water_info import MopAttachedEvent
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
@ -16,15 +16,14 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import EcovacsConfigEntry
|
from . import EcovacsConfigEntry
|
||||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
|
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
|
||||||
from .util import get_supported_entities
|
from .util import get_supported_entities
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True, frozen=True)
|
@dataclass(kw_only=True, frozen=True)
|
||||||
class EcovacsBinarySensorEntityDescription(
|
class EcovacsBinarySensorEntityDescription[EventT: Event](
|
||||||
BinarySensorEntityDescription,
|
BinarySensorEntityDescription,
|
||||||
EcovacsCapabilityEntityDescription,
|
EcovacsCapabilityEntityDescription,
|
||||||
Generic[EventT],
|
|
||||||
):
|
):
|
||||||
"""Class describing Deebot binary sensor entity."""
|
"""Class describing Deebot binary sensor entity."""
|
||||||
|
|
||||||
@ -55,7 +54,7 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EcovacsBinarySensor(
|
class EcovacsBinarySensor[EventT: Event](
|
||||||
EcovacsDescriptionEntity[CapabilityEvent[EventT]],
|
EcovacsDescriptionEntity[CapabilityEvent[EventT]],
|
||||||
BinarySensorEntity,
|
BinarySensorEntity,
|
||||||
):
|
):
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Generic, TypeVar
|
from typing import Any
|
||||||
|
|
||||||
from deebot_client.capabilities import Capabilities
|
from deebot_client.capabilities import Capabilities
|
||||||
from deebot_client.device import Device
|
from deebot_client.device import Device
|
||||||
@ -18,11 +18,8 @@ from homeassistant.helpers.entity import Entity, EntityDescription
|
|||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
CapabilityEntity = TypeVar("CapabilityEntity")
|
|
||||||
EventT = TypeVar("EventT", bound=Event)
|
|
||||||
|
|
||||||
|
class EcovacsEntity[CapabilityEntityT](Entity):
|
||||||
class EcovacsEntity(Entity, Generic[CapabilityEntity]):
|
|
||||||
"""Ecovacs entity."""
|
"""Ecovacs entity."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
@ -32,7 +29,7 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device: Device,
|
device: Device,
|
||||||
capability: CapabilityEntity,
|
capability: CapabilityEntityT,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize entity."""
|
"""Initialize entity."""
|
||||||
@ -80,7 +77,7 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]):
|
|||||||
|
|
||||||
self._subscribe(AvailabilityEvent, on_available)
|
self._subscribe(AvailabilityEvent, on_available)
|
||||||
|
|
||||||
def _subscribe(
|
def _subscribe[EventT: Event](
|
||||||
self,
|
self,
|
||||||
event_type: type[EventT],
|
event_type: type[EventT],
|
||||||
callback: Callable[[EventT], Coroutine[Any, Any, None]],
|
callback: Callable[[EventT], Coroutine[Any, Any, None]],
|
||||||
@ -98,13 +95,13 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]):
|
|||||||
self._device.events.request_refresh(event_type)
|
self._device.events.request_refresh(event_type)
|
||||||
|
|
||||||
|
|
||||||
class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]):
|
class EcovacsDescriptionEntity[CapabilityEntityT](EcovacsEntity[CapabilityEntityT]):
|
||||||
"""Ecovacs entity."""
|
"""Ecovacs entity."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device: Device,
|
device: Device,
|
||||||
capability: CapabilityEntity,
|
capability: CapabilityEntityT,
|
||||||
entity_description: EntityDescription,
|
entity_description: EntityDescription,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -114,13 +111,12 @@ class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]):
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True, frozen=True)
|
@dataclass(kw_only=True, frozen=True)
|
||||||
class EcovacsCapabilityEntityDescription(
|
class EcovacsCapabilityEntityDescription[CapabilityEntityT](
|
||||||
EntityDescription,
|
EntityDescription,
|
||||||
Generic[CapabilityEntity],
|
|
||||||
):
|
):
|
||||||
"""Ecovacs entity description."""
|
"""Ecovacs entity description."""
|
||||||
|
|
||||||
capability_fn: Callable[[Capabilities], CapabilityEntity | None]
|
capability_fn: Callable[[Capabilities], CapabilityEntityT | None]
|
||||||
|
|
||||||
|
|
||||||
class EcovacsLegacyEntity(Entity):
|
class EcovacsLegacyEntity(Entity):
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.3.0"]
|
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"]
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Generic
|
|
||||||
|
|
||||||
from deebot_client.capabilities import CapabilitySet
|
from deebot_client.capabilities import CapabilitySet
|
||||||
from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
|
from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
|
||||||
|
from deebot_client.events.base import Event
|
||||||
|
|
||||||
from homeassistant.components.number import (
|
from homeassistant.components.number import (
|
||||||
NumberEntity,
|
NumberEntity,
|
||||||
@ -23,16 +23,14 @@ from .entity import (
|
|||||||
EcovacsCapabilityEntityDescription,
|
EcovacsCapabilityEntityDescription,
|
||||||
EcovacsDescriptionEntity,
|
EcovacsDescriptionEntity,
|
||||||
EcovacsEntity,
|
EcovacsEntity,
|
||||||
EventT,
|
|
||||||
)
|
)
|
||||||
from .util import get_supported_entities
|
from .util import get_supported_entities
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True, frozen=True)
|
@dataclass(kw_only=True, frozen=True)
|
||||||
class EcovacsNumberEntityDescription(
|
class EcovacsNumberEntityDescription[EventT: Event](
|
||||||
NumberEntityDescription,
|
NumberEntityDescription,
|
||||||
EcovacsCapabilityEntityDescription,
|
EcovacsCapabilityEntityDescription,
|
||||||
Generic[EventT],
|
|
||||||
):
|
):
|
||||||
"""Ecovacs number entity description."""
|
"""Ecovacs number entity description."""
|
||||||
|
|
||||||
@ -94,7 +92,7 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class EcovacsNumberEntity(
|
class EcovacsNumberEntity[EventT: Event](
|
||||||
EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]],
|
EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]],
|
||||||
NumberEntity,
|
NumberEntity,
|
||||||
):
|
):
|
||||||
|
@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Generic
|
from typing import Any
|
||||||
|
|
||||||
from deebot_client.capabilities import CapabilitySetTypes
|
from deebot_client.capabilities import CapabilitySetTypes
|
||||||
from deebot_client.device import Device
|
from deebot_client.device import Device
|
||||||
from deebot_client.events import WorkModeEvent
|
from deebot_client.events import WorkModeEvent
|
||||||
|
from deebot_client.events.base import Event
|
||||||
from deebot_client.events.water_info import WaterAmountEvent
|
from deebot_client.events.water_info import WaterAmountEvent
|
||||||
|
|
||||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||||
@ -15,15 +16,14 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import EcovacsConfigEntry
|
from . import EcovacsConfigEntry
|
||||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
|
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
|
||||||
from .util import get_name_key, get_supported_entities
|
from .util import get_name_key, get_supported_entities
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True, frozen=True)
|
@dataclass(kw_only=True, frozen=True)
|
||||||
class EcovacsSelectEntityDescription(
|
class EcovacsSelectEntityDescription[EventT: Event](
|
||||||
SelectEntityDescription,
|
SelectEntityDescription,
|
||||||
EcovacsCapabilityEntityDescription,
|
EcovacsCapabilityEntityDescription,
|
||||||
Generic[EventT],
|
|
||||||
):
|
):
|
||||||
"""Ecovacs select entity description."""
|
"""Ecovacs select entity description."""
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class EcovacsSelectEntity(
|
class EcovacsSelectEntity[EventT: Event](
|
||||||
EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]],
|
EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]],
|
||||||
SelectEntity,
|
SelectEntity,
|
||||||
):
|
):
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Generic
|
from typing import Any
|
||||||
|
|
||||||
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
|
from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType
|
||||||
from deebot_client.device import Device
|
from deebot_client.device import Device
|
||||||
@ -46,16 +46,14 @@ from .entity import (
|
|||||||
EcovacsDescriptionEntity,
|
EcovacsDescriptionEntity,
|
||||||
EcovacsEntity,
|
EcovacsEntity,
|
||||||
EcovacsLegacyEntity,
|
EcovacsLegacyEntity,
|
||||||
EventT,
|
|
||||||
)
|
)
|
||||||
from .util import get_name_key, get_options, get_supported_entities
|
from .util import get_name_key, get_options, get_supported_entities
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True, frozen=True)
|
@dataclass(kw_only=True, frozen=True)
|
||||||
class EcovacsSensorEntityDescription(
|
class EcovacsSensorEntityDescription[EventT: Event](
|
||||||
EcovacsCapabilityEntityDescription,
|
EcovacsCapabilityEntityDescription,
|
||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
Generic[EventT],
|
|
||||||
):
|
):
|
||||||
"""Ecovacs sensor entity description."""
|
"""Ecovacs sensor entity description."""
|
||||||
|
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["sml"],
|
"loggers": ["sml"],
|
||||||
"requirements": ["pysml==0.0.12"]
|
"requirements": ["pysml==0.1.5"]
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Generic, TypeVar, override
|
from typing import Any, override
|
||||||
|
|
||||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||||
from eheimdigital.device import EheimDigitalDevice
|
from eheimdigital.device import EheimDigitalDevice
|
||||||
@ -30,16 +30,16 @@ from .entity import EheimDigitalEntity, exception_handler
|
|||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]):
|
class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
|
||||||
|
NumberEntityDescription
|
||||||
|
):
|
||||||
"""Class describing EHEIM Digital sensor entities."""
|
"""Class describing EHEIM Digital sensor entities."""
|
||||||
|
|
||||||
value_fn: Callable[[_DeviceT_co], float | None]
|
value_fn: Callable[[_DeviceT], float | None]
|
||||||
set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]]
|
set_value_fn: Callable[[_DeviceT, float], Awaitable[None]]
|
||||||
uom_fn: Callable[[_DeviceT_co], str] | None = None
|
uom_fn: Callable[[_DeviceT], str] | None = None
|
||||||
|
|
||||||
|
|
||||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||||
@ -136,7 +136,7 @@ async def async_setup_entry(
|
|||||||
device_address: dict[str, EheimDigitalDevice],
|
device_address: dict[str, EheimDigitalDevice],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the number entities for one or multiple devices."""
|
"""Set up the number entities for one or multiple devices."""
|
||||||
entities: list[EheimDigitalNumber[EheimDigitalDevice]] = []
|
entities: list[EheimDigitalNumber[Any]] = []
|
||||||
for device in device_address.values():
|
for device in device_address.values():
|
||||||
if isinstance(device, EheimDigitalClassicVario):
|
if isinstance(device, EheimDigitalClassicVario):
|
||||||
entities.extend(
|
entities.extend(
|
||||||
@ -163,18 +163,18 @@ async def async_setup_entry(
|
|||||||
async_setup_device_entities(coordinator.hub.devices)
|
async_setup_device_entities(coordinator.hub.devices)
|
||||||
|
|
||||||
|
|
||||||
class EheimDigitalNumber(
|
class EheimDigitalNumber[_DeviceT: EheimDigitalDevice](
|
||||||
EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co]
|
EheimDigitalEntity[_DeviceT], NumberEntity
|
||||||
):
|
):
|
||||||
"""Represent a EHEIM Digital number entity."""
|
"""Represent a EHEIM Digital number entity."""
|
||||||
|
|
||||||
entity_description: EheimDigitalNumberDescription[_DeviceT_co]
|
entity_description: EheimDigitalNumberDescription[_DeviceT]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: EheimDigitalUpdateCoordinator,
|
coordinator: EheimDigitalUpdateCoordinator,
|
||||||
device: _DeviceT_co,
|
device: _DeviceT,
|
||||||
description: EheimDigitalNumberDescription[_DeviceT_co],
|
description: EheimDigitalNumberDescription[_DeviceT],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize an EHEIM Digital number entity."""
|
"""Initialize an EHEIM Digital number entity."""
|
||||||
super().__init__(coordinator, device)
|
super().__init__(coordinator, device)
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Generic, TypeVar, override
|
from typing import Any, override
|
||||||
|
|
||||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||||
from eheimdigital.device import EheimDigitalDevice
|
from eheimdigital.device import EheimDigitalDevice
|
||||||
@ -17,15 +17,15 @@ from .entity import EheimDigitalEntity, exception_handler
|
|||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]):
|
class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
|
||||||
|
SelectEntityDescription
|
||||||
|
):
|
||||||
"""Class describing EHEIM Digital select entities."""
|
"""Class describing EHEIM Digital select entities."""
|
||||||
|
|
||||||
value_fn: Callable[[_DeviceT_co], str | None]
|
value_fn: Callable[[_DeviceT], str | None]
|
||||||
set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]]
|
set_value_fn: Callable[[_DeviceT, str], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||||
@ -59,7 +59,7 @@ async def async_setup_entry(
|
|||||||
device_address: dict[str, EheimDigitalDevice],
|
device_address: dict[str, EheimDigitalDevice],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the number entities for one or multiple devices."""
|
"""Set up the number entities for one or multiple devices."""
|
||||||
entities: list[EheimDigitalSelect[EheimDigitalDevice]] = []
|
entities: list[EheimDigitalSelect[Any]] = []
|
||||||
for device in device_address.values():
|
for device in device_address.values():
|
||||||
if isinstance(device, EheimDigitalClassicVario):
|
if isinstance(device, EheimDigitalClassicVario):
|
||||||
entities.extend(
|
entities.extend(
|
||||||
@ -75,18 +75,18 @@ async def async_setup_entry(
|
|||||||
async_setup_device_entities(coordinator.hub.devices)
|
async_setup_device_entities(coordinator.hub.devices)
|
||||||
|
|
||||||
|
|
||||||
class EheimDigitalSelect(
|
class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
|
||||||
EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co]
|
EheimDigitalEntity[_DeviceT], SelectEntity
|
||||||
):
|
):
|
||||||
"""Represent an EHEIM Digital select entity."""
|
"""Represent an EHEIM Digital select entity."""
|
||||||
|
|
||||||
entity_description: EheimDigitalSelectDescription[_DeviceT_co]
|
entity_description: EheimDigitalSelectDescription[_DeviceT]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: EheimDigitalUpdateCoordinator,
|
coordinator: EheimDigitalUpdateCoordinator,
|
||||||
device: _DeviceT_co,
|
device: _DeviceT,
|
||||||
description: EheimDigitalSelectDescription[_DeviceT_co],
|
description: EheimDigitalSelectDescription[_DeviceT],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize an EHEIM Digital select entity."""
|
"""Initialize an EHEIM Digital select entity."""
|
||||||
super().__init__(coordinator, device)
|
super().__init__(coordinator, device)
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Generic, TypeVar, override
|
from typing import Any, override
|
||||||
|
|
||||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||||
from eheimdigital.device import EheimDigitalDevice
|
from eheimdigital.device import EheimDigitalDevice
|
||||||
@ -20,14 +20,14 @@ from .entity import EheimDigitalEntity
|
|||||||
# Coordinator is used to centralize the data updates
|
# Coordinator is used to centralize the data updates
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]):
|
class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice](
|
||||||
|
SensorEntityDescription
|
||||||
|
):
|
||||||
"""Class describing EHEIM Digital sensor entities."""
|
"""Class describing EHEIM Digital sensor entities."""
|
||||||
|
|
||||||
value_fn: Callable[[_DeviceT_co], float | str | None]
|
value_fn: Callable[[_DeviceT], float | str | None]
|
||||||
|
|
||||||
|
|
||||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||||
@ -75,7 +75,7 @@ async def async_setup_entry(
|
|||||||
device_address: dict[str, EheimDigitalDevice],
|
device_address: dict[str, EheimDigitalDevice],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the light entities for one or multiple devices."""
|
"""Set up the light entities for one or multiple devices."""
|
||||||
entities: list[EheimDigitalSensor[EheimDigitalDevice]] = []
|
entities: list[EheimDigitalSensor[Any]] = []
|
||||||
for device in device_address.values():
|
for device in device_address.values():
|
||||||
if isinstance(device, EheimDigitalClassicVario):
|
if isinstance(device, EheimDigitalClassicVario):
|
||||||
entities += [
|
entities += [
|
||||||
@ -91,18 +91,18 @@ async def async_setup_entry(
|
|||||||
async_setup_device_entities(coordinator.hub.devices)
|
async_setup_device_entities(coordinator.hub.devices)
|
||||||
|
|
||||||
|
|
||||||
class EheimDigitalSensor(
|
class EheimDigitalSensor[_DeviceT: EheimDigitalDevice](
|
||||||
EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co]
|
EheimDigitalEntity[_DeviceT], SensorEntity
|
||||||
):
|
):
|
||||||
"""Represent a EHEIM Digital sensor entity."""
|
"""Represent a EHEIM Digital sensor entity."""
|
||||||
|
|
||||||
entity_description: EheimDigitalSensorDescription[_DeviceT_co]
|
entity_description: EheimDigitalSensorDescription[_DeviceT]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: EheimDigitalUpdateCoordinator,
|
coordinator: EheimDigitalUpdateCoordinator,
|
||||||
device: _DeviceT_co,
|
device: _DeviceT,
|
||||||
description: EheimDigitalSensorDescription[_DeviceT_co],
|
description: EheimDigitalSensorDescription[_DeviceT],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize an EHEIM Digital number entity."""
|
"""Initialize an EHEIM Digital number entity."""
|
||||||
super().__init__(coordinator, device)
|
super().__init__(coordinator, device)
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import time
|
from datetime import time
|
||||||
from typing import Generic, TypeVar, final, override
|
from typing import Any, final, override
|
||||||
|
|
||||||
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||||
from eheimdigital.device import EheimDigitalDevice
|
from eheimdigital.device import EheimDigitalDevice
|
||||||
@ -19,15 +19,13 @@ from .entity import EheimDigitalEntity, exception_handler
|
|||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]):
|
class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescription):
|
||||||
"""Class describing EHEIM Digital time entities."""
|
"""Class describing EHEIM Digital time entities."""
|
||||||
|
|
||||||
value_fn: Callable[[_DeviceT_co], time | None]
|
value_fn: Callable[[_DeviceT], time | None]
|
||||||
set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]]
|
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
CLASSICVARIO_DESCRIPTIONS: tuple[
|
CLASSICVARIO_DESCRIPTIONS: tuple[
|
||||||
@ -79,7 +77,7 @@ async def async_setup_entry(
|
|||||||
device_address: dict[str, EheimDigitalDevice],
|
device_address: dict[str, EheimDigitalDevice],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the time entities for one or multiple devices."""
|
"""Set up the time entities for one or multiple devices."""
|
||||||
entities: list[EheimDigitalTime[EheimDigitalDevice]] = []
|
entities: list[EheimDigitalTime[Any]] = []
|
||||||
for device in device_address.values():
|
for device in device_address.values():
|
||||||
if isinstance(device, EheimDigitalClassicVario):
|
if isinstance(device, EheimDigitalClassicVario):
|
||||||
entities.extend(
|
entities.extend(
|
||||||
@ -103,18 +101,18 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
|
|
||||||
@final
|
@final
|
||||||
class EheimDigitalTime(
|
class EheimDigitalTime[_DeviceT: EheimDigitalDevice](
|
||||||
EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co]
|
EheimDigitalEntity[_DeviceT], TimeEntity
|
||||||
):
|
):
|
||||||
"""Represent an EHEIM Digital time entity."""
|
"""Represent an EHEIM Digital time entity."""
|
||||||
|
|
||||||
entity_description: EheimDigitalTimeDescription[_DeviceT_co]
|
entity_description: EheimDigitalTimeDescription[_DeviceT]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: EheimDigitalUpdateCoordinator,
|
coordinator: EheimDigitalUpdateCoordinator,
|
||||||
device: _DeviceT_co,
|
device: _DeviceT,
|
||||||
description: EheimDigitalTimeDescription[_DeviceT_co],
|
description: EheimDigitalTimeDescription[_DeviceT],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize an EHEIM Digital time entity."""
|
"""Initialize an EHEIM Digital time entity."""
|
||||||
super().__init__(coordinator, device)
|
super().__init__(coordinator, device)
|
||||||
|
@ -2,7 +2,13 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"pick_implementation": {
|
"pick_implementation": {
|
||||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
||||||
|
"data": {
|
||||||
|
"implementation": "[%key:common::config_flow::data::implementation%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"implementation": "[%key:common::config_flow::description::implementation%]"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
"title": "[%key:common::config_flow::title::reauth%]",
|
"title": "[%key:common::config_flow::title::reauth%]",
|
||||||
|
@ -63,6 +63,7 @@ def _set_time_service(service: ServiceCall) -> None:
|
|||||||
_async_get_elk_panel(service).set_time(dt_util.now())
|
_async_get_elk_panel(service).set_time(dt_util.now())
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
"""Create ElkM1 services."""
|
"""Create ElkM1 services."""
|
||||||
|
|
||||||
|
@ -6,7 +6,6 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from eq3btsmart import Thermostat
|
from eq3btsmart import Thermostat
|
||||||
from eq3btsmart.exceptions import Eq3Exception
|
from eq3btsmart.exceptions import Eq3Exception
|
||||||
from eq3btsmart.thermostat_config import ThermostatConfig
|
|
||||||
|
|
||||||
from homeassistant.components import bluetooth
|
from homeassistant.components import bluetooth
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@ -53,12 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
|
|||||||
f"[{eq3_config.mac_address}] Device could not be found"
|
f"[{eq3_config.mac_address}] Device could not be found"
|
||||||
)
|
)
|
||||||
|
|
||||||
thermostat = Thermostat(
|
thermostat = Thermostat(mac_address=device) # type: ignore[arg-type]
|
||||||
thermostat_config=ThermostatConfig(
|
|
||||||
mac_address=mac_address,
|
|
||||||
),
|
|
||||||
ble_device=device,
|
|
||||||
)
|
|
||||||
|
|
||||||
entry.runtime_data = Eq3ConfigEntryData(
|
entry.runtime_data = Eq3ConfigEntryData(
|
||||||
eq3_config=eq3_config, thermostat=thermostat
|
eq3_config=eq3_config, thermostat=thermostat
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from eq3btsmart.models import Status
|
from eq3btsmart.models import Status
|
||||||
|
|
||||||
@ -80,7 +79,4 @@ class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity):
|
|||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return the state of the binary sensor."""
|
"""Return the state of the binary sensor."""
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert self._thermostat.status is not None
|
|
||||||
|
|
||||||
return self.entity_description.value_func(self._thermostat.status)
|
return self.entity_description.value_func(self._thermostat.status)
|
||||||
|
@ -1,9 +1,16 @@
|
|||||||
"""Platform for eQ-3 climate entities."""
|
"""Platform for eQ-3 climate entities."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode
|
from eq3btsmart.const import (
|
||||||
|
EQ3_DEFAULT_AWAY_TEMP,
|
||||||
|
EQ3_MAX_TEMP,
|
||||||
|
EQ3_OFF_TEMP,
|
||||||
|
Eq3OperationMode,
|
||||||
|
Eq3Preset,
|
||||||
|
)
|
||||||
from eq3btsmart.exceptions import Eq3Exception
|
from eq3btsmart.exceptions import Eq3Exception
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
from homeassistant.components.climate import (
|
||||||
@ -20,9 +27,11 @@ from homeassistant.exceptions import ServiceValidationError
|
|||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
|
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from . import Eq3ConfigEntry
|
from . import Eq3ConfigEntry
|
||||||
from .const import (
|
from .const import (
|
||||||
|
DEFAULT_AWAY_HOURS,
|
||||||
EQ_TO_HA_HVAC,
|
EQ_TO_HA_HVAC,
|
||||||
HA_TO_EQ_HVAC,
|
HA_TO_EQ_HVAC,
|
||||||
CurrentTemperatureSelector,
|
CurrentTemperatureSelector,
|
||||||
@ -57,8 +66,8 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
| ClimateEntityFeature.TURN_ON
|
| ClimateEntityFeature.TURN_ON
|
||||||
)
|
)
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
_attr_min_temp = EQ3BT_OFF_TEMP
|
_attr_min_temp = EQ3_OFF_TEMP
|
||||||
_attr_max_temp = EQ3BT_MAX_TEMP
|
_attr_max_temp = EQ3_MAX_TEMP
|
||||||
_attr_precision = PRECISION_HALVES
|
_attr_precision = PRECISION_HALVES
|
||||||
_attr_hvac_modes = list(HA_TO_EQ_HVAC.keys())
|
_attr_hvac_modes = list(HA_TO_EQ_HVAC.keys())
|
||||||
_attr_preset_modes = list(Preset)
|
_attr_preset_modes = list(Preset)
|
||||||
@ -70,38 +79,21 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
_target_temperature: float | None = None
|
_target_temperature: float | None = None
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_on_updated(self) -> None:
|
def _async_on_status_updated(self, data: Any) -> None:
|
||||||
"""Handle updated data from the thermostat."""
|
|
||||||
|
|
||||||
if self._thermostat.status is not None:
|
|
||||||
self._async_on_status_updated()
|
|
||||||
|
|
||||||
if self._thermostat.device_data is not None:
|
|
||||||
self._async_on_device_updated()
|
|
||||||
|
|
||||||
super()._async_on_updated()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_on_status_updated(self) -> None:
|
|
||||||
"""Handle updated status from the thermostat."""
|
"""Handle updated status from the thermostat."""
|
||||||
|
|
||||||
if self._thermostat.status is None:
|
self._target_temperature = self._thermostat.status.target_temperature
|
||||||
return
|
|
||||||
|
|
||||||
self._target_temperature = self._thermostat.status.target_temperature.value
|
|
||||||
self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode]
|
self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode]
|
||||||
self._attr_current_temperature = self._get_current_temperature()
|
self._attr_current_temperature = self._get_current_temperature()
|
||||||
self._attr_target_temperature = self._get_target_temperature()
|
self._attr_target_temperature = self._get_target_temperature()
|
||||||
self._attr_preset_mode = self._get_current_preset_mode()
|
self._attr_preset_mode = self._get_current_preset_mode()
|
||||||
self._attr_hvac_action = self._get_current_hvac_action()
|
self._attr_hvac_action = self._get_current_hvac_action()
|
||||||
|
super()._async_on_status_updated(data)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_on_device_updated(self) -> None:
|
def _async_on_device_updated(self, data: Any) -> None:
|
||||||
"""Handle updated device data from the thermostat."""
|
"""Handle updated device data from the thermostat."""
|
||||||
|
|
||||||
if self._thermostat.device_data is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
device_registry = dr.async_get(self.hass)
|
device_registry = dr.async_get(self.hass)
|
||||||
if device := device_registry.async_get_device(
|
if device := device_registry.async_get_device(
|
||||||
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
|
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
|
||||||
@ -109,8 +101,9 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
device_registry.async_update_device(
|
device_registry.async_update_device(
|
||||||
device.id,
|
device.id,
|
||||||
sw_version=str(self._thermostat.device_data.firmware_version),
|
sw_version=str(self._thermostat.device_data.firmware_version),
|
||||||
serial_number=self._thermostat.device_data.device_serial.value,
|
serial_number=self._thermostat.device_data.device_serial,
|
||||||
)
|
)
|
||||||
|
super()._async_on_device_updated(data)
|
||||||
|
|
||||||
def _get_current_temperature(self) -> float | None:
|
def _get_current_temperature(self) -> float | None:
|
||||||
"""Return the current temperature."""
|
"""Return the current temperature."""
|
||||||
@ -119,17 +112,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
case CurrentTemperatureSelector.NOTHING:
|
case CurrentTemperatureSelector.NOTHING:
|
||||||
return None
|
return None
|
||||||
case CurrentTemperatureSelector.VALVE:
|
case CurrentTemperatureSelector.VALVE:
|
||||||
if self._thermostat.status is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return float(self._thermostat.status.valve_temperature)
|
return float(self._thermostat.status.valve_temperature)
|
||||||
case CurrentTemperatureSelector.UI:
|
case CurrentTemperatureSelector.UI:
|
||||||
return self._target_temperature
|
return self._target_temperature
|
||||||
case CurrentTemperatureSelector.DEVICE:
|
case CurrentTemperatureSelector.DEVICE:
|
||||||
if self._thermostat.status is None:
|
return float(self._thermostat.status.target_temperature)
|
||||||
return None
|
|
||||||
|
|
||||||
return float(self._thermostat.status.target_temperature.value)
|
|
||||||
case CurrentTemperatureSelector.ENTITY:
|
case CurrentTemperatureSelector.ENTITY:
|
||||||
state = self.hass.states.get(self._eq3_config.external_temp_sensor)
|
state = self.hass.states.get(self._eq3_config.external_temp_sensor)
|
||||||
if state is not None:
|
if state is not None:
|
||||||
@ -147,16 +134,12 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
case TargetTemperatureSelector.TARGET:
|
case TargetTemperatureSelector.TARGET:
|
||||||
return self._target_temperature
|
return self._target_temperature
|
||||||
case TargetTemperatureSelector.LAST_REPORTED:
|
case TargetTemperatureSelector.LAST_REPORTED:
|
||||||
if self._thermostat.status is None:
|
return float(self._thermostat.status.target_temperature)
|
||||||
return None
|
|
||||||
|
|
||||||
return float(self._thermostat.status.target_temperature.value)
|
|
||||||
|
|
||||||
def _get_current_preset_mode(self) -> str:
|
def _get_current_preset_mode(self) -> str:
|
||||||
"""Return the current preset mode."""
|
"""Return the current preset mode."""
|
||||||
|
|
||||||
if (status := self._thermostat.status) is None:
|
status = self._thermostat.status
|
||||||
return PRESET_NONE
|
|
||||||
if status.is_window_open:
|
if status.is_window_open:
|
||||||
return Preset.WINDOW_OPEN
|
return Preset.WINDOW_OPEN
|
||||||
if status.is_boost:
|
if status.is_boost:
|
||||||
@ -165,7 +148,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
return Preset.LOW_BATTERY
|
return Preset.LOW_BATTERY
|
||||||
if status.is_away:
|
if status.is_away:
|
||||||
return Preset.AWAY
|
return Preset.AWAY
|
||||||
if status.operation_mode is OperationMode.ON:
|
if status.operation_mode is Eq3OperationMode.ON:
|
||||||
return Preset.OPEN
|
return Preset.OPEN
|
||||||
if status.presets is None:
|
if status.presets is None:
|
||||||
return PRESET_NONE
|
return PRESET_NONE
|
||||||
@ -179,10 +162,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
def _get_current_hvac_action(self) -> HVACAction:
|
def _get_current_hvac_action(self) -> HVACAction:
|
||||||
"""Return the current hvac action."""
|
"""Return the current hvac action."""
|
||||||
|
|
||||||
if (
|
if self._thermostat.status.operation_mode is Eq3OperationMode.OFF:
|
||||||
self._thermostat.status is None
|
|
||||||
or self._thermostat.status.operation_mode is OperationMode.OFF
|
|
||||||
):
|
|
||||||
return HVACAction.OFF
|
return HVACAction.OFF
|
||||||
if self._thermostat.status.valve == 0:
|
if self._thermostat.status.valve == 0:
|
||||||
return HVACAction.IDLE
|
return HVACAction.IDLE
|
||||||
@ -227,7 +207,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
"""Set new target hvac mode."""
|
"""Set new target hvac mode."""
|
||||||
|
|
||||||
if hvac_mode is HVACMode.OFF:
|
if hvac_mode is HVACMode.OFF:
|
||||||
await self.async_set_temperature(temperature=EQ3BT_OFF_TEMP)
|
await self.async_set_temperature(temperature=EQ3_OFF_TEMP)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode])
|
await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode])
|
||||||
@ -241,10 +221,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
|
|||||||
case Preset.BOOST:
|
case Preset.BOOST:
|
||||||
await self._thermostat.async_set_boost(True)
|
await self._thermostat.async_set_boost(True)
|
||||||
case Preset.AWAY:
|
case Preset.AWAY:
|
||||||
await self._thermostat.async_set_away(True)
|
away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS)
|
||||||
|
await self._thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP)
|
||||||
case Preset.ECO:
|
case Preset.ECO:
|
||||||
await self._thermostat.async_set_preset(Eq3Preset.ECO)
|
await self._thermostat.async_set_preset(Eq3Preset.ECO)
|
||||||
case Preset.COMFORT:
|
case Preset.COMFORT:
|
||||||
await self._thermostat.async_set_preset(Eq3Preset.COMFORT)
|
await self._thermostat.async_set_preset(Eq3Preset.COMFORT)
|
||||||
case Preset.OPEN:
|
case Preset.OPEN:
|
||||||
await self._thermostat.async_set_mode(OperationMode.ON)
|
await self._thermostat.async_set_mode(Eq3OperationMode.ON)
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from eq3btsmart.const import OperationMode
|
from eq3btsmart.const import Eq3OperationMode
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
from homeassistant.components.climate import (
|
||||||
PRESET_AWAY,
|
PRESET_AWAY,
|
||||||
@ -34,17 +34,17 @@ ENTITY_KEY_AWAY_UNTIL = "away_until"
|
|||||||
|
|
||||||
GET_DEVICE_TIMEOUT = 5 # seconds
|
GET_DEVICE_TIMEOUT = 5 # seconds
|
||||||
|
|
||||||
EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = {
|
EQ_TO_HA_HVAC: dict[Eq3OperationMode, HVACMode] = {
|
||||||
OperationMode.OFF: HVACMode.OFF,
|
Eq3OperationMode.OFF: HVACMode.OFF,
|
||||||
OperationMode.ON: HVACMode.HEAT,
|
Eq3OperationMode.ON: HVACMode.HEAT,
|
||||||
OperationMode.AUTO: HVACMode.AUTO,
|
Eq3OperationMode.AUTO: HVACMode.AUTO,
|
||||||
OperationMode.MANUAL: HVACMode.HEAT,
|
Eq3OperationMode.MANUAL: HVACMode.HEAT,
|
||||||
}
|
}
|
||||||
|
|
||||||
HA_TO_EQ_HVAC = {
|
HA_TO_EQ_HVAC = {
|
||||||
HVACMode.OFF: OperationMode.OFF,
|
HVACMode.OFF: Eq3OperationMode.OFF,
|
||||||
HVACMode.AUTO: OperationMode.AUTO,
|
HVACMode.AUTO: Eq3OperationMode.AUTO,
|
||||||
HVACMode.HEAT: OperationMode.MANUAL,
|
HVACMode.HEAT: Eq3OperationMode.MANUAL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -81,6 +81,7 @@ class TargetTemperatureSelector(str, Enum):
|
|||||||
DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE
|
DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE
|
||||||
DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET
|
DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET
|
||||||
DEFAULT_SCAN_INTERVAL = 10 # seconds
|
DEFAULT_SCAN_INTERVAL = 10 # seconds
|
||||||
|
DEFAULT_AWAY_HOURS = 30 * 24
|
||||||
|
|
||||||
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
|
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
|
||||||
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
|
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
"""Base class for all eQ-3 entities."""
|
"""Base class for all eQ-3 entities."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from eq3btsmart import Eq3Exception
|
||||||
|
from eq3btsmart.const import Eq3Event
|
||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers.device_registry import (
|
from homeassistant.helpers.device_registry import (
|
||||||
CONNECTION_BLUETOOTH,
|
CONNECTION_BLUETOOTH,
|
||||||
@ -45,7 +50,15 @@ class Eq3Entity(Entity):
|
|||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Run when entity about to be added to hass."""
|
"""Run when entity about to be added to hass."""
|
||||||
|
|
||||||
self._thermostat.register_update_callback(self._async_on_updated)
|
self._thermostat.register_callback(
|
||||||
|
Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated
|
||||||
|
)
|
||||||
|
self._thermostat.register_callback(
|
||||||
|
Eq3Event.STATUS_RECEIVED, self._async_on_status_updated
|
||||||
|
)
|
||||||
|
self._thermostat.register_callback(
|
||||||
|
Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated
|
||||||
|
)
|
||||||
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
@ -65,10 +78,25 @@ class Eq3Entity(Entity):
|
|||||||
async def async_will_remove_from_hass(self) -> None:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
"""Run when entity will be removed from hass."""
|
"""Run when entity will be removed from hass."""
|
||||||
|
|
||||||
self._thermostat.unregister_update_callback(self._async_on_updated)
|
self._thermostat.unregister_callback(
|
||||||
|
Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated
|
||||||
|
)
|
||||||
|
self._thermostat.unregister_callback(
|
||||||
|
Eq3Event.STATUS_RECEIVED, self._async_on_status_updated
|
||||||
|
)
|
||||||
|
self._thermostat.unregister_callback(
|
||||||
|
Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated
|
||||||
|
)
|
||||||
|
|
||||||
def _async_on_updated(self) -> None:
|
@callback
|
||||||
"""Handle updated data from the thermostat."""
|
def _async_on_status_updated(self, data: Any) -> None:
|
||||||
|
"""Handle updated status from the thermostat."""
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_on_device_updated(self, data: Any) -> None:
|
||||||
|
"""Handle updated device data from the thermostat."""
|
||||||
|
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@ -90,4 +118,9 @@ class Eq3Entity(Entity):
|
|||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Whether the entity is available."""
|
"""Whether the entity is available."""
|
||||||
|
|
||||||
return self._thermostat.status is not None and self._attr_available
|
try:
|
||||||
|
_ = self._thermostat.status
|
||||||
|
except Eq3Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._attr_available
|
||||||
|
@ -22,5 +22,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["eq3btsmart"],
|
"loggers": ["eq3btsmart"],
|
||||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.16.0"]
|
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"]
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,12 @@
|
|||||||
"""Platform for eq3 number entities."""
|
"""Platform for eq3 number entities."""
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Callable, Coroutine
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from eq3btsmart import Thermostat
|
from eq3btsmart import Thermostat
|
||||||
from eq3btsmart.const import (
|
from eq3btsmart.const import EQ3_MAX_OFFSET, EQ3_MAX_TEMP, EQ3_MIN_OFFSET, EQ3_MIN_TEMP
|
||||||
EQ3BT_MAX_OFFSET,
|
from eq3btsmart.models import Presets, Status
|
||||||
EQ3BT_MAX_TEMP,
|
|
||||||
EQ3BT_MIN_OFFSET,
|
|
||||||
EQ3BT_MIN_TEMP,
|
|
||||||
)
|
|
||||||
from eq3btsmart.models import Presets
|
|
||||||
|
|
||||||
from homeassistant.components.number import (
|
from homeassistant.components.number import (
|
||||||
NumberDeviceClass,
|
NumberDeviceClass,
|
||||||
@ -42,7 +37,7 @@ class Eq3NumberEntityDescription(NumberEntityDescription):
|
|||||||
value_func: Callable[[Presets], float]
|
value_func: Callable[[Presets], float]
|
||||||
value_set_func: Callable[
|
value_set_func: Callable[
|
||||||
[Thermostat],
|
[Thermostat],
|
||||||
Callable[[float], Awaitable[None]],
|
Callable[[float], Coroutine[None, None, Status]],
|
||||||
]
|
]
|
||||||
mode: NumberMode = NumberMode.BOX
|
mode: NumberMode = NumberMode.BOX
|
||||||
entity_category: EntityCategory | None = EntityCategory.CONFIG
|
entity_category: EntityCategory | None = EntityCategory.CONFIG
|
||||||
@ -51,44 +46,44 @@ class Eq3NumberEntityDescription(NumberEntityDescription):
|
|||||||
NUMBER_ENTITY_DESCRIPTIONS = [
|
NUMBER_ENTITY_DESCRIPTIONS = [
|
||||||
Eq3NumberEntityDescription(
|
Eq3NumberEntityDescription(
|
||||||
key=ENTITY_KEY_COMFORT,
|
key=ENTITY_KEY_COMFORT,
|
||||||
value_func=lambda presets: presets.comfort_temperature.value,
|
value_func=lambda presets: presets.comfort_temperature,
|
||||||
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
|
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
|
||||||
translation_key=ENTITY_KEY_COMFORT,
|
translation_key=ENTITY_KEY_COMFORT,
|
||||||
native_min_value=EQ3BT_MIN_TEMP,
|
native_min_value=EQ3_MIN_TEMP,
|
||||||
native_max_value=EQ3BT_MAX_TEMP,
|
native_max_value=EQ3_MAX_TEMP,
|
||||||
native_step=EQ3BT_STEP,
|
native_step=EQ3BT_STEP,
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=NumberDeviceClass.TEMPERATURE,
|
device_class=NumberDeviceClass.TEMPERATURE,
|
||||||
),
|
),
|
||||||
Eq3NumberEntityDescription(
|
Eq3NumberEntityDescription(
|
||||||
key=ENTITY_KEY_ECO,
|
key=ENTITY_KEY_ECO,
|
||||||
value_func=lambda presets: presets.eco_temperature.value,
|
value_func=lambda presets: presets.eco_temperature,
|
||||||
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
|
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
|
||||||
translation_key=ENTITY_KEY_ECO,
|
translation_key=ENTITY_KEY_ECO,
|
||||||
native_min_value=EQ3BT_MIN_TEMP,
|
native_min_value=EQ3_MIN_TEMP,
|
||||||
native_max_value=EQ3BT_MAX_TEMP,
|
native_max_value=EQ3_MAX_TEMP,
|
||||||
native_step=EQ3BT_STEP,
|
native_step=EQ3BT_STEP,
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=NumberDeviceClass.TEMPERATURE,
|
device_class=NumberDeviceClass.TEMPERATURE,
|
||||||
),
|
),
|
||||||
Eq3NumberEntityDescription(
|
Eq3NumberEntityDescription(
|
||||||
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
|
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
|
||||||
value_func=lambda presets: presets.window_open_temperature.value,
|
value_func=lambda presets: presets.window_open_temperature,
|
||||||
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
|
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
|
||||||
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
|
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
|
||||||
native_min_value=EQ3BT_MIN_TEMP,
|
native_min_value=EQ3_MIN_TEMP,
|
||||||
native_max_value=EQ3BT_MAX_TEMP,
|
native_max_value=EQ3_MAX_TEMP,
|
||||||
native_step=EQ3BT_STEP,
|
native_step=EQ3BT_STEP,
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=NumberDeviceClass.TEMPERATURE,
|
device_class=NumberDeviceClass.TEMPERATURE,
|
||||||
),
|
),
|
||||||
Eq3NumberEntityDescription(
|
Eq3NumberEntityDescription(
|
||||||
key=ENTITY_KEY_OFFSET,
|
key=ENTITY_KEY_OFFSET,
|
||||||
value_func=lambda presets: presets.offset_temperature.value,
|
value_func=lambda presets: presets.offset_temperature,
|
||||||
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
|
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
|
||||||
translation_key=ENTITY_KEY_OFFSET,
|
translation_key=ENTITY_KEY_OFFSET,
|
||||||
native_min_value=EQ3BT_MIN_OFFSET,
|
native_min_value=EQ3_MIN_OFFSET,
|
||||||
native_max_value=EQ3BT_MAX_OFFSET,
|
native_max_value=EQ3_MAX_OFFSET,
|
||||||
native_step=EQ3BT_STEP,
|
native_step=EQ3BT_STEP,
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
device_class=NumberDeviceClass.TEMPERATURE,
|
device_class=NumberDeviceClass.TEMPERATURE,
|
||||||
@ -96,7 +91,7 @@ NUMBER_ENTITY_DESCRIPTIONS = [
|
|||||||
Eq3NumberEntityDescription(
|
Eq3NumberEntityDescription(
|
||||||
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
|
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
|
||||||
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
|
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
|
||||||
value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60,
|
value_func=lambda presets: presets.window_open_time.total_seconds() / 60,
|
||||||
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
|
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
|
||||||
native_min_value=0,
|
native_min_value=0,
|
||||||
native_max_value=60,
|
native_max_value=60,
|
||||||
@ -137,7 +132,6 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity):
|
|||||||
"""Return the state of the entity."""
|
"""Return the state of the entity."""
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert self._thermostat.status is not None
|
|
||||||
assert self._thermostat.status.presets is not None
|
assert self._thermostat.status.presets is not None
|
||||||
|
|
||||||
return self.entity_description.value_func(self._thermostat.status.presets)
|
return self.entity_description.value_func(self._thermostat.status.presets)
|
||||||
@ -152,7 +146,7 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity):
|
|||||||
"""Return whether the entity is available."""
|
"""Return whether the entity is available."""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
self._thermostat.status is not None
|
super().available
|
||||||
and self._thermostat.status.presets is not None
|
and self._thermostat.status.presets is not None
|
||||||
and self._attr_available
|
and self._attr_available
|
||||||
)
|
)
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
"""Voluptuous schemas for eq3btsmart."""
|
"""Voluptuous schemas for eq3btsmart."""
|
||||||
|
|
||||||
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_MIN_TEMP
|
from eq3btsmart.const import EQ3_MAX_TEMP, EQ3_MIN_TEMP
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import CONF_MAC
|
from homeassistant.const import CONF_MAC
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
|
||||||
SCHEMA_TEMPERATURE = vol.Range(min=EQ3BT_MIN_TEMP, max=EQ3BT_MAX_TEMP)
|
SCHEMA_TEMPERATURE = vol.Range(min=EQ3_MIN_TEMP, max=EQ3_MAX_TEMP)
|
||||||
SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string})
|
SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string})
|
||||||
SCHEMA_MAC = vol.Schema(
|
SCHEMA_MAC = vol.Schema(
|
||||||
{
|
{
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from eq3btsmart.models import Status
|
from eq3btsmart.models import Status
|
||||||
|
|
||||||
@ -40,9 +39,7 @@ SENSOR_ENTITY_DESCRIPTIONS = [
|
|||||||
Eq3SensorEntityDescription(
|
Eq3SensorEntityDescription(
|
||||||
key=ENTITY_KEY_AWAY_UNTIL,
|
key=ENTITY_KEY_AWAY_UNTIL,
|
||||||
translation_key=ENTITY_KEY_AWAY_UNTIL,
|
translation_key=ENTITY_KEY_AWAY_UNTIL,
|
||||||
value_func=lambda status: (
|
value_func=lambda status: (status.away_until if status.away_until else None),
|
||||||
status.away_until.value if status.away_until else None
|
|
||||||
),
|
|
||||||
device_class=SensorDeviceClass.DATE,
|
device_class=SensorDeviceClass.DATE,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -78,7 +75,4 @@ class Eq3SensorEntity(Eq3Entity, SensorEntity):
|
|||||||
def native_value(self) -> int | datetime | None:
|
def native_value(self) -> int | datetime | None:
|
||||||
"""Return the value reported by the sensor."""
|
"""Return the value reported by the sensor."""
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert self._thermostat.status is not None
|
|
||||||
|
|
||||||
return self.entity_description.value_func(self._thermostat.status)
|
return self.entity_description.value_func(self._thermostat.status)
|
||||||
|
@ -1,26 +1,45 @@
|
|||||||
"""Platform for eq3 switch entities."""
|
"""Platform for eq3 switch entities."""
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Callable, Coroutine
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any
|
from datetime import timedelta
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from eq3btsmart import Thermostat
|
from eq3btsmart import Thermostat
|
||||||
|
from eq3btsmart.const import EQ3_DEFAULT_AWAY_TEMP, Eq3OperationMode
|
||||||
from eq3btsmart.models import Status
|
from eq3btsmart.models import Status
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from . import Eq3ConfigEntry
|
from . import Eq3ConfigEntry
|
||||||
from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK
|
from .const import (
|
||||||
|
DEFAULT_AWAY_HOURS,
|
||||||
|
ENTITY_KEY_AWAY,
|
||||||
|
ENTITY_KEY_BOOST,
|
||||||
|
ENTITY_KEY_LOCK,
|
||||||
|
)
|
||||||
from .entity import Eq3Entity
|
from .entity import Eq3Entity
|
||||||
|
|
||||||
|
|
||||||
|
async def async_set_away(thermostat: Thermostat, enable: bool) -> Status:
|
||||||
|
"""Backport old async_set_away behavior."""
|
||||||
|
|
||||||
|
if not enable:
|
||||||
|
return await thermostat.async_set_mode(Eq3OperationMode.AUTO)
|
||||||
|
|
||||||
|
away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS)
|
||||||
|
return await thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class Eq3SwitchEntityDescription(SwitchEntityDescription):
|
class Eq3SwitchEntityDescription(SwitchEntityDescription):
|
||||||
"""Entity description for eq3 switch entities."""
|
"""Entity description for eq3 switch entities."""
|
||||||
|
|
||||||
toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]]
|
toggle_func: Callable[[Thermostat], Callable[[bool], Coroutine[None, None, Status]]]
|
||||||
value_func: Callable[[Status], bool]
|
value_func: Callable[[Status], bool]
|
||||||
|
|
||||||
|
|
||||||
@ -40,7 +59,7 @@ SWITCH_ENTITY_DESCRIPTIONS = [
|
|||||||
Eq3SwitchEntityDescription(
|
Eq3SwitchEntityDescription(
|
||||||
key=ENTITY_KEY_AWAY,
|
key=ENTITY_KEY_AWAY,
|
||||||
translation_key=ENTITY_KEY_AWAY,
|
translation_key=ENTITY_KEY_AWAY,
|
||||||
toggle_func=lambda thermostat: thermostat.async_set_away,
|
toggle_func=lambda thermostat: partial(async_set_away, thermostat),
|
||||||
value_func=lambda status: status.is_away,
|
value_func=lambda status: status.is_away,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -88,7 +107,4 @@ class Eq3SwitchEntity(Eq3Entity, SwitchEntity):
|
|||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return the state of the switch."""
|
"""Return the state of the switch."""
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
assert self._thermostat.status is not None
|
|
||||||
|
|
||||||
return self.entity_description.value_func(self._thermostat.status)
|
return self.entity_description.value_func(self._thermostat.status)
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user