Compare commits

..

1 Commits

Author SHA1 Message Date
mib1185 d8df89a34e add support for discovery by add-ons 2025-06-16 21:44:28 +00:00
100 changed files with 1019 additions and 1757 deletions
@@ -1,374 +0,0 @@
name: Auto-detect duplicate issues
# yamllint disable-line rule:truthy
on:
issues:
types: [labeled]
permissions:
issues: write
models: read
jobs:
detect-duplicates:
runs-on: ubuntu-latest
steps:
- name: Check if integration label was added and extract details
id: extract
uses: actions/github-script@v7.0.1
with:
script: |
// Debug: Log the event payload
console.log('Event name:', context.eventName);
console.log('Event action:', context.payload.action);
console.log('Event payload keys:', Object.keys(context.payload));
// Check the specific label that was added
const addedLabel = context.payload.label;
if (!addedLabel) {
console.log('No label found in labeled event payload');
core.setOutput('should_continue', 'false');
return;
}
console.log(`Label added: ${addedLabel.name}`);
if (!addedLabel.name.startsWith('integration:')) {
console.log('Added label is not an integration label, skipping duplicate detection');
core.setOutput('should_continue', 'false');
return;
}
console.log(`Integration label added: ${addedLabel.name}`);
let currentIssue;
let integrationLabels = [];
try {
const issue = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number
});
currentIssue = issue.data;
// Check if potential-duplicate label already exists
const hasPotentialDuplicateLabel = currentIssue.labels
.some(label => label.name === 'potential-duplicate');
if (hasPotentialDuplicateLabel) {
console.log('Issue already has potential-duplicate label, skipping duplicate detection');
core.setOutput('should_continue', 'false');
return;
}
integrationLabels = currentIssue.labels
.filter(label => label.name.startsWith('integration:'))
.map(label => label.name);
} catch (error) {
core.error(`Failed to fetch issue #${context.payload.issue.number}:`, error.message);
core.setOutput('should_continue', 'false');
return;
}
// Check if we've already posted a duplicate detection comment recently
let comments;
try {
comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
per_page: 10
});
} catch (error) {
core.error('Failed to fetch comments:', error.message);
// Continue anyway, worst case we might post a duplicate comment
comments = { data: [] };
}
// Check if we've already posted a duplicate detection comment
const recentDuplicateComment = comments.data.find(comment =>
comment.user && comment.user.login === 'github-actions[bot]' &&
comment.body.includes('<!-- workflow: detect-duplicate-issues -->')
);
if (recentDuplicateComment) {
console.log('Already posted duplicate detection comment, skipping');
core.setOutput('should_continue', 'false');
return;
}
core.setOutput('should_continue', 'true');
core.setOutput('current_number', currentIssue.number);
core.setOutput('current_title', currentIssue.title);
core.setOutput('current_body', currentIssue.body);
core.setOutput('current_url', currentIssue.html_url);
core.setOutput('integration_labels', JSON.stringify(integrationLabels));
console.log(`Current issue: #${currentIssue.number}`);
console.log(`Integration labels: ${integrationLabels.join(', ')}`);
- name: Fetch similar issues
id: fetch_similar
if: steps.extract.outputs.should_continue == 'true'
uses: actions/github-script@v7.0.1
env:
INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }}
CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }}
with:
script: |
const integrationLabels = JSON.parse(process.env.INTEGRATION_LABELS);
const currentNumber = parseInt(process.env.CURRENT_NUMBER);
if (integrationLabels.length === 0) {
console.log('No integration labels found, skipping duplicate detection');
core.setOutput('has_similar', 'false');
return;
}
// Use GitHub search API to find issues with matching integration labels
console.log(`Searching for issues with integration labels: ${integrationLabels.join(', ')}`);
// Build search query for issues with any of the current integration labels
const labelQueries = integrationLabels.map(label => `label:"${label}"`);
let searchQuery;
if (labelQueries.length === 1) {
searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue ${labelQueries[0]}`;
} else {
searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue (${labelQueries.join(' OR ')})`;
}
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-mini
system-prompt: |
You are a Home Assistant issue duplicate detector. Your task is to identify potential duplicate issues based on their content.
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
- Higher comment count often indicates community engagement and importance
- Older closed issues might be resolved differently than newer approaches
- Consider the time between issues - very old issues may have different contexts
Rules:
1. Compare the current issue with the provided similar issues
2. Look for issues that report the same problem or request the same functionality
3. Consider different wording but same underlying issue as duplicates
4. For CLOSED issues, only mark as duplicate if they describe the EXACT same problem
5. For OPEN issues, use a lower threshold (70%+ similarity)
6. Prioritize issues with higher comment counts as they indicate more activity/relevance
7. Return ONLY a JSON array of issue numbers that are potential duplicates
8. If no duplicates are found, return an empty array: []
9. Maximum 5 potential duplicates, prioritize open issues with comments
10. 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 }}
Similar 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 are potential duplicates of the current issue. Consider their state (open/closed), how recently they were updated, and their comment count (higher = more relevant).
max-tokens: 100
- name: Post duplicate detection results
id: post_results
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/github-script@v7.0.1
env:
AI_RESPONSE: ${{ steps.ai_detection.outputs.response }}
SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }}
with:
script: |
const aiResponse = process.env.AI_RESPONSE;
console.log('Raw AI response:', JSON.stringify(aiResponse));
let duplicateNumbers = [];
try {
// Clean the response of any potential control characters
const cleanResponse = aiResponse.trim().replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
console.log('Cleaned AI response:', cleanResponse);
duplicateNumbers = JSON.parse(cleanResponse);
// Ensure it's an array and contains only numbers
if (!Array.isArray(duplicateNumbers)) {
console.log('AI response is not an array, trying to extract numbers');
const numberMatches = cleanResponse.match(/\d+/g);
duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
}
// Filter to only valid numbers
duplicateNumbers = duplicateNumbers.filter(n => typeof n === 'number' && !isNaN(n));
} catch (error) {
console.log('Failed to parse AI response as JSON:', error.message);
console.log('Raw response:', aiResponse);
// Fallback: try to extract numbers from the response
const numberMatches = aiResponse.match(/\d+/g);
duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
console.log('Extracted numbers as fallback:', duplicateNumbers);
}
if (!Array.isArray(duplicateNumbers) || duplicateNumbers.length === 0) {
console.log('No duplicates detected by AI');
return;
}
console.log(`AI detected ${duplicateNumbers.length} potential duplicates: ${duplicateNumbers.join(', ')}`);
// Get details of detected duplicates
const similarIssues = JSON.parse(process.env.SIMILAR_ISSUES);
const duplicates = similarIssues.filter(issue => duplicateNumbers.includes(issue.number));
if (duplicates.length === 0) {
console.log('No matching issues found for detected numbers');
return;
}
// Create comment with duplicate detection results
const duplicateLinks = duplicates.map(issue => `- [#${issue.number}: ${issue.title}](${issue.url})`).join('\n');
const commentBody = [
'<!-- workflow: detect-duplicate-issues -->',
'### 🔍 **Potential duplicate detection**',
'',
'I\'ve analyzed similar issues and found the following potential duplicates:',
'',
duplicateLinks,
'',
'**What to do next:**',
'1. Please review these issues to see if they match your issue',
'2. If you find an existing issue that covers your problem:',
' - Consider closing this issue',
' - Add your findings or 👍 on the existing issue instead',
'3. If your issue is different or adds new aspects, please clarify how it differs',
'',
'This helps keep our issues organized and ensures similar issues are consolidated for better visibility.',
'',
'*This message was generated automatically by our duplicate detection system.*'
].join('\n');
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: commentBody
});
console.log(`Posted duplicate detection comment with ${duplicates.length} potential duplicates`);
// Add the potential-duplicate label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: ['potential-duplicate']
});
console.log('Added potential-duplicate label to the issue');
} catch (error) {
core.error('Failed to post duplicate detection comment or add label:', error.message);
if (error.status === 403) {
core.error('Permission denied or rate limit exceeded');
}
// Don't throw - we've done the analysis, just couldn't post the result
}
@@ -1,184 +0,0 @@
name: Auto-detect non-English issues
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
permissions:
issues: write
models: read
jobs:
detect-language:
runs-on: ubuntu-latest
steps:
- name: Check issue language
id: detect_language
uses: actions/github-script@v7.0.1
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_USER_TYPE: ${{ github.event.issue.user.type }}
with:
script: |
// Get the issue details from environment variables
const issueNumber = process.env.ISSUE_NUMBER;
const issueTitle = process.env.ISSUE_TITLE || '';
const issueBody = process.env.ISSUE_BODY || '';
const userType = process.env.ISSUE_USER_TYPE;
// Skip language detection for bot users
if (userType === 'Bot') {
console.log('Skipping language detection for bot user');
core.setOutput('should_continue', 'false');
return;
}
console.log(`Checking language for issue #${issueNumber}`);
console.log(`Title: ${issueTitle}`);
// Combine title and body for language detection
const fullText = `${issueTitle}\n\n${issueBody}`;
// Check if the text is too short to reliably detect language
if (fullText.trim().length < 20) {
console.log('Text too short for reliable language detection');
core.setOutput('should_continue', 'false'); // Skip processing for very short text
return;
}
core.setOutput('issue_number', issueNumber);
core.setOutput('issue_text', fullText);
core.setOutput('should_continue', 'true');
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@v1.1.0
with:
model: openai/gpt-4o-mini
system-prompt: |
You are a language detection system. Your task is to determine if the provided text is written in English or another language.
Rules:
1. Analyze the text and determine the primary language
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. Consider technical terms, code snippets, and URLs as neutral (they don't indicate non-English)
5. Focus on the actual sentences and descriptions written by the user
6. Return ONLY a JSON object with two fields:
- "is_english": boolean (true if the text is primarily in English, false otherwise)
- "detected_language": string (the name of the detected language, e.g., "English", "Spanish", "Chinese", etc.)
7. Be lenient - if the text is mostly English with minor non-English elements, consider it English
8. Common programming terms, error messages, and technical jargon should not be considered as non-English
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;
}
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 -1
View File
@@ -65,8 +65,8 @@ homeassistant.components.aladdin_connect.*
homeassistant.components.alarm_control_panel.*
homeassistant.components.alert.*
homeassistant.components.alexa.*
homeassistant.components.alexa_devices.*
homeassistant.components.alpha_vantage.*
homeassistant.components.amazon_devices.*
homeassistant.components.amazon_polly.*
homeassistant.components.amberelectric.*
homeassistant.components.ambient_network.*
Generated
+2 -2
View File
@@ -89,8 +89,8 @@ build.json @home-assistant/supervisor
/tests/components/alert/ @home-assistant/core @frenck
/homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh
/homeassistant/components/alexa_devices/ @chemelli74
/tests/components/alexa_devices/ @chemelli74
/homeassistant/components/amazon_devices/ @chemelli74
/tests/components/amazon_devices/ @chemelli74
/homeassistant/components/amazon_polly/ @jschlyter
/homeassistant/components/amberelectric/ @madpilot
/tests/components/amberelectric/ @madpilot
+29
View File
@@ -0,0 +1,29 @@
"""Enum backports from standard lib.
This file contained the backport of the StrEnum of Python 3.11.
Since we have dropped support for Python 3.10, we can remove this backport.
This file is kept for now to avoid breaking custom components that might
import it.
"""
from __future__ import annotations
from enum import StrEnum as _StrEnum
from functools import partial
from homeassistant.helpers.deprecation import (
DeprecatedAlias,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
# StrEnum deprecated as of 2024.5 use enum.StrEnum instead.
_DEPRECATED_StrEnum = DeprecatedAlias(_StrEnum, "enum.StrEnum", "2025.5")
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())
+31
View File
@@ -0,0 +1,31 @@
"""Functools backports from standard lib.
This file contained the backport of the cached_property implementation of Python 3.12.
Since we have dropped support for Python 3.11, we can remove this backport.
This file is kept for now to avoid breaking custom components that might
import it.
"""
from __future__ import annotations
# pylint: disable-next=hass-deprecated-import
from functools import cached_property as _cached_property, partial
from homeassistant.helpers.deprecation import (
DeprecatedAlias,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
# cached_property deprecated as of 2024.5 use functools.cached_property instead.
_DEPRECATED_cached_property = DeprecatedAlias(
_cached_property, "functools.cached_property", "2025.5"
)
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "Amazon",
"integrations": [
"alexa",
"alexa_devices",
"amazon_devices",
"amazon_polly",
"aws",
"aws_s3",
@@ -37,35 +37,30 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="radonShortTermAvg",
native_unit_of_measurement="Bq/m³",
translation_key="radon",
suggested_display_precision=0,
),
"temp": SensorEntityDescription(
key="temp",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"humidity": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"pressure": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"sla": SensorEntityDescription(
key="sla",
device_class=SensorDeviceClass.SOUND_PRESSURE,
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"battery": SensorEntityDescription(
key="battery",
@@ -73,47 +68,40 @@ SENSORS: dict[str, SensorEntityDescription] = {
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"co2": SensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"voc": SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"light": SensorEntityDescription(
key="light",
native_unit_of_measurement=PERCENTAGE,
translation_key="light",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"lux": SensorEntityDescription(
key="lux",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"virusRisk": SensorEntityDescription(
key="virusRisk",
translation_key="virus_risk",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"mold": SensorEntityDescription(
key="mold",
translation_key="mold",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"rssi": SensorEntityDescription(
key="rssi",
@@ -122,21 +110,18 @@ SENSORS: dict[str, SensorEntityDescription] = {
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"pm1": SensorEntityDescription(
key="pm1",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM1,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"pm25": SensorEntityDescription(
key="pm25",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
}
@@ -1,4 +1,4 @@
"""Alexa Devices integration."""
"""Amazon Devices integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -13,7 +13,7 @@ PLATFORMS = [
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Set up Alexa Devices platform."""
"""Set up Amazon Devices platform."""
coordinator = AmazonDevicesCoordinator(hass, entry)
@@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Alexa Devices binary sensor entity description."""
"""Amazon Devices binary sensor entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
@@ -52,7 +52,7 @@ async def async_setup_entry(
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices binary sensors based on a config entry."""
"""Set up Amazon Devices binary sensors based on a config entry."""
coordinator = entry.runtime_data
@@ -1,4 +1,4 @@
"""Config flow for Alexa Devices integration."""
"""Config flow for Amazon Devices integration."""
from __future__ import annotations
@@ -17,7 +17,7 @@ from .const import CONF_LOGIN_DATA, DOMAIN
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Alexa Devices."""
"""Handle a config flow for Amazon Devices."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -1,8 +1,8 @@
"""Alexa Devices constants."""
"""Amazon Devices constants."""
import logging
_LOGGER = logging.getLogger(__package__)
DOMAIN = "alexa_devices"
DOMAIN = "amazon_devices"
CONF_LOGIN_DATA = "login_data"
@@ -1,4 +1,4 @@
"""Support for Alexa Devices."""
"""Support for Amazon Devices."""
from datetime import timedelta
@@ -23,7 +23,7 @@ type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
"""Base coordinator for Alexa Devices."""
"""Base coordinator for Amazon Devices."""
config_entry: AmazonConfigEntry
@@ -1,4 +1,4 @@
"""Diagnostics support for Alexa Devices integration."""
"""Diagnostics support for Amazon Devices integration."""
from __future__ import annotations
@@ -1,4 +1,4 @@
"""Defines a base Alexa Devices entity."""
"""Defines a base Amazon Devices entity."""
from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SPEAKER_GROUP_MODEL
@@ -12,7 +12,7 @@ from .coordinator import AmazonDevicesCoordinator
class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
"""Defines a base Alexa Devices entity."""
"""Defines a base Amazon Devices entity."""
_attr_has_entity_name = True
@@ -1,9 +1,9 @@
{
"domain": "alexa_devices",
"name": "Alexa Devices",
"domain": "amazon_devices",
"name": "Amazon Devices",
"codeowners": ["@chemelli74"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/alexa_devices",
"documentation": "https://www.home-assistant.io/integrations/amazon_devices",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
@@ -20,7 +20,7 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class AmazonNotifyEntityDescription(NotifyEntityDescription):
"""Alexa Devices notify entity description."""
"""Amazon Devices notify entity description."""
method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]]
subkey: str
@@ -49,7 +49,7 @@ async def async_setup_entry(
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices notification entity based on a config entry."""
"""Set up Amazon Devices notification entity based on a config entry."""
coordinator = entry.runtime_data
@@ -12,16 +12,16 @@
"step": {
"user": {
"data": {
"country": "[%key:component::alexa_devices::common::data_country%]",
"country": "[%key:component::amazon_devices::common::data_country%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
"code": "[%key:component::amazon_devices::common::data_description_code%]"
},
"data_description": {
"country": "[%key:component::alexa_devices::common::data_description_country%]",
"username": "[%key:component::alexa_devices::common::data_description_username%]",
"password": "[%key:component::alexa_devices::common::data_description_password%]",
"code": "[%key:component::alexa_devices::common::data_description_code%]"
"country": "[%key:component::amazon_devices::common::data_description_country%]",
"username": "[%key:component::amazon_devices::common::data_description_username%]",
"password": "[%key:component::amazon_devices::common::data_description_password%]",
"code": "[%key:component::amazon_devices::common::data_description_code%]"
}
}
},
@@ -20,7 +20,7 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class AmazonSwitchEntityDescription(SwitchEntityDescription):
"""Alexa Devices switch entity description."""
"""Amazon Devices switch entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
subkey: str
@@ -43,7 +43,7 @@ async def async_setup_entry(
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices switches based on a config entry."""
"""Set up Amazon Devices switches based on a config entry."""
coordinator = entry.runtime_data
+2 -2
View File
@@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
from .coordinator import BlinkConfigEntry, BlinkUpdateCoordinator
from .services import async_setup_services
from .services import setup_services
_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:
"""Set up Blink."""
async_setup_services(hass)
setup_services(hass)
return True
+25 -27
View File
@@ -6,7 +6,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PIN
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
@@ -21,36 +21,34 @@ SERVICE_SEND_PIN_SCHEMA = vol.Schema(
)
async def _send_pin(call: ServiceCall) -> None:
"""Call blink to send new pin."""
config_entry: BlinkConfigEntry | None
for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]:
if not (config_entry := call.hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
if config_entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": config_entry.title},
)
coordinator = config_entry.runtime_data
await coordinator.api.auth.send_auth_key(
coordinator.api,
call.data[CONF_PIN],
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
def setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Blink integration."""
async def send_pin(call: ServiceCall):
"""Call blink to send new pin."""
config_entry: BlinkConfigEntry | None
for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]:
if not (config_entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
if config_entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": config_entry.title},
)
coordinator = config_entry.runtime_data
await coordinator.api.auth.send_auth_key(
coordinator.api,
call.data[CONF_PIN],
)
hass.services.async_register(
DOMAIN,
SERVICE_SEND_PIN,
_send_pin,
send_pin,
schema=SERVICE_SEND_PIN_SCHEMA,
)
@@ -9,11 +9,12 @@ from typing import Any
from homeassistant.components.notify import BaseNotificationService
from homeassistant.const import CONF_COMMAND
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.util.process import kill_subprocess
from .const import CONF_COMMAND_TIMEOUT, LOGGER
from .utils import render_template_args
_LOGGER = logging.getLogger(__name__)
@@ -44,10 +45,28 @@ class CommandLineNotificationService(BaseNotificationService):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a command line."""
if not (command := render_template_args(self.hass, self.command)):
return
command = self.command
if " " not in command:
prog = command
args = None
args_compiled = None
else:
prog, args = command.split(" ", 1)
args_compiled = Template(args, self.hass)
LOGGER.debug("Running with message: %s", message)
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
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
command,
@@ -19,6 +19,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.template import Template
@@ -36,7 +37,7 @@ from .const import (
LOGGER,
TRIGGER_ENTITY_OPTIONS,
)
from .utils import async_check_output_or_log, render_template_args
from .utils import async_check_output_or_log
DEFAULT_NAME = "Command Sensor"
@@ -221,6 +222,32 @@ class CommandSensorData:
async def async_update(self) -> None:
"""Get the latest data with a shell command."""
if not (command := render_template_args(self.hass, self.command)):
return
command = self.command
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)
+8 -39
View File
@@ -3,13 +3,9 @@
from __future__ import annotations
import asyncio
import logging
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.template import Template
from .const import LOGGER
_LOGGER = logging.getLogger(__name__)
_EXEC_FAILED_CODE = 127
@@ -22,7 +18,7 @@ async def async_call_shell_with_timeout(
return code is returned.
"""
try:
LOGGER.debug("Running command: %s", command)
_LOGGER.debug("Running command: %s", command)
proc = await asyncio.create_subprocess_shell( # shell by design
command,
close_fds=False, # required for posix_spawn
@@ -30,14 +26,14 @@ async def async_call_shell_with_timeout(
async with asyncio.timeout(timeout):
await proc.communicate()
except TimeoutError:
LOGGER.error("Timeout for command: %s", command)
_LOGGER.error("Timeout for command: %s", command)
return -1
return_code = proc.returncode
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:
LOGGER.error(
_LOGGER.error(
"Command failed (with return code %s): %s",
proc.returncode,
command,
@@ -57,39 +53,12 @@ async def async_check_output_or_log(command: str, timeout: int) -> str | None:
stdout, _ = await proc.communicate()
if proc.returncode != 0:
LOGGER.error(
_LOGGER.error(
"Command failed (with return code %s): %s", proc.returncode, command
)
else:
return stdout.strip().decode("utf-8")
except TimeoutError:
LOGGER.error("Timeout for command: %s", command)
_LOGGER.error("Timeout for command: %s", command)
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
+32 -77
View File
@@ -28,36 +28,45 @@ async def async_setup_entry(
) -> None:
"""Set up the Fibaro covers."""
controller = entry.runtime_data
entities: list[FibaroEntity] = []
for device in controller.fibaro_devices[Platform.COVER]:
# Positionable covers report the position over value
if device.value.has_value:
entities.append(PositionableFibaroCover(device))
else:
entities.append(FibaroCover(device))
async_add_entities(entities, True)
async_add_entities(
[FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]],
True,
)
class PositionableFibaroCover(FibaroEntity, CoverEntity):
"""Representation of a fibaro cover which supports positioning."""
class FibaroCover(FibaroEntity, CoverEntity):
"""Representation a Fibaro Cover."""
def __init__(self, fibaro_device: DeviceModel) -> None:
"""Initialize the device."""
"""Initialize the Vera device."""
super().__init__(fibaro_device)
self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
if self._is_open_close_only():
self._attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
if "stop" in self.fibaro_device.actions:
self._attr_supported_features |= CoverEntityFeature.STOP
@staticmethod
def bound(position: int | None) -> int | None:
def bound(position):
"""Normalize the position."""
if position is None:
return None
position = int(position)
if position <= 5:
return 0
if position >= 95:
return 100
return position
def _is_open_close_only(self) -> bool:
"""Return if only open / close is supported."""
# Normally positionable devices report the position over value,
# so if it is missing we have a device which supports open / close only
return not self.fibaro_device.value.has_value
def update(self) -> None:
"""Update the state."""
super().update()
@@ -65,15 +74,20 @@ class PositionableFibaroCover(FibaroEntity, CoverEntity):
self._attr_current_cover_position = self.bound(self.level)
self._attr_current_cover_tilt_position = self.bound(self.level2)
device_state = self.fibaro_device.state
# Be aware that opening and closing is only available for some modern
# devices.
# For example the Fibaro Roller Shutter 4 reports this correctly.
device_state = self.fibaro_device.state.str_value(default="").lower()
self._attr_is_opening = device_state == "opening"
self._attr_is_closing = device_state == "closing"
if device_state.has_value:
self._attr_is_opening = device_state.str_value().lower() == "opening"
self._attr_is_closing = device_state.str_value().lower() == "closing"
closed: bool | None = None
if self.current_cover_position is not None:
if self._is_open_close_only():
if device_state.has_value and device_state.str_value().lower() != "unknown":
closed = device_state.str_value().lower() == "closed"
elif self.current_cover_position is not None:
closed = self.current_cover_position == 0
self._attr_is_closed = closed
@@ -82,7 +96,7 @@ class PositionableFibaroCover(FibaroEntity, CoverEntity):
self.set_level(cast(int, kwargs.get(ATTR_POSITION)))
def set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the slats to a specific position."""
"""Move the cover to a specific position."""
self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION)))
def open_cover(self, **kwargs: Any) -> None:
@@ -104,62 +118,3 @@ class PositionableFibaroCover(FibaroEntity, CoverEntity):
def stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
self.action("stop")
class FibaroCover(FibaroEntity, CoverEntity):
"""Representation of a fibaro cover which supports only open / close commands."""
def __init__(self, fibaro_device: DeviceModel) -> None:
"""Initialize the device."""
super().__init__(fibaro_device)
self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
self._attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
if "stop" in self.fibaro_device.actions:
self._attr_supported_features |= CoverEntityFeature.STOP
if "rotateSlatsUp" in self.fibaro_device.actions:
self._attr_supported_features |= CoverEntityFeature.OPEN_TILT
if "rotateSlatsDown" in self.fibaro_device.actions:
self._attr_supported_features |= CoverEntityFeature.CLOSE_TILT
if "stopSlats" in self.fibaro_device.actions:
self._attr_supported_features |= CoverEntityFeature.STOP_TILT
def update(self) -> None:
"""Update the state."""
super().update()
device_state = self.fibaro_device.state.str_value(default="").lower()
self._attr_is_opening = device_state == "opening"
self._attr_is_closing = device_state == "closing"
closed: bool | None = None
if device_state not in {"", "unknown"}:
closed = device_state == "closed"
self._attr_is_closed = closed
def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
self.action("open")
def close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
self.action("close")
def stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
self.action("stop")
def open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover slats."""
self.action("rotateSlatsUp")
def close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover slats."""
self.action("rotateSlatsDown")
def stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the cover slats turning."""
self.action("stopSlats")
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250531.2"]
"requirements": ["home-assistant-frontend==20250531.0"]
}
@@ -27,7 +27,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Fully Kiosk Browser."""
async_setup_services(hass)
await async_setup_services(hass)
return True
@@ -6,7 +6,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -23,73 +23,71 @@ from .const import (
from .coordinator import FullyKioskDataUpdateCoordinator
async def _collect_coordinators(
call: ServiceCall,
) -> list[FullyKioskDataUpdateCoordinator]:
device_ids: list[str] = call.data[ATTR_DEVICE_ID]
config_entries = list[ConfigEntry]()
registry = dr.async_get(call.hass)
for target in device_ids:
device = registry.async_get(target)
if device:
device_entries = list[ConfigEntry]()
for entry_id in device.config_entries:
entry = call.hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == DOMAIN:
device_entries.append(entry)
if not device_entries:
raise HomeAssistantError(f"Device '{target}' is not a {DOMAIN} device")
config_entries.extend(device_entries)
else:
raise HomeAssistantError(f"Device '{target}' not found in device registry")
coordinators = list[FullyKioskDataUpdateCoordinator]()
for config_entry in config_entries:
if config_entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(f"{config_entry.title} is not loaded")
coordinators.append(config_entry.runtime_data)
return coordinators
async def _async_load_url(call: ServiceCall) -> None:
"""Load a URL on the Fully Kiosk Browser."""
for coordinator in await _collect_coordinators(call):
await coordinator.fully.loadUrl(call.data[ATTR_URL])
async def _async_start_app(call: ServiceCall) -> None:
"""Start an app on the device."""
for coordinator in await _collect_coordinators(call):
await coordinator.fully.startApplication(call.data[ATTR_APPLICATION])
async def _async_set_config(call: ServiceCall) -> None:
"""Set a Fully Kiosk Browser config value on the device."""
for coordinator in await _collect_coordinators(call):
key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
# Fully API has different methods for setting string and bool values.
# check if call.data[ATTR_VALUE] is a bool
if isinstance(value, bool) or (
isinstance(value, str) and value.lower() in ("true", "false")
):
await coordinator.fully.setConfigurationBool(key, value)
else:
# Convert any int values to string
if isinstance(value, int):
value = str(value)
await coordinator.fully.setConfigurationString(key, value)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Fully Kiosk Browser integration."""
async def collect_coordinators(
device_ids: list[str],
) -> list[FullyKioskDataUpdateCoordinator]:
config_entries = list[ConfigEntry]()
registry = dr.async_get(hass)
for target in device_ids:
device = registry.async_get(target)
if device:
device_entries = list[ConfigEntry]()
for entry_id in device.config_entries:
entry = hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == DOMAIN:
device_entries.append(entry)
if not device_entries:
raise HomeAssistantError(
f"Device '{target}' is not a {DOMAIN} device"
)
config_entries.extend(device_entries)
else:
raise HomeAssistantError(
f"Device '{target}' not found in device registry"
)
coordinators = list[FullyKioskDataUpdateCoordinator]()
for config_entry in config_entries:
if config_entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(f"{config_entry.title} is not loaded")
coordinators.append(config_entry.runtime_data)
return coordinators
async def async_load_url(call: ServiceCall) -> None:
"""Load a URL on the Fully Kiosk Browser."""
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]):
await coordinator.fully.loadUrl(call.data[ATTR_URL])
async def async_start_app(call: ServiceCall) -> None:
"""Start an app on the device."""
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]):
await coordinator.fully.startApplication(call.data[ATTR_APPLICATION])
async def async_set_config(call: ServiceCall) -> None:
"""Set a Fully Kiosk Browser config value on the device."""
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]):
key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
# Fully API has different methods for setting string and bool values.
# check if call.data[ATTR_VALUE] is a bool
if isinstance(value, bool) or (
isinstance(value, str) and value.lower() in ("true", "false")
):
await coordinator.fully.setConfigurationBool(key, value)
else:
# Convert any int values to string
if isinstance(value, int):
value = str(value)
await coordinator.fully.setConfigurationString(key, value)
# Register all the above services
service_mapping = [
(_async_load_url, SERVICE_LOAD_URL, ATTR_URL),
(_async_start_app, SERVICE_START_APPLICATION, ATTR_APPLICATION),
(async_load_url, SERVICE_LOAD_URL, ATTR_URL),
(async_start_app, SERVICE_START_APPLICATION, ATTR_APPLICATION),
]
for service_handler, service_name, attrib in service_mapping:
hass.services.async_register(
@@ -109,7 +107,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_SET_CONFIG,
_async_set_config,
async_set_config,
schema=vol.Schema(
vol.All(
{
@@ -27,7 +27,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google Mail integration."""
hass.data.setdefault(DOMAIN, {})[DATA_HASS_CONFIG] = config
async_setup_services(hass)
await async_setup_services(hass)
return True
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
from googleapiclient.http import HttpRequest
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_extract_config_entry_ids
@@ -46,57 +46,56 @@ SERVICE_VACATION_SCHEMA = vol.All(
)
async def _extract_gmail_config_entries(
call: ServiceCall,
) -> list[GoogleMailConfigEntry]:
return [
entry
for entry_id in await async_extract_config_entry_ids(call.hass, call)
if (entry := call.hass.config_entries.async_get_entry(entry_id))
and entry.domain == DOMAIN
]
async def _gmail_service(call: ServiceCall) -> None:
"""Call Google Mail service."""
for entry in await _extract_gmail_config_entries(call):
try:
auth = entry.runtime_data
except AttributeError as ex:
raise ValueError(f"Config entry not loaded: {entry.entry_id}") from ex
service = await auth.get_resource()
_settings = {
"enableAutoReply": call.data[ATTR_ENABLED],
"responseSubject": call.data.get(ATTR_TITLE),
}
if contacts := call.data.get(ATTR_RESTRICT_CONTACTS):
_settings["restrictToContacts"] = contacts
if domain := call.data.get(ATTR_RESTRICT_DOMAIN):
_settings["restrictToDomain"] = domain
if _date := call.data.get(ATTR_START):
_dt = datetime.combine(_date, datetime.min.time())
_settings["startTime"] = _dt.timestamp() * 1000
if _date := call.data.get(ATTR_END):
_dt = datetime.combine(_date, datetime.min.time())
_settings["endTime"] = (_dt + timedelta(days=1)).timestamp() * 1000
if call.data[ATTR_PLAIN_TEXT]:
_settings["responseBodyPlainText"] = call.data[ATTR_MESSAGE]
else:
_settings["responseBodyHtml"] = call.data[ATTR_MESSAGE]
settings: HttpRequest = (
service.users().settings().updateVacation(userId=ATTR_ME, body=_settings)
)
await call.hass.async_add_executor_job(settings.execute)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Google Mail integration."""
async def extract_gmail_config_entries(
call: ServiceCall,
) -> list[GoogleMailConfigEntry]:
return [
entry
for entry_id in await async_extract_config_entry_ids(hass, call)
if (entry := hass.config_entries.async_get_entry(entry_id))
and entry.domain == DOMAIN
]
async def gmail_service(call: ServiceCall) -> None:
"""Call Google Mail service."""
for entry in await extract_gmail_config_entries(call):
try:
auth = entry.runtime_data
except AttributeError as ex:
raise ValueError(f"Config entry not loaded: {entry.entry_id}") from ex
service = await auth.get_resource()
_settings = {
"enableAutoReply": call.data[ATTR_ENABLED],
"responseSubject": call.data.get(ATTR_TITLE),
}
if contacts := call.data.get(ATTR_RESTRICT_CONTACTS):
_settings["restrictToContacts"] = contacts
if domain := call.data.get(ATTR_RESTRICT_DOMAIN):
_settings["restrictToDomain"] = domain
if _date := call.data.get(ATTR_START):
_dt = datetime.combine(_date, datetime.min.time())
_settings["startTime"] = _dt.timestamp() * 1000
if _date := call.data.get(ATTR_END):
_dt = datetime.combine(_date, datetime.min.time())
_settings["endTime"] = (_dt + timedelta(days=1)).timestamp() * 1000
if call.data[ATTR_PLAIN_TEXT]:
_settings["responseBodyPlainText"] = call.data[ATTR_MESSAGE]
else:
_settings["responseBodyHtml"] = call.data[ATTR_MESSAGE]
settings: HttpRequest = (
service.users()
.settings()
.updateVacation(userId=ATTR_ME, body=_settings)
)
await hass.async_add_executor_job(settings.execute)
hass.services.async_register(
domain=DOMAIN,
service=SERVICE_SET_VACATION,
schema=SERVICE_VACATION_SCHEMA,
service_func=_gmail_service,
service_func=gmail_service,
)
+12 -23
View File
@@ -9,10 +9,8 @@ from functools import partial
import logging
import os
import re
import struct
from typing import Any, NamedTuple
import aiofiles
from aiohasupervisor import SupervisorError
import voluptuous as vol
@@ -58,6 +56,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.service_info.hassio import (
HassioServiceInfo as _HassioServiceInfo,
)
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.async_ import create_eager_task
@@ -234,17 +233,6 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
)
def _is_32_bit() -> bool:
size = struct.calcsize("P")
return size * 8 == 32
async def _get_arch() -> str:
async with aiofiles.open("/etc/apk/arch") as arch_file:
raw_arch = await arch_file.read()
return {"x86": "i386"}.get(raw_arch, raw_arch)
class APIEndpointSettings(NamedTuple):
"""Settings for API endpoint."""
@@ -566,7 +554,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data[ADDONS_COORDINATOR] = coordinator
arch = await _get_arch()
system_info = await async_get_system_info(hass)
def deprecated_setup_issue() -> None:
os_info = get_os_info(hass)
@@ -574,19 +562,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if os_info is None or info is None:
return
is_haos = info.get("hassos") is not None
arch = system_info["arch"]
board = os_info.get("board")
unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"}
unsupported_os_on_board = board in {"rpi3", "rpi4"}
if is_haos and (unsupported_board or unsupported_os_on_board):
supported_board = board in {"rpi3", "rpi4", "tinker", "odroid-xu4", "rpi2"}
if is_haos and arch == "armv7" and supported_board:
issue_id = "deprecated_os_"
if unsupported_os_on_board:
if board in {"rpi3", "rpi4"}:
issue_id += "aarch64"
elif unsupported_board:
elif board in {"tinker", "odroid-xu4", "rpi2"}:
issue_id += "armv7"
ir.async_create_issue(
hass,
"homeassistant",
issue_id,
breaks_in_ha_version="2025.12.0",
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
@@ -595,10 +584,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"installation_guide": "https://www.home-assistant.io/installation/",
},
)
bit32 = _is_32_bit()
deprecated_architecture = bit32 and not (
unsupported_board or unsupported_os_on_board
)
deprecated_architecture = False
if arch in {"i386", "armhf"} or (arch == "armv7" and not supported_board):
deprecated_architecture = True
if not is_haos or deprecated_architecture:
issue_id = "deprecated"
if not is_haos:
@@ -609,6 +597,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass,
"homeassistant",
issue_id,
breaks_in_ha_version="2025.12.0",
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
@@ -5,13 +5,26 @@ from __future__ import annotations
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.start import async_at_started
from homeassistant.util import dt as dt_util
from .const import TRAVEL_MODE_PUBLIC
from .const import (
CONF_ARRIVAL_TIME,
CONF_DEPARTURE_TIME,
CONF_DESTINATION_ENTITY_ID,
CONF_DESTINATION_LATITUDE,
CONF_DESTINATION_LONGITUDE,
CONF_ORIGIN_ENTITY_ID,
CONF_ORIGIN_LATITUDE,
CONF_ORIGIN_LONGITUDE,
CONF_ROUTE_MODE,
TRAVEL_MODE_PUBLIC,
)
from .coordinator import (
HereConfigEntry,
HERERoutingDataUpdateCoordinator,
HERETransitDataUpdateCoordinator,
)
from .model import HERETravelTimeConfig
PLATFORMS = [Platform.SENSOR]
@@ -20,13 +33,29 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry)
"""Set up HERE Travel Time from a config entry."""
api_key = config_entry.data[CONF_API_KEY]
arrival = dt_util.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, ""))
departure = dt_util.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, ""))
here_travel_time_config = HERETravelTimeConfig(
destination_latitude=config_entry.data.get(CONF_DESTINATION_LATITUDE),
destination_longitude=config_entry.data.get(CONF_DESTINATION_LONGITUDE),
destination_entity_id=config_entry.data.get(CONF_DESTINATION_ENTITY_ID),
origin_latitude=config_entry.data.get(CONF_ORIGIN_LATITUDE),
origin_longitude=config_entry.data.get(CONF_ORIGIN_LONGITUDE),
origin_entity_id=config_entry.data.get(CONF_ORIGIN_ENTITY_ID),
travel_mode=config_entry.data[CONF_MODE],
route_mode=config_entry.options[CONF_ROUTE_MODE],
arrival=arrival,
departure=departure,
)
cls: type[HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator]
if config_entry.data[CONF_MODE] in {TRAVEL_MODE_PUBLIC, "publicTransportTimeTable"}:
cls = HERETransitDataUpdateCoordinator
else:
cls = HERERoutingDataUpdateCoordinator
data_coordinator = cls(hass, config_entry, api_key)
data_coordinator = cls(hass, config_entry, api_key, here_travel_time_config)
config_entry.runtime_data = data_coordinator
async def _async_update_at_start(_: HomeAssistant) -> None:
@@ -26,7 +26,7 @@ from here_transit import (
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODE, UnitOfLength
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.location import find_coordinates
@@ -34,21 +34,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import DistanceConverter
from .const import (
CONF_ARRIVAL_TIME,
CONF_DEPARTURE_TIME,
CONF_DESTINATION_ENTITY_ID,
CONF_DESTINATION_LATITUDE,
CONF_DESTINATION_LONGITUDE,
CONF_ORIGIN_ENTITY_ID,
CONF_ORIGIN_LATITUDE,
CONF_ORIGIN_LONGITUDE,
CONF_ROUTE_MODE,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
ROUTE_MODE_FASTEST,
)
from .model import HERETravelTimeAPIParams, HERETravelTimeData
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST
from .model import HERETravelTimeConfig, HERETravelTimeData
BACKOFF_MULTIPLIER = 1.1
@@ -60,7 +47,7 @@ type HereConfigEntry = ConfigEntry[
class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]):
"""HERETravelTime DataUpdateCoordinator for the routing API."""
"""here_routing DataUpdateCoordinator."""
config_entry: HereConfigEntry
@@ -69,6 +56,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
hass: HomeAssistant,
config_entry: HereConfigEntry,
api_key: str,
config: HERETravelTimeConfig,
) -> None:
"""Initialize."""
super().__init__(
@@ -79,34 +67,41 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
self._api = HERERoutingApi(api_key)
self.config = config
async def _async_update_data(self) -> HERETravelTimeData:
"""Get the latest data from the HERE Routing API."""
params = prepare_parameters(self.hass, self.config_entry)
origin, destination, arrival, departure = prepare_parameters(
self.hass, self.config
)
route_mode = (
RoutingMode.FAST
if self.config.route_mode == ROUTE_MODE_FASTEST
else RoutingMode.SHORT
)
_LOGGER.debug(
(
"Requesting route for origin: %s, destination: %s, route_mode: %s,"
" mode: %s, arrival: %s, departure: %s"
),
params.origin,
params.destination,
params.route_mode,
TransportMode(params.travel_mode),
params.arrival,
params.departure,
origin,
destination,
route_mode,
TransportMode(self.config.travel_mode),
arrival,
departure,
)
try:
response = await self._api.route(
transport_mode=TransportMode(params.travel_mode),
origin=here_routing.Place(params.origin[0], params.origin[1]),
destination=here_routing.Place(
params.destination[0], params.destination[1]
),
routing_mode=params.route_mode,
arrival_time=params.arrival,
departure_time=params.departure,
transport_mode=TransportMode(self.config.travel_mode),
origin=here_routing.Place(origin[0], origin[1]),
destination=here_routing.Place(destination[0], destination[1]),
routing_mode=route_mode,
arrival_time=arrival,
departure_time=departure,
return_values=[Return.POLYINE, Return.SUMMARY],
spans=[Spans.NAMES],
)
@@ -180,7 +175,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
class HERETransitDataUpdateCoordinator(
DataUpdateCoordinator[HERETravelTimeData | None]
):
"""HERETravelTime DataUpdateCoordinator for the transit API."""
"""HERETravelTime DataUpdateCoordinator."""
config_entry: HereConfigEntry
@@ -189,6 +184,7 @@ class HERETransitDataUpdateCoordinator(
hass: HomeAssistant,
config_entry: HereConfigEntry,
api_key: str,
config: HERETravelTimeConfig,
) -> None:
"""Initialize."""
super().__init__(
@@ -199,31 +195,32 @@ class HERETransitDataUpdateCoordinator(
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
)
self._api = HERETransitApi(api_key)
self.config = config
async def _async_update_data(self) -> HERETravelTimeData | None:
"""Get the latest data from the HERE Routing API."""
params = prepare_parameters(self.hass, self.config_entry)
origin, destination, arrival, departure = prepare_parameters(
self.hass, self.config
)
_LOGGER.debug(
(
"Requesting transit route for origin: %s, destination: %s, arrival: %s,"
" departure: %s"
),
params.origin,
params.destination,
params.arrival,
params.departure,
origin,
destination,
arrival,
departure,
)
try:
response = await self._api.route(
origin=here_transit.Place(
latitude=params.origin[0], longitude=params.origin[1]
),
origin=here_transit.Place(latitude=origin[0], longitude=origin[1]),
destination=here_transit.Place(
latitude=params.destination[0], longitude=params.destination[1]
latitude=destination[0], longitude=destination[1]
),
arrival_time=params.arrival,
departure_time=params.departure,
arrival_time=arrival,
departure_time=departure,
return_values=[
here_transit.Return.POLYLINE,
here_transit.Return.TRAVEL_SUMMARY,
@@ -288,8 +285,8 @@ class HERETransitDataUpdateCoordinator(
def prepare_parameters(
hass: HomeAssistant,
config_entry: HereConfigEntry,
) -> HERETravelTimeAPIParams:
config: HERETravelTimeConfig,
) -> tuple[list[str], list[str], str | None, str | None]:
"""Prepare parameters for the HERE api."""
def _from_entity_id(entity_id: str) -> list[str]:
@@ -308,55 +305,32 @@ def prepare_parameters(
return formatted_coordinates
# Destination
if (
destination_entity_id := config_entry.data.get(CONF_DESTINATION_ENTITY_ID)
) is not None:
destination = _from_entity_id(str(destination_entity_id))
if config.destination_entity_id is not None:
destination = _from_entity_id(config.destination_entity_id)
else:
destination = [
str(config_entry.data[CONF_DESTINATION_LATITUDE]),
str(config_entry.data[CONF_DESTINATION_LONGITUDE]),
str(config.destination_latitude),
str(config.destination_longitude),
]
# Origin
if (origin_entity_id := config_entry.data.get(CONF_ORIGIN_ENTITY_ID)) is not None:
origin = _from_entity_id(str(origin_entity_id))
if config.origin_entity_id is not None:
origin = _from_entity_id(config.origin_entity_id)
else:
origin = [
str(config_entry.data[CONF_ORIGIN_LATITUDE]),
str(config_entry.data[CONF_ORIGIN_LONGITUDE]),
str(config.origin_latitude),
str(config.origin_longitude),
]
# Arrival/Departure
arrival: datetime | None = None
if (
conf_arrival := dt_util.parse_time(
config_entry.options.get(CONF_ARRIVAL_TIME, "")
)
) is not None:
arrival = next_datetime(conf_arrival)
departure: datetime | None = None
if (
conf_departure := dt_util.parse_time(
config_entry.options.get(CONF_DEPARTURE_TIME, "")
)
) is not None:
departure = next_datetime(conf_departure)
arrival: str | None = None
departure: str | None = None
if config.arrival is not None:
arrival = next_datetime(config.arrival).isoformat()
if config.departure is not None:
departure = next_datetime(config.departure).isoformat()
route_mode = (
RoutingMode.FAST
if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST
else RoutingMode.SHORT
)
return HERETravelTimeAPIParams(
destination=destination,
origin=origin,
travel_mode=config_entry.data[CONF_MODE],
route_mode=route_mode,
arrival=arrival,
departure=departure,
)
return (origin, destination, arrival, departure)
def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None:
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from datetime import time
from typing import TypedDict
@@ -21,12 +21,16 @@ class HERETravelTimeData(TypedDict):
@dataclass
class HERETravelTimeAPIParams:
"""Configuration for polling the HERE API."""
class HERETravelTimeConfig:
"""Configuration for HereTravelTimeDataUpdateCoordinator."""
destination: list[str]
origin: list[str]
destination_latitude: float | None
destination_longitude: float | None
destination_entity_id: str | None
origin_latitude: float | None
origin_longitude: float | None
origin_entity_id: str | None
travel_mode: str
route_mode: str
arrival: datetime | None
departure: datetime | None
arrival: time | None
departure: time | None
@@ -4,10 +4,8 @@ import asyncio
from collections.abc import Callable, Coroutine
import itertools as it
import logging
import struct
from typing import Any
import aiofiles
import voluptuous as vol
from homeassistant import config as conf_util, core_config
@@ -96,17 +94,6 @@ DEPRECATION_URL = (
)
def _is_32_bit() -> bool:
size = struct.calcsize("P")
return size * 8 == 32
async def _get_arch() -> str:
async with aiofiles.open("/etc/apk/arch") as arch_file:
raw_arch = (await arch_file.read()).strip()
return {"x86": "i386", "x86_64": "amd64"}.get(raw_arch, raw_arch)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
"""Set up general services related to Home Assistant."""
@@ -416,21 +403,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
installation_type = info["installation_type"][15:]
if installation_type in {"Core", "Container"}:
deprecated_method = installation_type == "Core"
bit32 = _is_32_bit()
arch = info["arch"]
if bit32 and installation_type == "Container":
arch = await _get_arch()
if arch == "armv7" and installation_type == "Container":
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_container",
"deprecated_container_armv7",
breaks_in_ha_version="2025.12.0",
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_container",
translation_placeholders={"arch": arch},
translation_key="deprecated_container_armv7",
)
deprecated_architecture = bit32 and installation_type != "Container"
deprecated_architecture = False
if arch in {"i386", "armhf"} or (
arch == "armv7" and installation_type != "Container"
):
deprecated_architecture = True
if deprecated_method or deprecated_architecture:
issue_id = "deprecated"
if deprecated_method:
@@ -441,6 +430,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
hass,
DOMAIN,
issue_id,
breaks_in_ha_version="2025.12.0",
learn_more_url=DEPRECATION_URL,
is_fixable=False,
severity=IssueSeverity.WARNING,
@@ -107,9 +107,9 @@
"title": "Deprecation notice: 32-bit architecture",
"description": "This system uses 32-bit hardware (`{arch}`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. As your hardware is no longer capable of running newer versions of Home Assistant, you will need to migrate to new hardware."
},
"deprecated_container": {
"deprecated_container_armv7": {
"title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]",
"description": "This system is running on a 32-bit operating system (`{arch}`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. Check if your system is capable of running a 64-bit operating system. If not, you will need to migrate to new hardware."
"description": "This system is running on a 32-bit operating system (`armv7`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. Check if your system is capable of running a 64-bit operating system. If not, you will need to migrate to new hardware."
},
"deprecated_os_aarch64": {
"title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]",
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/hyperion",
"iot_class": "local_push",
"loggers": ["hyperion"],
"requirements": ["hyperion-py==0.7.6"],
"requirements": ["hyperion-py==0.7.5"],
"ssdp": [
{
"manufacturer": "Hyperion Open Source Ambient Lighting",
@@ -82,9 +82,6 @@
},
"usb_capture": {
"name": "Component USB capture"
},
"audio_capture": {
"name": "Component Audio capture"
}
},
"sensor": {
@@ -9,7 +9,6 @@ from hyperion import client
from hyperion.const import (
KEY_COMPONENT,
KEY_COMPONENTID_ALL,
KEY_COMPONENTID_AUDIO,
KEY_COMPONENTID_BLACKBORDER,
KEY_COMPONENTID_BOBLIGHTSERVER,
KEY_COMPONENTID_FORWARDER,
@@ -60,7 +59,6 @@ COMPONENT_SWITCHES = [
KEY_COMPONENTID_GRABBER,
KEY_COMPONENTID_LEDDEVICE,
KEY_COMPONENTID_V4L,
KEY_COMPONENTID_AUDIO,
]
@@ -85,7 +83,6 @@ def _component_to_translation_key(component: str) -> str:
KEY_COMPONENTID_GRABBER: "platform_capture",
KEY_COMPONENTID_LEDDEVICE: "led_device",
KEY_COMPONENTID_V4L: "usb_capture",
KEY_COMPONENTID_AUDIO: "audio_capture",
}[component]
@@ -30,6 +30,7 @@ from homeassistant.helpers.selector import (
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from .const import DEFAULT_VERIFY_SSL, DOMAIN
@@ -81,6 +82,7 @@ class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
_name: str
_current_data: Mapping[str, Any]
_hassio_discovery: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -172,3 +174,66 @@ class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"name": self._name},
errors=errors,
)
async def async_step_hassio(
self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult:
"""Handle the discovery step via hassio."""
self._hassio_discovery = discovery_info.config
return await self.async_step_hassio_confirm()
async def async_step_hassio_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm Supervisor discovery."""
assert self._hassio_discovery
host = self._hassio_discovery[CONF_HOST]
port = self._hassio_discovery[CONF_PORT]
ssl = self._hassio_discovery[CONF_SSL]
addon = self._hassio_discovery["addon"]
errors: dict[str, str] = {}
if user_input is not None:
try:
my_user_info = await check_user_info(
self.hass,
host,
port,
ssl,
user_input[CONF_VERIFY_SSL],
user_input[CONF_API_KEY],
)
except ImmichUnauthorizedError:
errors["base"] = "invalid_auth"
except CONNECT_ERRORS:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(my_user_info.user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=my_user_info.name,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_SSL: ssl,
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
CONF_API_KEY: user_input[CONF_API_KEY],
},
)
return self.async_show_form(
step_id="hassio_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): TextSelector(
config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
}
),
description_placeholders={"addon": addon},
errors=errors,
)
@@ -26,6 +26,17 @@
"data_description": {
"api_key": "[%key:component::immich::common::data_desc_api_key%]"
}
},
"hassio_confirm": {
"description": "Setup connection to add-on {addon}",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_key": "[%key:component::immich::common::data_desc_api_key%]",
"verify_ssl": "[%key:component::immich::common::data_desc_ssl_verify%]"
}
}
},
"error": {
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/jewish_calendar",
"iot_class": "calculated",
"loggers": ["hdate"],
"requirements": ["hdate[astral]==1.1.1"],
"requirements": ["hdate[astral]==1.1.0"],
"single_config_entry": true
}
@@ -24,11 +24,3 @@ async def async_get_auth_implementation(
token_url=OAUTH2_TOKEN,
),
)
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {
"developer_dashboard_url": "https://developer.honeywellhome.com",
"redirect_url": "https://my.home-assistant.io/redirect/oauth",
}
+1 -4
View File
@@ -1,7 +1,4 @@
{
"application_credentials": {
"description": "To be able to log in to Honeywell Lyric the integration requires a client ID and secret. To acquire those, please follow the following steps.\n\n1. Go to the [Honeywell Lyric Developer Apps Dashboard]({developer_dashboard_url}).\n1. Sign up for a developer account if you don't have one yet. This is a separate account from your Honeywell account.\n1. Log in with your Honeywell Lyric developer account.\n1. Go to the **My Apps** section.\n1. Press the **CREATE NEW APP** button.\n1. Give the application a name of your choice.\n1. Set the **Callback URL** to `{redirect_url}`.\n1. Save your changes.\\n1. Copy the **Consumer Key** and paste it here as the **Client ID**, then copy the **Consumer Secret** and paste it here as the **Client Secret**."
},
"config": {
"step": {
"pick_implementation": {
@@ -12,7 +9,7 @@
"description": "The Lyric integration needs to re-authenticate your account."
},
"oauth_discovery": {
"description": "Home Assistant has found a Honeywell Lyric device on your network. Be aware that the setup of the Lyric integration is more complicated than other integrations. Press **Submit** to continue setting up Honeywell Lyric."
"description": "Home Assistant has found a Honeywell Lyric device on your network. Press **Submit** to continue setting up Honeywell Lyric."
}
},
"abort": {
@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.06.09"],
"requirements": ["yt-dlp[default]==2025.05.22"],
"single_config_entry": true
}
@@ -45,7 +45,7 @@ from .const import ( # noqa: F401
SupportedDialect,
)
from .core import Recorder
from .services import async_setup_services
from .services import async_register_services
from .tasks import AddRecorderPlatformTask
from .util import get_instance
@@ -174,7 +174,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
instance.async_initialize()
instance.async_register()
instance.start()
async_setup_services(hass)
async_register_services(hass, instance)
websocket_api.async_setup(hass)
await _async_setup_integration_platform(hass, instance)
+117 -99
View File
@@ -17,7 +17,6 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entityfilter import generate_filter
from homeassistant.helpers.recorder import DATA_INSTANCE
from homeassistant.helpers.service import (
async_extract_entity_ids,
async_register_admin_service,
@@ -26,6 +25,7 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN
from .core import Recorder
from .statistics import statistics_during_period
from .tasks import PurgeEntitiesTask, PurgeTask
@@ -87,137 +87,155 @@ SERVICE_GET_STATISTICS_SCHEMA = vol.Schema(
)
async def _async_handle_purge_service(service: ServiceCall) -> None:
"""Handle calls to the purge service."""
hass = service.hass
instance = hass.data[DATA_INSTANCE]
kwargs = service.data
keep_days = kwargs.get(ATTR_KEEP_DAYS, instance.keep_days)
repack = cast(bool, kwargs[ATTR_REPACK])
apply_filter = cast(bool, kwargs[ATTR_APPLY_FILTER])
purge_before = dt_util.utcnow() - timedelta(days=keep_days)
instance.queue_task(PurgeTask(purge_before, repack, apply_filter))
async def _async_handle_purge_entities_service(service: ServiceCall) -> None:
"""Handle calls to the purge entities service."""
hass = service.hass
entity_ids = await async_extract_entity_ids(hass, service)
domains = service.data.get(ATTR_DOMAINS, [])
keep_days = service.data.get(ATTR_KEEP_DAYS, 0)
entity_globs = service.data.get(ATTR_ENTITY_GLOBS, [])
entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs)
purge_before = dt_util.utcnow() - timedelta(days=keep_days)
hass.data[DATA_INSTANCE].queue_task(PurgeEntitiesTask(entity_filter, purge_before))
async def _async_handle_enable_service(service: ServiceCall) -> None:
service.hass.data[DATA_INSTANCE].set_enable(True)
async def _async_handle_disable_service(service: ServiceCall) -> None:
service.hass.data[DATA_INSTANCE].set_enable(False)
async def _async_handle_get_statistics_service(
service: ServiceCall,
) -> ServiceResponse:
"""Handle calls to the get_statistics service."""
hass = service.hass
start_time = dt_util.as_utc(service.data["start_time"])
end_time = (
dt_util.as_utc(service.data["end_time"]) if "end_time" in service.data else None
)
statistic_ids = service.data["statistic_ids"]
types = service.data["types"]
period = service.data["period"]
units = service.data.get("units")
result = await hass.data[DATA_INSTANCE].async_add_executor_job(
statistics_during_period,
hass,
start_time,
end_time,
statistic_ids,
period,
units,
types,
)
formatted_result: JsonObjectType = {}
for statistic_id, statistic_rows in result.items():
formatted_statistic_rows: JsonArrayType = []
for row in statistic_rows:
formatted_row: JsonObjectType = {
"start": dt_util.utc_from_timestamp(row["start"]).isoformat(),
"end": dt_util.utc_from_timestamp(row["end"]).isoformat(),
}
if (last_reset := row.get("last_reset")) is not None:
formatted_row["last_reset"] = dt_util.utc_from_timestamp(
last_reset
).isoformat()
if (state := row.get("state")) is not None:
formatted_row["state"] = state
if (sum_value := row.get("sum")) is not None:
formatted_row["sum"] = sum_value
if (min_value := row.get("min")) is not None:
formatted_row["min"] = min_value
if (max_value := row.get("max")) is not None:
formatted_row["max"] = max_value
if (mean := row.get("mean")) is not None:
formatted_row["mean"] = mean
if (change := row.get("change")) is not None:
formatted_row["change"] = change
formatted_statistic_rows.append(formatted_row)
formatted_result[statistic_id] = formatted_statistic_rows
return {"statistics": formatted_result}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register recorder services."""
def _async_register_purge_service(hass: HomeAssistant, instance: Recorder) -> None:
async def async_handle_purge_service(service: ServiceCall) -> None:
"""Handle calls to the purge service."""
kwargs = service.data
keep_days = kwargs.get(ATTR_KEEP_DAYS, instance.keep_days)
repack = cast(bool, kwargs[ATTR_REPACK])
apply_filter = cast(bool, kwargs[ATTR_APPLY_FILTER])
purge_before = dt_util.utcnow() - timedelta(days=keep_days)
instance.queue_task(PurgeTask(purge_before, repack, apply_filter))
async_register_admin_service(
hass,
DOMAIN,
SERVICE_PURGE,
_async_handle_purge_service,
async_handle_purge_service,
schema=SERVICE_PURGE_SCHEMA,
)
@callback
def _async_register_purge_entities_service(
hass: HomeAssistant, instance: Recorder
) -> None:
async def async_handle_purge_entities_service(service: ServiceCall) -> None:
"""Handle calls to the purge entities service."""
entity_ids = await async_extract_entity_ids(hass, service)
domains = service.data.get(ATTR_DOMAINS, [])
keep_days = service.data.get(ATTR_KEEP_DAYS, 0)
entity_globs = service.data.get(ATTR_ENTITY_GLOBS, [])
entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs)
purge_before = dt_util.utcnow() - timedelta(days=keep_days)
instance.queue_task(PurgeEntitiesTask(entity_filter, purge_before))
async_register_admin_service(
hass,
DOMAIN,
SERVICE_PURGE_ENTITIES,
_async_handle_purge_entities_service,
async_handle_purge_entities_service,
schema=SERVICE_PURGE_ENTITIES_SCHEMA,
)
@callback
def _async_register_enable_service(hass: HomeAssistant, instance: Recorder) -> None:
async def async_handle_enable_service(service: ServiceCall) -> None:
instance.set_enable(True)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_ENABLE,
_async_handle_enable_service,
async_handle_enable_service,
schema=SERVICE_ENABLE_SCHEMA,
)
@callback
def _async_register_disable_service(hass: HomeAssistant, instance: Recorder) -> None:
async def async_handle_disable_service(service: ServiceCall) -> None:
instance.set_enable(False)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_DISABLE,
_async_handle_disable_service,
async_handle_disable_service,
schema=SERVICE_DISABLE_SCHEMA,
)
@callback
def _async_register_get_statistics_service(
hass: HomeAssistant, instance: Recorder
) -> None:
async def async_handle_get_statistics_service(
service: ServiceCall,
) -> ServiceResponse:
"""Handle calls to the get_statistics service."""
start_time = dt_util.as_utc(service.data["start_time"])
end_time = (
dt_util.as_utc(service.data["end_time"])
if "end_time" in service.data
else None
)
statistic_ids = service.data["statistic_ids"]
types = service.data["types"]
period = service.data["period"]
units = service.data.get("units")
result = await instance.async_add_executor_job(
statistics_during_period,
hass,
start_time,
end_time,
statistic_ids,
period,
units,
types,
)
formatted_result: JsonObjectType = {}
for statistic_id, statistic_rows in result.items():
formatted_statistic_rows: JsonArrayType = []
for row in statistic_rows:
formatted_row: JsonObjectType = {
"start": dt_util.utc_from_timestamp(row["start"]).isoformat(),
"end": dt_util.utc_from_timestamp(row["end"]).isoformat(),
}
if (last_reset := row.get("last_reset")) is not None:
formatted_row["last_reset"] = dt_util.utc_from_timestamp(
last_reset
).isoformat()
if (state := row.get("state")) is not None:
formatted_row["state"] = state
if (sum_value := row.get("sum")) is not None:
formatted_row["sum"] = sum_value
if (min_value := row.get("min")) is not None:
formatted_row["min"] = min_value
if (max_value := row.get("max")) is not None:
formatted_row["max"] = max_value
if (mean := row.get("mean")) is not None:
formatted_row["mean"] = mean
if (change := row.get("change")) is not None:
formatted_row["change"] = change
formatted_statistic_rows.append(formatted_row)
formatted_result[statistic_id] = formatted_statistic_rows
return {"statistics": formatted_result}
async_register_admin_service(
hass,
DOMAIN,
SERVICE_GET_STATISTICS,
_async_handle_get_statistics_service,
async_handle_get_statistics_service,
schema=SERVICE_GET_STATISTICS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@callback
def async_register_services(hass: HomeAssistant, instance: Recorder) -> None:
"""Register recorder services."""
_async_register_purge_service(hass, instance)
_async_register_purge_entities_service(hass, instance)
_async_register_enable_service(hass, instance)
_async_register_disable_service(hass, instance)
_async_register_get_statistics_service(hass, instance)
+3 -4
View File
@@ -31,7 +31,7 @@ async def async_setup_entry(
if dev_id in event_entities:
return
# new player!
event_entity = RoonEventEntity(roon_server, player_data, config_entry.entry_id)
event_entity = RoonEventEntity(roon_server, player_data)
event_entities.add(dev_id)
async_add_entities([event_entity])
@@ -50,14 +50,13 @@ class RoonEventEntity(EventEntity):
_attr_event_types = ["volume_up", "volume_down", "mute_toggle"]
_attr_translation_key = "volume"
def __init__(self, server, player_data, entry_id):
def __init__(self, server, player_data):
"""Initialize the entity."""
self._server = server
self._player_data = player_data
player_name = player_data["display_name"]
self._attr_name = f"{player_name} roon volume"
self._attr_unique_id = self._player_data["dev_id"]
self._entry_id = entry_id
if self._player_data.get("source_controls"):
dev_model = self._player_data["source_controls"][0].get("display_name")
@@ -70,7 +69,7 @@ class RoonEventEntity(EventEntity):
name=cast(str | None, self.name),
manufacturer="RoonLabs",
model=dev_model,
via_device=(DOMAIN, self._entry_id),
via_device=(DOMAIN, self._server.roon_id),
)
def _roonapi_volume_callback(
@@ -72,7 +72,7 @@ async def async_setup_entry(
dev_id = player_data["dev_id"]
if dev_id not in media_players:
# new player!
media_player = RoonDevice(roon_server, player_data, config_entry.entry_id)
media_player = RoonDevice(roon_server, player_data)
media_players.add(dev_id)
async_add_entities([media_player])
else:
@@ -106,7 +106,7 @@ class RoonDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.PLAY_MEDIA
)
def __init__(self, server, player_data, entry_id):
def __init__(self, server, player_data):
"""Initialize Roon device object."""
self._remove_signal_status = None
self._server = server
@@ -125,7 +125,6 @@ class RoonDevice(MediaPlayerEntity):
self._attr_volume_level = 0
self._volume_fixed = True
self._volume_incremental = False
self._entry_id = entry_id
self.update_data(player_data)
async def async_added_to_hass(self) -> None:
@@ -167,7 +166,7 @@ class RoonDevice(MediaPlayerEntity):
name=cast(str | None, self.name),
manufacturer="RoonLabs",
model=dev_model,
via_device=(DOMAIN, self._entry_id),
via_device=(DOMAIN, self._server.roon_id),
)
def update_data(self, player_data=None):
@@ -64,7 +64,6 @@ from .utils import (
get_http_port,
get_rpc_scripts_event_types,
get_ws_context,
remove_stale_blu_trv_devices,
)
PLATFORMS: Final = [
@@ -301,7 +300,6 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
runtime_data.rpc_script_events = await get_rpc_scripts_event_types(
device, ignore_scripts=[BLE_SCRIPT_NAME]
)
remove_stale_blu_trv_devices(hass, device, entry)
except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err:
await device.shutdown()
raise ConfigEntryNotReady(
@@ -61,8 +61,8 @@ rules:
reconfiguration-flow: done
repair-issues: done
stale-devices:
status: done
comment: BLU TRV is removed when un-paired
status: todo
comment: BLU TRV needs to be removed when un-paired
# Platinum
async-dependency: done
-30
View File
@@ -16,7 +16,6 @@ from aioshelly.const import (
DEFAULT_COAP_PORT,
DEFAULT_HTTP_PORT,
MODEL_1L,
MODEL_BLU_GATEWAY_G3,
MODEL_DIMMER,
MODEL_DIMMER_2,
MODEL_EM3,
@@ -822,32 +821,3 @@ def get_block_device_info(
manufacturer="Shelly",
via_device=(DOMAIN, mac),
)
@callback
def remove_stale_blu_trv_devices(
hass: HomeAssistant, rpc_device: RpcDevice, entry: ConfigEntry
) -> None:
"""Remove stale BLU TRV devices."""
if rpc_device.model != MODEL_BLU_GATEWAY_G3:
return
dev_reg = dr.async_get(hass)
devices = dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id)
config = rpc_device.config
blutrv_keys = get_rpc_key_ids(config, BLU_TRV_IDENTIFIER)
trv_addrs = [config[f"{BLU_TRV_IDENTIFIER}:{key}"]["addr"] for key in blutrv_keys]
for device in devices:
if not device.via_device_id:
# Device is not a sub-device, skip
continue
if any(
identifier[0] == DOMAIN and identifier[1] in trv_addrs
for identifier in device.identifiers
):
continue
LOGGER.debug("Removing stale BLU TRV device %s", device.name)
dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id)
@@ -153,6 +153,11 @@ class SqueezeboxButtonEntity(SqueezeboxEntity, ButtonEntity):
f"{format_mac(self._player.player_id)}_{entity_description.key}"
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.coordinator.available and super().available
async def async_press(self) -> None:
"""Execute the button action."""
await self._player.async_query("button", self.entity_description.press_action)
@@ -33,13 +33,6 @@ class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]):
manufacturer=self._player.creator,
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
# super().available refers to CoordinatorEntity.available (self.coordinator.last_update_success)
# self.coordinator.available is the custom availability flag from SqueezeBoxPlayerUpdateCoordinator
return self.coordinator.available and super().available
class LMSStatusEntity(CoordinatorEntity[LMSStatusDataUpdateCoordinator]):
"""Defines a base status sensor entity."""
@@ -246,6 +246,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.coordinator.available and super().available
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device-specific attributes."""
+2 -2
View File
@@ -233,13 +233,13 @@ class TelegramNotificationService:
"""Initialize the service."""
self.app = app
self.config = config
self._parsers: dict[str, str | None] = {
self._parsers = {
PARSER_HTML: ParseMode.HTML,
PARSER_MD: ParseMode.MARKDOWN,
PARSER_MD2: ParseMode.MARKDOWN_V2,
PARSER_PLAIN_TEXT: None,
}
self.parse_mode = self._parsers[parser]
self.parse_mode = self._parsers.get(parser)
self.bot = bot
self.hass = hass
self._last_message_id: dict[int, int] = {}
@@ -54,7 +54,6 @@ from .const import (
PARSER_HTML,
PARSER_MD,
PARSER_MD2,
PARSER_PLAIN_TEXT,
PLATFORM_BROADCAST,
PLATFORM_POLLING,
PLATFORM_WEBHOOKS,
@@ -127,8 +126,8 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema(
ATTR_PARSER,
): SelectSelector(
SelectSelectorConfig(
options=[PARSER_MD, PARSER_MD2, PARSER_HTML, PARSER_PLAIN_TEXT],
translation_key="parse_mode",
options=[PARSER_MD, PARSER_MD2, PARSER_HTML],
translation_key="parsers",
)
)
}
@@ -144,8 +143,6 @@ class OptionsFlowHandler(OptionsFlow):
"""Manage the options."""
if user_input is not None:
if user_input[ATTR_PARSER] == PARSER_PLAIN_TEXT:
user_input[ATTR_PARSER] = None
return self.async_create_entry(data=user_input)
return self.async_show_form(
@@ -27,7 +27,6 @@ send_message:
- "markdown"
- "markdownv2"
- "plain_text"
translation_key: "parse_mode"
disable_notification:
selector:
boolean:
@@ -106,12 +106,11 @@
"webhooks": "Webhooks"
}
},
"parse_mode": {
"parsers": {
"options": {
"markdown": "Markdown (Legacy)",
"markdownv2": "MarkdownV2",
"html": "HTML",
"plain_text": "Plain text"
"html": "HTML"
}
}
},
+1 -1
View File
@@ -47,7 +47,7 @@ FLOWS = {
"airzone",
"airzone_cloud",
"alarmdecoder",
"alexa_devices",
"amazon_devices",
"amberelectric",
"ambient_network",
"ambient_station",
+2 -2
View File
@@ -207,11 +207,11 @@
"amazon": {
"name": "Amazon",
"integrations": {
"alexa_devices": {
"amazon_devices": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Alexa Devices"
"name": "Amazon Devices"
},
"amazon_polly": {
"integration_type": "hub",
+3
View File
@@ -199,6 +199,7 @@ class EntityInfo(TypedDict):
"""Entity info."""
domain: str
custom_component: bool
config_entry: NotRequired[str]
@@ -1449,8 +1450,10 @@ class Entity(
Not to be extended by integrations.
"""
is_custom_component = "custom_components" in type(self).__module__
entity_info: EntityInfo = {
"domain": self.platform.platform_name,
"custom_component": is_custom_component,
}
if self.platform.config_entry:
entity_info["config_entry"] = self.platform.config_entry.entry_id
+9 -2
View File
@@ -3,7 +3,6 @@
aiodhcpwatcher==1.2.0
aiodiscover==2.7.0
aiodns==3.4.0
aiofiles==24.1.0
aiohasupervisor==0.3.1
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
@@ -39,7 +38,7 @@ habluetooth==3.49.0
hass-nabucasa==0.101.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250531.2
home-assistant-frontend==20250531.0
home-assistant-intents==2025.6.10
httpx==0.28.1
ifaddr==0.2.0
@@ -202,6 +201,14 @@ tenacity!=8.4.0
# TypeError: 'Timeout' object does not support the context manager protocol
async-timeout==4.0.3
# aiofiles keeps getting downgraded by custom components
# causing newer methods to not be available and breaking
# some integrations at startup
# https://github.com/home-assistant/core/issues/127529
# https://github.com/home-assistant/core/issues/122508
# https://github.com/home-assistant/core/issues/118004
aiofiles>=24.1.0
# multidict < 6.4.0 has memory leaks
# https://github.com/aio-libs/multidict/issues/1134
# https://github.com/aio-libs/multidict/issues/1131
Generated
+2 -2
View File
@@ -405,7 +405,7 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.alexa_devices.*]
[mypy-homeassistant.components.alpha_vantage.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
@@ -415,7 +415,7 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.alpha_vantage.*]
[mypy-homeassistant.components.amazon_devices.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
+12
View File
@@ -25,6 +25,18 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
constant=re.compile(r"^cached_property$"),
),
],
"homeassistant.backports.enum": [
ObsoleteImportMatch(
reason="We can now use the Python 3.11 provided enum.StrEnum instead",
constant=re.compile(r"^StrEnum$"),
),
],
"homeassistant.backports.functools": [
ObsoleteImportMatch(
reason="replaced by propcache.api.cached_property",
constant=re.compile(r"^cached_property$"),
),
],
"homeassistant.components.light": [
ObsoleteImportMatch(
reason="replaced by ColorMode enum",
-1
View File
@@ -24,7 +24,6 @@ classifiers = [
requires-python = ">=3.13.2"
dependencies = [
"aiodns==3.4.0",
"aiofiles==24.1.0",
# Integrations may depend on hassio integration without listing it to
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
-1
View File
@@ -4,7 +4,6 @@
# Home Assistant Core
aiodns==3.4.0
aiofiles==24.1.0
aiohasupervisor==0.3.1
aiohttp==3.12.12
aiohttp_cors==0.8.1
+5 -5
View File
@@ -181,7 +181,7 @@ aioairzone-cloud==0.6.12
# homeassistant.components.airzone
aioairzone==1.0.0
# homeassistant.components.alexa_devices
# homeassistant.components.amazon_devices
aioamazondevices==3.0.6
# homeassistant.components.ambient_network
@@ -1133,7 +1133,7 @@ hass-splunk==0.1.1
hassil==2.2.3
# homeassistant.components.jewish_calendar
hdate[astral]==1.1.1
hdate[astral]==1.1.0
# homeassistant.components.heatmiser
heatmiserV3==2.0.3
@@ -1164,7 +1164,7 @@ hole==0.8.0
holidays==0.74
# homeassistant.components.frontend
home-assistant-frontend==20250531.2
home-assistant-frontend==20250531.0
# homeassistant.components.conversation
home-assistant-intents==2025.6.10
@@ -1185,7 +1185,7 @@ huawei-lte-api==1.11.0
huum==0.7.12
# homeassistant.components.hyperion
hyperion-py==0.7.6
hyperion-py==0.7.5
# homeassistant.components.iammeter
iammeter==0.2.1
@@ -3162,7 +3162,7 @@ youless-api==2.2.0
youtubeaio==1.1.5
# homeassistant.components.media_extractor
yt-dlp[default]==2025.06.09
yt-dlp[default]==2025.05.22
# homeassistant.components.zabbix
zabbix-utils==2.0.2
+3 -3
View File
@@ -27,7 +27,7 @@ pytest-github-actions-annotate-failures==0.3.0
pytest-socket==0.7.0
pytest-sugar==1.0.0
pytest-timeout==2.4.0
pytest-unordered==0.7.0
pytest-unordered==0.6.1
pytest-picked==0.5.1
pytest-xdist==3.7.0
pytest==8.4.0
@@ -35,7 +35,7 @@ requests-mock==1.12.1
respx==0.22.0
syrupy==4.9.1
tqdm==4.67.1
types-aiofiles==24.1.0.20250606
types-aiofiles==24.1.0.20250516
types-atomicwrites==1.4.5.1
types-croniter==6.0.0.20250411
types-caldav==1.3.0.20250516
@@ -49,5 +49,5 @@ types-python-dateutil==2.9.0.20250516
types-python-slugify==8.0.2.20240310
types-pytz==2025.2.0.20250516
types-PyYAML==6.0.12.20250516
types-requests==2.32.4.20250611
types-requests==2.31.0.3
types-xmltodict==0.13.0.3
+5 -5
View File
@@ -169,7 +169,7 @@ aioairzone-cloud==0.6.12
# homeassistant.components.airzone
aioairzone==1.0.0
# homeassistant.components.alexa_devices
# homeassistant.components.amazon_devices
aioamazondevices==3.0.6
# homeassistant.components.ambient_network
@@ -988,7 +988,7 @@ hass-nabucasa==0.101.0
hassil==2.2.3
# homeassistant.components.jewish_calendar
hdate[astral]==1.1.1
hdate[astral]==1.1.0
# homeassistant.components.here_travel_time
here-routing==1.0.1
@@ -1010,7 +1010,7 @@ hole==0.8.0
holidays==0.74
# homeassistant.components.frontend
home-assistant-frontend==20250531.2
home-assistant-frontend==20250531.0
# homeassistant.components.conversation
home-assistant-intents==2025.6.10
@@ -1028,7 +1028,7 @@ huawei-lte-api==1.11.0
huum==0.7.12
# homeassistant.components.hyperion
hyperion-py==0.7.6
hyperion-py==0.7.5
# homeassistant.components.iaqualink
iaqualink==0.5.3
@@ -2606,7 +2606,7 @@ youless-api==2.2.0
youtubeaio==1.1.5
# homeassistant.components.media_extractor
yt-dlp[default]==2025.06.09
yt-dlp[default]==2025.05.22
# homeassistant.components.zamg
zamg==0.3.6
+8
View File
@@ -226,6 +226,14 @@ tenacity!=8.4.0
# TypeError: 'Timeout' object does not support the context manager protocol
async-timeout==4.0.3
# aiofiles keeps getting downgraded by custom components
# causing newer methods to not be available and breaking
# some integrations at startup
# https://github.com/home-assistant/core/issues/127529
# https://github.com/home-assistant/core/issues/122508
# https://github.com/home-assistant/core/issues/118004
aiofiles>=24.1.0
# multidict < 6.4.0 has memory leaks
# https://github.com/aio-libs/multidict/issues/1134
# https://github.com/aio-libs/multidict/issues/1131
@@ -1,4 +1,4 @@
"""Tests for the Alexa Devices integration."""
"""Tests for the Amazon Devices integration."""
from homeassistant.core import HomeAssistant
@@ -1,4 +1,4 @@
"""Alexa Devices tests configuration."""
"""Amazon Devices tests configuration."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
@@ -7,7 +7,7 @@ from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import DEVICE_TYPE_TO_MODEL
import pytest
from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN
from homeassistant.components.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from .const import TEST_COUNTRY, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME
@@ -19,7 +19,7 @@ from tests.common import MockConfigEntry
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.alexa_devices.async_setup_entry",
"homeassistant.components.amazon_devices.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@@ -27,14 +27,14 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture
def mock_amazon_devices_client() -> Generator[AsyncMock]:
"""Mock an Alexa Devices client."""
"""Mock an Amazon Devices client."""
with (
patch(
"homeassistant.components.alexa_devices.coordinator.AmazonEchoApi",
"homeassistant.components.amazon_devices.coordinator.AmazonEchoApi",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.alexa_devices.config_flow.AmazonEchoApi",
"homeassistant.components.amazon_devices.config_flow.AmazonEchoApi",
new=mock_client,
),
):
@@ -1,4 +1,4 @@
"""Alexa Devices tests const."""
"""Amazon Devices tests const."""
TEST_CODE = "023123"
TEST_COUNTRY = "IT"
@@ -25,7 +25,7 @@
'original_device_class': None,
'original_icon': None,
'original_name': 'Bluetooth',
'platform': 'alexa_devices',
'platform': 'amazon_devices',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
@@ -73,7 +73,7 @@
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'Connectivity',
'platform': 'alexa_devices',
'platform': 'amazon_devices',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
@@ -57,7 +57,7 @@
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'alexa_devices',
'domain': 'amazon_devices',
'minor_version': 1,
'options': dict({
}),
@@ -13,7 +13,7 @@
'id': <ANY>,
'identifiers': set({
tuple(
'alexa_devices',
'amazon_devices',
'echo_test_serial_number',
),
}),
@@ -25,7 +25,7 @@
'original_device_class': None,
'original_icon': None,
'original_name': 'Announce',
'platform': 'alexa_devices',
'platform': 'amazon_devices',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
@@ -74,7 +74,7 @@
'original_device_class': None,
'original_icon': None,
'original_name': 'Speak',
'platform': 'alexa_devices',
'platform': 'amazon_devices',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
@@ -25,7 +25,7 @@
'original_device_class': None,
'original_icon': None,
'original_name': 'Do not disturb',
'platform': 'alexa_devices',
'platform': 'amazon_devices',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
@@ -1,4 +1,4 @@
"""Tests for the Alexa Devices binary sensor platform."""
"""Tests for the Amazon Devices binary sensor platform."""
from unittest.mock import AsyncMock, patch
@@ -11,7 +11,7 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL
from homeassistant.components.amazon_devices.coordinator import SCAN_INTERVAL
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -31,7 +31,7 @@ async def test_all_entities(
) -> None:
"""Test all entities."""
with patch(
"homeassistant.components.alexa_devices.PLATFORMS", [Platform.BINARY_SENSOR]
"homeassistant.components.amazon_devices.PLATFORMS", [Platform.BINARY_SENSOR]
):
await setup_integration(hass, mock_config_entry)
@@ -1,11 +1,11 @@
"""Tests for the Alexa Devices config flow."""
"""Tests for the Amazon Devices config flow."""
from unittest.mock import AsyncMock
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
import pytest
from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN
from homeassistant.components.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
@@ -1,4 +1,4 @@
"""Tests for Alexa Devices diagnostics platform."""
"""Tests for Amazon Devices diagnostics platform."""
from __future__ import annotations
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.components.alexa_devices.const import DOMAIN
from homeassistant.components.amazon_devices.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -1,10 +1,10 @@
"""Tests for the Alexa Devices integration."""
"""Tests for the Amazon Devices integration."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.alexa_devices.const import DOMAIN
from homeassistant.components.amazon_devices.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -1,4 +1,4 @@
"""Tests for the Alexa Devices notify platform."""
"""Tests for the Amazon Devices notify platform."""
from unittest.mock import AsyncMock, patch
@@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL
from homeassistant.components.amazon_devices.coordinator import SCAN_INTERVAL
from homeassistant.components.notify import (
ATTR_MESSAGE,
DOMAIN as NOTIFY_DOMAIN,
@@ -32,7 +32,7 @@ async def test_all_entities(
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.NOTIFY]):
with patch("homeassistant.components.amazon_devices.PLATFORMS", [Platform.NOTIFY]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@@ -1,4 +1,4 @@
"""Tests for the Alexa Devices switch platform."""
"""Tests for the Amazon Devices switch platform."""
from unittest.mock import AsyncMock, patch
@@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL
from homeassistant.components.amazon_devices.coordinator import SCAN_INTERVAL
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TURN_OFF,
@@ -37,7 +37,7 @@ async def test_all_entities(
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.SWITCH]):
with patch("homeassistant.components.amazon_devices.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+1 -2
View File
@@ -126,8 +126,7 @@ async def test_command_line_output_single_command(
await hass.services.async_call(
NOTIFY_DOMAIN, "test3", {"message": "test message"}, blocking=True
)
assert "Running command: echo" in caplog.text
assert "Running with message: test message" in caplog.text
assert "Running command: echo, with message: test message" in caplog.text
async def test_command_template(hass: HomeAssistant) -> None:
+2 -38
View File
@@ -83,8 +83,8 @@ def mock_power_sensor() -> Mock:
@pytest.fixture
def mock_positionable_cover() -> Mock:
"""Fixture for a positionable cover."""
def mock_cover() -> Mock:
"""Fixture for a cover."""
cover = Mock()
cover.fibaro_id = 3
cover.parent_fibaro_id = 0
@@ -112,42 +112,6 @@ def mock_positionable_cover() -> Mock:
return cover
@pytest.fixture
def mock_cover() -> Mock:
"""Fixture for a cover supporting slats but without positioning."""
cover = Mock()
cover.fibaro_id = 4
cover.parent_fibaro_id = 0
cover.name = "Test cover"
cover.room_id = 1
cover.dead = False
cover.visible = True
cover.enabled = True
cover.type = "com.fibaro.baseShutter"
cover.base_type = "com.fibaro.actor"
cover.properties = {"manufacturer": ""}
cover.actions = {
"open": 0,
"close": 0,
"stop": 0,
"rotateSlatsUp": 0,
"rotateSlatsDown": 0,
"stopSlats": 0,
}
cover.supported_features = {}
value_mock = Mock()
value_mock.has_value = False
cover.value = value_mock
value2_mock = Mock()
value2_mock.has_value = False
cover.value_2 = value2_mock
state_mock = Mock()
state_mock.has_value = True
state_mock.str_value.return_value = "closed"
cover.state = state_mock
return cover
@pytest.fixture
def mock_light() -> Mock:
"""Fixture for a dimmmable light."""
+14 -214
View File
@@ -2,7 +2,7 @@
from unittest.mock import Mock, patch
from homeassistant.components.cover import CoverEntityFeature, CoverState
from homeassistant.components.cover import CoverState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -12,98 +12,6 @@ from .conftest import init_integration
from tests.common import MockConfigEntry
async def test_positionable_cover_setup(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_positionable_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that the cover creates an entity."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_fibaro_client.read_devices.return_value = [mock_positionable_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
# Assert
entry = entity_registry.async_get("cover.room_1_test_cover_3")
assert entry
assert entry.supported_features == (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION
)
assert entry.unique_id == "hc2_111111.3"
assert entry.original_name == "Room 1 Test cover"
async def test_cover_opening(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_positionable_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that the cover opening state is reported."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_fibaro_client.read_devices.return_value = [mock_positionable_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
# Assert
assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPENING
async def test_cover_opening_closing_none(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_positionable_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that the cover opening closing states return None if not available."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_positionable_cover.state.str_value.return_value = ""
mock_fibaro_client.read_devices.return_value = [mock_positionable_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
# Assert
assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPEN
async def test_cover_closing(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_positionable_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that the cover closing state is reported."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_positionable_cover.state.str_value.return_value = "closing"
mock_fibaro_client.read_devices.return_value = [mock_positionable_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
# Assert
assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.CLOSING
async def test_cover_setup(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
@@ -122,28 +30,20 @@ async def test_cover_setup(
# Act
await init_integration(hass, mock_config_entry)
# Assert
entry = entity_registry.async_get("cover.room_1_test_cover_4")
entry = entity_registry.async_get("cover.room_1_test_cover_3")
assert entry
assert entry.supported_features == (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
)
assert entry.unique_id == "hc2_111111.4"
assert entry.unique_id == "hc2_111111.3"
assert entry.original_name == "Room 1 Test cover"
async def test_cover_open_action(
async def test_cover_opening(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that open_cover works."""
"""Test that the cover opening state is reported."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
@@ -152,147 +52,47 @@ async def test_cover_open_action(
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
await hass.services.async_call(
"cover",
"open_cover",
{"entity_id": "cover.room_1_test_cover_4"},
blocking=True,
)
# Assert
mock_cover.execute_action.assert_called_once_with("open", ())
assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPENING
async def test_cover_close_action(
async def test_cover_opening_closing_none(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that close_cover works."""
"""Test that the cover opening closing states return None if not available."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_cover.state.has_value = False
mock_fibaro_client.read_devices.return_value = [mock_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
await hass.services.async_call(
"cover",
"close_cover",
{"entity_id": "cover.room_1_test_cover_4"},
blocking=True,
)
# Assert
mock_cover.execute_action.assert_called_once_with("close", ())
assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPEN
async def test_cover_stop_action(
async def test_cover_closing(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that stop_cover works."""
"""Test that the cover closing state is reported."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_cover.state.str_value.return_value = "closing"
mock_fibaro_client.read_devices.return_value = [mock_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
await hass.services.async_call(
"cover",
"stop_cover",
{"entity_id": "cover.room_1_test_cover_4"},
blocking=True,
)
# Assert
mock_cover.execute_action.assert_called_once_with("stop", ())
async def test_cover_open_slats_action(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that open_cover_tilt works."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_fibaro_client.read_devices.return_value = [mock_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
await hass.services.async_call(
"cover",
"open_cover_tilt",
{"entity_id": "cover.room_1_test_cover_4"},
blocking=True,
)
# Assert
mock_cover.execute_action.assert_called_once_with("rotateSlatsUp", ())
async def test_cover_close_tilt_action(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that close_cover_tilt works."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_fibaro_client.read_devices.return_value = [mock_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
await hass.services.async_call(
"cover",
"close_cover_tilt",
{"entity_id": "cover.room_1_test_cover_4"},
blocking=True,
)
# Assert
mock_cover.execute_action.assert_called_once_with("rotateSlatsDown", ())
async def test_cover_stop_slats_action(
hass: HomeAssistant,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_cover: Mock,
mock_room: Mock,
) -> None:
"""Test that stop_cover_tilt works."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_fibaro_client.read_devices.return_value = [mock_cover]
with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]):
# Act
await init_integration(hass, mock_config_entry)
await hass.services.async_call(
"cover",
"stop_cover_tilt",
{"entity_id": "cover.room_1_test_cover_4"},
blocking=True,
)
# Assert
mock_cover.execute_action.assert_called_once_with("stopSlats", ())
assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.CLOSING
-13
View File
@@ -260,16 +260,3 @@ def all_setup_requests(
},
},
)
@pytest.fixture
def arch() -> str:
"""Arch found in apk file."""
return "amd64"
@pytest.fixture(autouse=True)
def mock_arch_file(arch: str) -> Generator[None]:
"""Mock arch file."""
with patch("homeassistant.components.hassio._get_arch", return_value=arch):
yield
+26 -87
View File
@@ -1156,11 +1156,7 @@ def test_deprecated_constants(
("rpi2", "deprecated_os_armv7"),
],
)
@pytest.mark.parametrize(
"arch",
["armv7"],
)
async def test_deprecated_installation_issue_os_armv7(
async def test_deprecated_installation_issue_aarch64(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
freezer: FrozenDateTimeFactory,
@@ -1171,15 +1167,18 @@ async def test_deprecated_installation_issue_os_armv7(
with (
patch.dict(os.environ, MOCK_ENVIRON),
patch(
"homeassistant.components.homeassistant.async_get_system_info",
"homeassistant.components.hassio.async_get_system_info",
return_value={
"installation_type": "Home Assistant OS",
"arch": "armv7",
},
),
patch(
"homeassistant.components.hassio._is_32_bit",
return_value=True,
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant OS",
"arch": "armv7",
},
),
patch(
"homeassistant.components.hassio.get_os_info", return_value={"board": board}
@@ -1229,7 +1228,7 @@ async def test_deprecated_installation_issue_os_armv7(
"armv7",
],
)
async def test_deprecated_installation_issue_32bit_os(
async def test_deprecated_installation_issue_32bit_method(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
freezer: FrozenDateTimeFactory,
@@ -1239,15 +1238,18 @@ async def test_deprecated_installation_issue_32bit_os(
with (
patch.dict(os.environ, MOCK_ENVIRON),
patch(
"homeassistant.components.homeassistant.async_get_system_info",
"homeassistant.components.hassio.async_get_system_info",
return_value={
"installation_type": "Home Assistant OS",
"arch": arch,
},
),
patch(
"homeassistant.components.hassio._is_32_bit",
return_value=True,
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant OS",
"arch": arch,
},
),
patch(
"homeassistant.components.hassio.get_os_info",
@@ -1306,15 +1308,18 @@ async def test_deprecated_installation_issue_32bit_supervised(
with (
patch.dict(os.environ, MOCK_ENVIRON),
patch(
"homeassistant.components.homeassistant.async_get_system_info",
"homeassistant.components.hassio.async_get_system_info",
return_value={
"installation_type": "Home Assistant Supervised",
"arch": arch,
},
),
patch(
"homeassistant.components.hassio._is_32_bit",
return_value=True,
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Supervised",
"arch": arch,
},
),
patch(
"homeassistant.components.hassio.get_os_info",
@@ -1360,75 +1365,6 @@ async def test_deprecated_installation_issue_32bit_supervised(
}
@pytest.mark.parametrize(
"arch",
[
"amd64",
"aarch64",
],
)
async def test_deprecated_installation_issue_64bit_supervised(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
freezer: FrozenDateTimeFactory,
arch: str,
) -> None:
"""Test deprecated architecture issue."""
with (
patch.dict(os.environ, MOCK_ENVIRON),
patch(
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Supervised",
"arch": arch,
},
),
patch(
"homeassistant.components.hassio._is_32_bit",
return_value=False,
),
patch(
"homeassistant.components.hassio.get_os_info",
return_value={"board": "generic-x86-64"},
),
patch(
"homeassistant.components.hassio.get_info", return_value={"hassos": None}
),
patch("homeassistant.components.hardware.async_setup", return_value=True),
):
assert await async_setup_component(hass, "homeassistant", {})
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
freezer.tick(REQUEST_REFRESH_DELAY)
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.services.async_call(
"homeassistant",
"update_entity",
{
"entity_id": [
"update.home_assistant_core_update",
"update.home_assistant_supervisor_update",
]
},
blocking=True,
)
freezer.tick(HASSIO_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue("homeassistant", "deprecated_method")
assert issue.domain == "homeassistant"
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.translation_placeholders == {
"installation_type": "Supervised",
"arch": arch,
}
@pytest.mark.parametrize(
("board", "issue_id"),
[
@@ -1446,15 +1382,18 @@ async def test_deprecated_installation_issue_supported_board(
with (
patch.dict(os.environ, MOCK_ENVIRON),
patch(
"homeassistant.components.homeassistant.async_get_system_info",
"homeassistant.components.hassio.async_get_system_info",
return_value={
"installation_type": "Home Assistant OS",
"arch": "aarch64",
},
),
patch(
"homeassistant.components.hassio._is_32_bit",
return_value=False,
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant OS",
"arch": "aarch64",
},
),
patch(
"homeassistant.components.hassio.get_os_info", return_value={"board": board}
+49 -57
View File
@@ -648,24 +648,18 @@ async def test_reload_all(
"armv7",
],
)
async def test_deprecated_installation_issue_32bit_core(
async def test_deprecated_installation_issue_32bit_method(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
arch: str,
) -> None:
"""Test deprecated installation issue."""
with (
patch(
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Core",
"arch": arch,
},
),
patch(
"homeassistant.components.homeassistant._is_32_bit",
return_value=True,
),
with patch(
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Core",
"arch": arch,
},
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
@@ -683,28 +677,46 @@ async def test_deprecated_installation_issue_32bit_core(
@pytest.mark.parametrize(
"arch",
[
"aarch64",
"generic-x86-64",
"i386",
"armhf",
],
)
async def test_deprecated_installation_issue_64bit_core(
async def test_deprecated_installation_issue_32bit(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
arch: str,
) -> None:
"""Test deprecated installation issue."""
with (
patch(
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Core",
"arch": arch,
},
),
patch(
"homeassistant.components.homeassistant._is_32_bit",
return_value=False,
),
with patch(
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Container",
"arch": arch,
},
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_architecture")
assert issue.domain == DOMAIN
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.translation_placeholders == {
"installation_type": "Container",
"arch": arch,
}
async def test_deprecated_installation_issue_method(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test deprecated installation issue."""
with patch(
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Core",
"arch": "generic-x86-64",
},
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
@@ -715,46 +727,26 @@ async def test_deprecated_installation_issue_64bit_core(
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.translation_placeholders == {
"installation_type": "Core",
"arch": arch,
"arch": "generic-x86-64",
}
@pytest.mark.parametrize(
"arch",
[
"i386",
"armv7",
"armhf",
],
)
async def test_deprecated_installation_issue_32bit(
async def test_deprecated_installation_issue_armv7_container(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
arch: str,
) -> None:
"""Test deprecated installation issue."""
with (
patch(
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Container",
"arch": arch,
},
),
patch(
"homeassistant.components.homeassistant._is_32_bit",
return_value=True,
),
patch(
"homeassistant.components.homeassistant._get_arch",
return_value=arch,
),
with patch(
"homeassistant.components.homeassistant.async_get_system_info",
return_value={
"installation_type": "Home Assistant Container",
"arch": "armv7",
},
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_container")
issue = issue_registry.async_get_issue(DOMAIN, "deprecated_container_armv7")
assert issue.domain == DOMAIN
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.translation_placeholders == {"arch": arch}
+8
View File
@@ -25,6 +25,14 @@ MOCK_CONFIG_ENTRY_DATA = {
CONF_VERIFY_SSL: False,
}
MOCK_CONFIG_ENTRY_DATA_HASSIO = {
CONF_HOST: "172.16.10.1",
CONF_API_KEY: "abcdef0123456789",
CONF_PORT: 8080,
CONF_SSL: False,
CONF_VERIFY_SSL: False,
}
ALBUM_DATA = {
"id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
"albumName": "My Album",
+122 -3
View File
@@ -7,12 +7,13 @@ from aioimmich.exceptions import ImmichUnauthorizedError
import pytest
from homeassistant.components.immich.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_USER_DATA
from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_CONFIG_ENTRY_DATA_HASSIO, MOCK_USER_DATA
from tests.common import MockConfigEntry
@@ -242,3 +243,121 @@ async def test_reauth_flow_mismatch(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
async def test_hassio_flow(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock
) -> None:
"""Test discovery via hassio."""
test_data = HassioServiceInfo(
config={
"host": "172.16.10.1",
"port": 8080,
"ssl": False,
"addon": "IMMICH",
},
name="IMMICH",
slug="immich",
uuid="1234",
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_HASSIO},
data=test_data,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "hassio_confirm"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: MOCK_USER_DATA[CONF_API_KEY],
CONF_VERIFY_SSL: MOCK_USER_DATA[CONF_VERIFY_SSL],
},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "user"
assert result2["data"] == MOCK_CONFIG_ENTRY_DATA_HASSIO
assert result2["result"].unique_id == "e7ef5713-9dab-4bd4-b899-715b0ca4379e"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(
ImmichUnauthorizedError(
{
"message": "Invalid API key",
"error": "Unauthenticated",
"statusCode": 401,
"correlationId": "abcdefg",
}
),
"invalid_auth",
),
(ClientError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_hassio_flow_error_handling(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_immich: Mock,
exception: Exception,
error: str,
) -> None:
"""Test error handling during discovery via hassio."""
test_data = HassioServiceInfo(
config={
"host": "172.16.10.1",
"port": 8080,
"ssl": False,
"addon": "IMMICH",
},
name="IMMICH",
slug="immich",
uuid="1234",
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_HASSIO},
data=test_data,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "hassio_confirm"
mock_immich.users.async_get_my_user.side_effect = exception
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: MOCK_USER_DATA[CONF_API_KEY],
CONF_VERIFY_SSL: MOCK_USER_DATA[CONF_VERIFY_SSL],
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "hassio_confirm"
assert result2["errors"] == {"base": error}
mock_immich.users.async_get_my_user.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_KEY: MOCK_USER_DATA[CONF_API_KEY],
CONF_VERIFY_SSL: MOCK_USER_DATA[CONF_VERIFY_SSL],
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "user"
assert result3["data"] == MOCK_CONFIG_ENTRY_DATA_HASSIO
assert result3["result"].unique_id == "e7ef5713-9dab-4bd4-b899-715b0ca4379e"
-49
View File
@@ -260,33 +260,6 @@ MOCK_BLU_TRV_REMOTE_CONFIG = {
"meta": {},
},
},
{
"key": "blutrv:201",
"status": {
"id": 201,
"target_C": 17.1,
"current_C": 17.1,
"pos": 0,
"rssi": -60,
"battery": 100,
"packet_id": 58,
"last_updated_ts": 1734967725,
"paired": True,
"rpc": True,
"rsv": 61,
},
"config": {
"id": 201,
"addr": "f8:44:77:25:f0:de",
"name": "TRV-201",
"key": None,
"trv": "bthomedevice:201",
"temp_sensors": [],
"dw_sensors": [],
"override_delay": 30,
"meta": {},
},
},
],
"blutrv:200": {
"id": 0,
@@ -299,17 +272,6 @@ MOCK_BLU_TRV_REMOTE_CONFIG = {
"name": "TRV-Name",
"local_name": "SBTR-001AEU",
},
"blutrv:201": {
"id": 1,
"enable": True,
"min_valve_position": 0,
"default_boost_duration": 1800,
"default_override_duration": 2147483647,
"default_override_target_C": 8,
"addr": "f8:44:77:25:f0:de",
"name": "TRV-201",
"local_name": "SBTR-001AEU",
},
}
@@ -325,17 +287,6 @@ MOCK_BLU_TRV_REMOTE_STATUS = {
"battery": 100,
"errors": [],
},
"blutrv:201": {
"id": 0,
"pos": 0,
"steps": 0,
"current_C": 15.2,
"target_C": 17.1,
"schedule_rev": 0,
"rssi": -60,
"battery": 100,
"errors": [],
},
}
+1 -48
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, call, patch
from aioshelly.block_device import COAP
from aioshelly.common import ConnectionOptions
from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM
from aioshelly.const import MODEL_PLUS_2PM
from aioshelly.exceptions import (
DeviceConnectionError,
InvalidAuthError,
@@ -38,7 +38,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import DeviceRegistry, format_mac
from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.setup import async_setup_component
from . import MOCK_MAC, init_integration, mutate_rpc_device_status
@@ -607,49 +606,3 @@ async def test_ble_scanner_unsupported_firmware_fixed(
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0
async def test_blu_trv_stale_device_removal(
hass: HomeAssistant,
mock_blu_trv: Mock,
entity_registry: EntityRegistry,
device_registry: DeviceRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test BLU TRV removal of stale a device after un-pairing."""
trv_200_entity_id = "climate.trv_name"
trv_201_entity_id = "climate.trv_201"
monkeypatch.setattr(mock_blu_trv, "model", MODEL_BLU_GATEWAY_G3)
gw_entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3)
# verify that both trv devices are present
assert hass.states.get(trv_200_entity_id) is not None
trv_200_entry = entity_registry.async_get(trv_200_entity_id)
assert trv_200_entry
trv_200_device_entry = device_registry.async_get(trv_200_entry.device_id)
assert trv_200_device_entry
assert trv_200_device_entry.name == "TRV-Name"
assert hass.states.get(trv_201_entity_id) is not None
trv_201_entry = entity_registry.async_get(trv_201_entity_id)
assert trv_201_entry
trv_201_device_entry = device_registry.async_get(trv_201_entry.device_id)
assert trv_201_device_entry
assert trv_201_device_entry.name == "TRV-201"
# simulate un-pairing of trv 201 device
monkeypatch.delitem(mock_blu_trv.config, "blutrv:201")
monkeypatch.delitem(mock_blu_trv.status, "blutrv:201")
await hass.config_entries.async_reload(gw_entry.entry_id)
await hass.async_block_till_done()
# verify that trv 201 is removed
assert hass.states.get(trv_200_entity_id) is not None
assert device_registry.async_get(trv_200_entry.device_id) is not None
assert hass.states.get(trv_201_entity_id) is None
assert device_registry.async_get(trv_201_entry.device_id) is None
@@ -19,8 +19,8 @@ from homeassistant.components.telegram_bot.const import (
ERROR_MESSAGE,
ISSUE_DEPRECATED_YAML,
ISSUE_DEPRECATED_YAML_IMPORT_ISSUE_ERROR,
PARSER_HTML,
PARSER_MD,
PARSER_PLAIN_TEXT,
PLATFORM_BROADCAST,
PLATFORM_WEBHOOKS,
SUBENTRY_TYPE_ALLOWED_CHAT_IDS,
@@ -56,13 +56,13 @@ async def test_options_flow(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{
ATTR_PARSER: PARSER_PLAIN_TEXT,
ATTR_PARSER: PARSER_HTML,
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][ATTR_PARSER] is None
assert result["data"][ATTR_PARSER] == PARSER_HTML
async def test_reconfigure_flow_broadcast(
+2
View File
@@ -827,10 +827,12 @@ async def test_setup_source(hass: HomeAssistant) -> None:
assert entity.entity_sources(hass) == {
"test_domain.platform_config_source": {
"custom_component": False,
"domain": "test_platform",
},
"test_domain.config_entry_source": {
"config_entry": platform.config_entry.entry_id,
"custom_component": False,
"domain": "test_platform",
},
}
+41
View File
@@ -0,0 +1,41 @@
"""Test backports package."""
from __future__ import annotations
from enum import StrEnum
from functools import cached_property # pylint: disable=hass-deprecated-import
from types import ModuleType
from typing import Any
import pytest
from homeassistant.backports import (
enum as backports_enum,
functools as backports_functools,
)
from .common import import_and_test_deprecated_alias
@pytest.mark.parametrize(
("module", "replacement", "breaks_in_ha_version"),
[
(backports_enum, StrEnum, "2025.5"),
(backports_functools, cached_property, "2025.5"),
],
)
def test_deprecated_aliases(
caplog: pytest.LogCaptureFixture,
module: ModuleType,
replacement: Any,
breaks_in_ha_version: str,
) -> None:
"""Test deprecated aliases."""
alias_name = replacement.__name__
import_and_test_deprecated_alias(
caplog,
module,
alias_name,
replacement,
breaks_in_ha_version,
)