diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 87fed908c6e..94e876aa3ab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,15 +1,14 @@ name: Report an issue with Home Assistant Core description: Report an issue with Home Assistant Core. -type: Bug body: - type: markdown attributes: value: | This issue form is for reporting bugs only! - If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr]. + If you have a feature or enhancement request, please [request them here instead][fr]. - [fr]: https://community.home-assistant.io/c/feature-requests + [fr]: https://github.com/orgs/home-assistant/discussions - type: textarea validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8a4c7d46708..e14233edfc9 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -10,8 +10,8 @@ contact_links: url: https://www.home-assistant.io/help about: We use GitHub for tracking bugs, check our website for resources on getting help. - name: Feature Request - url: https://community.home-assistant.io/c/feature-requests - about: Please use our Community Forum for making feature requests. + url: https://github.com/orgs/home-assistant/discussions + about: Please use this link to request new features or enhancements to existing features. - name: I'm unsure where to go url: https://www.home-assistant.io/join-chat about: If you are unsure where to go, then joining our chat is recommended; Just ask! diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index dd4bded2cc5..d7bbfc8fa5e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -94,7 +94,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v10 + uses: dawidd6/action-download-artifact@v11 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,10 +105,10 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v10 + uses: dawidd6/action-download-artifact@v11 with: github_token: ${{secrets.GITHUB_TOKEN}} - repo: home-assistant/intents-package + repo: OHF-Voice/intents-package branch: main workflow: nightly.yaml workflow_conclusion: success @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5a5172f513f..19cc8bd3af7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 2 + CACHE_VERSION: 3 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.7" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 36902d13356..583cfdd211c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.19 + uses: github/codeql-action/init@v3.29.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.19 + uses: github/codeql-action/analyze@v3.29.0 with: category: "/language:python" diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml new file mode 100644 index 00000000000..b01a0d68352 --- /dev/null +++ b/.github/workflows/detect-duplicate-issues.yml @@ -0,0 +1,385 @@ +name: Auto-detect duplicate issues + +# yamllint disable-line rule:truthy +on: + issues: + types: [labeled] + +permissions: + issues: write + models: read + +jobs: + detect-duplicates: + runs-on: ubuntu-latest + + steps: + - name: Check if integration label was added and extract details + id: extract + uses: actions/github-script@v7.0.1 + with: + script: | + // Debug: Log the event payload + console.log('Event name:', context.eventName); + console.log('Event action:', context.payload.action); + console.log('Event payload keys:', Object.keys(context.payload)); + + // Check the specific label that was added + const addedLabel = context.payload.label; + if (!addedLabel) { + console.log('No label found in labeled event payload'); + core.setOutput('should_continue', 'false'); + return; + } + + console.log(`Label added: ${addedLabel.name}`); + + if (!addedLabel.name.startsWith('integration:')) { + console.log('Added label is not an integration label, skipping duplicate detection'); + core.setOutput('should_continue', 'false'); + return; + } + + console.log(`Integration label added: ${addedLabel.name}`); + + let currentIssue; + let integrationLabels = []; + + try { + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number + }); + + currentIssue = issue.data; + + // Check if potential-duplicate label already exists + const hasPotentialDuplicateLabel = currentIssue.labels + .some(label => label.name === 'potential-duplicate'); + + if (hasPotentialDuplicateLabel) { + console.log('Issue already has potential-duplicate label, skipping duplicate detection'); + core.setOutput('should_continue', 'false'); + return; + } + + integrationLabels = currentIssue.labels + .filter(label => label.name.startsWith('integration:')) + .map(label => label.name); + } catch (error) { + core.error(`Failed to fetch issue #${context.payload.issue.number}:`, error.message); + core.setOutput('should_continue', 'false'); + return; + } + + // Check if we've already posted a duplicate detection comment recently + let comments; + try { + comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + per_page: 10 + }); + } catch (error) { + core.error('Failed to fetch comments:', error.message); + // Continue anyway, worst case we might post a duplicate comment + comments = { data: [] }; + } + + // Check if we've already posted a duplicate detection comment + const recentDuplicateComment = comments.data.find(comment => + comment.user && comment.user.login === 'github-actions[bot]' && + comment.body.includes('') + ); + + if (recentDuplicateComment) { + console.log('Already posted duplicate detection comment, skipping'); + core.setOutput('should_continue', 'false'); + return; + } + + core.setOutput('should_continue', 'true'); + core.setOutput('current_number', currentIssue.number); + core.setOutput('current_title', currentIssue.title); + core.setOutput('current_body', currentIssue.body); + core.setOutput('current_url', currentIssue.html_url); + core.setOutput('integration_labels', JSON.stringify(integrationLabels)); + + console.log(`Current issue: #${currentIssue.number}`); + console.log(`Integration labels: ${integrationLabels.join(', ')}`); + + - name: Fetch similar issues + id: fetch_similar + if: steps.extract.outputs.should_continue == 'true' + uses: actions/github-script@v7.0.1 + env: + INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }} + CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }} + with: + script: | + const integrationLabels = JSON.parse(process.env.INTEGRATION_LABELS); + const currentNumber = parseInt(process.env.CURRENT_NUMBER); + + if (integrationLabels.length === 0) { + console.log('No integration labels found, skipping duplicate detection'); + core.setOutput('has_similar', 'false'); + return; + } + + // Use GitHub search API to find issues with matching integration labels + console.log(`Searching for issues with integration labels: ${integrationLabels.join(', ')}`); + + // Build search query for issues with any of the current integration labels + const labelQueries = integrationLabels.map(label => `label:"${label}"`); + + // Calculate date 6 months ago + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + const dateFilter = `created:>=${sixMonthsAgo.toISOString().split('T')[0]}`; + + let searchQuery; + + if (labelQueries.length === 1) { + searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue ${labelQueries[0]} ${dateFilter}`; + } else { + searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue (${labelQueries.join(' OR ')}) ${dateFilter}`; + } + + console.log(`Search query: ${searchQuery}`); + + let result; + try { + result = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 15, + sort: 'updated', + order: 'desc' + }); + } catch (error) { + core.error('Failed to search for similar issues:', error.message); + if (error.status === 403 && error.message.includes('rate limit')) { + core.error('GitHub API rate limit exceeded'); + } + core.setOutput('has_similar', 'false'); + return; + } + + // Filter out the current issue, pull requests, and newer issues (higher numbers) + const similarIssues = result.data.items + .filter(item => + item.number !== currentNumber && + !item.pull_request && + item.number < currentNumber // Only include older issues (lower numbers) + ) + .map(item => ({ + number: item.number, + title: item.title, + body: item.body, + url: item.html_url, + state: item.state, + createdAt: item.created_at, + updatedAt: item.updated_at, + comments: item.comments, + labels: item.labels.map(l => l.name) + })); + + console.log(`Found ${similarIssues.length} issues with matching integration labels`); + console.log('Raw similar issues:', JSON.stringify(similarIssues.slice(0, 3), null, 2)); + + if (similarIssues.length === 0) { + console.log('No similar issues found, setting has_similar to false'); + core.setOutput('has_similar', 'false'); + return; + } + + console.log('Similar issues found, setting has_similar to true'); + core.setOutput('has_similar', 'true'); + + // Clean the issue data to prevent JSON parsing issues + const cleanedIssues = similarIssues.slice(0, 15).map(item => { + // Handle body with improved truncation and null handling + let cleanBody = ''; + if (item.body && typeof item.body === 'string') { + // Remove control characters + const cleaned = item.body.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''); + // Truncate to 1000 characters and add ellipsis if needed + cleanBody = cleaned.length > 1000 + ? cleaned.substring(0, 1000) + '...' + : cleaned; + } + + return { + number: item.number, + title: item.title.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''), // Remove control characters + body: cleanBody, + url: item.url, + state: item.state, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + comments: item.comments, + labels: item.labels + }; + }); + + console.log(`Cleaned issues count: ${cleanedIssues.length}`); + console.log('First cleaned issue:', JSON.stringify(cleanedIssues[0], null, 2)); + + core.setOutput('similar_issues', JSON.stringify(cleanedIssues)); + + - name: Detect duplicates using AI + id: ai_detection + if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' + uses: actions/ai-inference@v1.1.0 + with: + model: openai/gpt-4o + system-prompt: | + You are a Home Assistant issue duplicate detector. Your task is to identify TRUE DUPLICATES - issues that report the EXACT SAME problem, not just similar or related issues. + + CRITICAL: An issue is ONLY a duplicate if: + - It describes the SAME problem with the SAME root cause + - Issues about the same integration but different problems are NOT duplicates + - Issues with similar symptoms but different causes are NOT duplicates + + Important considerations: + - Open issues are more relevant than closed ones for duplicate detection + - Recently updated issues may indicate ongoing work or discussion + - Issues with more comments are generally more relevant and active + - Older closed issues might be resolved differently than newer approaches + - Consider the time between issues - very old issues may have different contexts + + Rules: + 1. ONLY mark as duplicate if the issues describe IDENTICAL problems + 2. Look for issues that report the same problem or request the same functionality + 3. Different error messages = NOT a duplicate (even if same integration) + 4. For CLOSED issues, only mark as duplicate if they describe the EXACT same problem + 5. For OPEN issues, use a lower threshold (90%+ similarity) + 6. Prioritize issues with higher comment counts as they indicate more activity/relevance + 7. When in doubt, do NOT mark as duplicate + 8. Return ONLY a JSON array of issue numbers that are duplicates + 9. If no duplicates are found, return an empty array: [] + 10. Maximum 5 potential duplicates, prioritize open issues with comments + 11. Consider the age of issues - prefer recent duplicates over very old ones + + Example response format: + [1234, 5678, 9012] + + prompt: | + Current issue (just created): + Title: ${{ steps.extract.outputs.current_title }} + Body: ${{ steps.extract.outputs.current_body }} + + Other issues to compare against (each includes state, creation date, last update, and comment count): + ${{ steps.fetch_similar.outputs.similar_issues }} + + Analyze these issues and identify which ones describe IDENTICAL problems and thus are duplicates of the current issue. When sorting them, consider their state (open/closed), how recently they were updated, and their comment count (higher = more relevant). + + max-tokens: 100 + + - name: Post duplicate detection results + id: post_results + if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' + uses: actions/github-script@v7.0.1 + env: + AI_RESPONSE: ${{ steps.ai_detection.outputs.response }} + SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }} + with: + script: | + const aiResponse = process.env.AI_RESPONSE; + + console.log('Raw AI response:', JSON.stringify(aiResponse)); + + let duplicateNumbers = []; + try { + // Clean the response of any potential control characters + const cleanResponse = aiResponse.trim().replace(/[\u0000-\u001F\u007F-\u009F]/g, ''); + console.log('Cleaned AI response:', cleanResponse); + + duplicateNumbers = JSON.parse(cleanResponse); + + // Ensure it's an array and contains only numbers + if (!Array.isArray(duplicateNumbers)) { + console.log('AI response is not an array, trying to extract numbers'); + const numberMatches = cleanResponse.match(/\d+/g); + duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : []; + } + + // Filter to only valid numbers + duplicateNumbers = duplicateNumbers.filter(n => typeof n === 'number' && !isNaN(n)); + + } catch (error) { + console.log('Failed to parse AI response as JSON:', error.message); + console.log('Raw response:', aiResponse); + + // Fallback: try to extract numbers from the response + const numberMatches = aiResponse.match(/\d+/g); + duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : []; + console.log('Extracted numbers as fallback:', duplicateNumbers); + } + + if (!Array.isArray(duplicateNumbers) || duplicateNumbers.length === 0) { + console.log('No duplicates detected by AI'); + return; + } + + console.log(`AI detected ${duplicateNumbers.length} potential duplicates: ${duplicateNumbers.join(', ')}`); + + // Get details of detected duplicates + const similarIssues = JSON.parse(process.env.SIMILAR_ISSUES); + const duplicates = similarIssues.filter(issue => duplicateNumbers.includes(issue.number)); + + if (duplicates.length === 0) { + console.log('No matching issues found for detected numbers'); + return; + } + + // Create comment with duplicate detection results + const duplicateLinks = duplicates.map(issue => `- [#${issue.number}: ${issue.title}](${issue.url})`).join('\n'); + + const commentBody = [ + '', + '### 🔍 **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 + } diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml new file mode 100644 index 00000000000..264b8ab9854 --- /dev/null +++ b/.github/workflows/detect-non-english-issues.yml @@ -0,0 +1,193 @@ +name: Auto-detect non-English issues + +# yamllint disable-line rule:truthy +on: + issues: + types: [opened] + +permissions: + issues: write + models: read + +jobs: + detect-language: + runs-on: ubuntu-latest + + steps: + - name: Check issue language + id: detect_language + uses: actions/github-script@v7.0.1 + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_USER_TYPE: ${{ github.event.issue.user.type }} + with: + script: | + // Get the issue details from environment variables + const issueNumber = process.env.ISSUE_NUMBER; + const issueTitle = process.env.ISSUE_TITLE || ''; + const issueBody = process.env.ISSUE_BODY || ''; + const userType = process.env.ISSUE_USER_TYPE; + + // Skip language detection for bot users + if (userType === 'Bot') { + console.log('Skipping language detection for bot user'); + core.setOutput('should_continue', 'false'); + return; + } + + console.log(`Checking language for issue #${issueNumber}`); + console.log(`Title: ${issueTitle}`); + + // Combine title and body for language detection + const fullText = `${issueTitle}\n\n${issueBody}`; + + // Check if the text is too short to reliably detect language + if (fullText.trim().length < 20) { + console.log('Text too short for reliable language detection'); + core.setOutput('should_continue', 'false'); // Skip processing for very short text + return; + } + + core.setOutput('issue_number', issueNumber); + core.setOutput('issue_text', fullText); + core.setOutput('should_continue', 'true'); + + - name: Detect language using AI + id: ai_language_detection + if: steps.detect_language.outputs.should_continue == 'true' + uses: actions/ai-inference@v1.1.0 + with: + model: openai/gpt-4o-mini + system-prompt: | + You are a language detection system. Your task is to determine if the provided text is written in English or another language. + + Rules: + 1. Analyze the text and determine the primary language of the USER'S DESCRIPTION only + 2. IGNORE markdown headers (lines starting with #, ##, ###, etc.) as these are from issue templates, not user input + 3. IGNORE all code blocks (text between ``` or ` markers) as they may contain system-generated error messages in other languages + 4. IGNORE error messages, logs, and system output even if not in code blocks - these often appear in the user's system language + 5. Consider technical terms, code snippets, URLs, and file paths as neutral (they don't indicate non-English) + 6. Focus ONLY on the actual sentences and descriptions written by the user explaining their issue + 7. If the user's explanation/description is in English but includes non-English error messages or logs, consider it ENGLISH + 8. Return ONLY a JSON object with two fields: + - "is_english": boolean (true if the user's description is primarily in English, false otherwise) + - "detected_language": string (the name of the detected language, e.g., "English", "Spanish", "Chinese", etc.) + 9. Be lenient - if the user's explanation is in English with non-English system output, it's still English + 10. Common programming terms, error messages, and technical jargon should not be considered as non-English + 11. If you cannot reliably determine the language, set detected_language to "undefined" + + Example response: + {"is_english": false, "detected_language": "Spanish"} + + prompt: | + Please analyze the following issue text and determine if it is written in English: + + ${{ steps.detect_language.outputs.issue_text }} + + max-tokens: 50 + + - name: Process non-English issues + if: steps.detect_language.outputs.should_continue == 'true' + uses: actions/github-script@v7.0.1 + env: + AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }} + ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }} + with: + script: | + const issueNumber = parseInt(process.env.ISSUE_NUMBER); + const aiResponse = process.env.AI_RESPONSE; + + console.log('AI language detection response:', aiResponse); + + let languageResult; + try { + languageResult = JSON.parse(aiResponse.trim()); + + // Validate the response structure + if (!languageResult || typeof languageResult.is_english !== 'boolean') { + throw new Error('Invalid response structure'); + } + } catch (error) { + core.error(`Failed to parse AI response: ${error.message}`); + console.log('Raw AI response:', aiResponse); + + // Log more details for debugging + core.warning('Defaulting to English due to parsing error'); + + // Default to English if we can't parse the response + return; + } + + if (languageResult.is_english) { + console.log('Issue is in English, no action needed'); + return; + } + + // If language is undefined or not detected, skip processing + if (!languageResult.detected_language || languageResult.detected_language === 'undefined') { + console.log('Language could not be determined, skipping processing'); + return; + } + + console.log(`Issue detected as non-English: ${languageResult.detected_language}`); + + // Post comment explaining the language requirement + const commentBody = [ + '', + '### 🌐 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'); + } + } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf896f8b12c..30351a9381e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.12 + rev: v0.12.0 hooks: - id: ruff-check args: diff --git a/.strict-typing b/.strict-typing index 4febfd68486..b34cbfa5fca 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 3f3ce07ce84..1ceb6ff0e7d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -57,6 +57,8 @@ build.json @home-assistant/supervisor /tests/components/aemet/ @Noltari /homeassistant/components/agent_dvr/ @ispysoftware /tests/components/agent_dvr/ @ispysoftware +/homeassistant/components/ai_task/ @home-assistant/core +/tests/components/ai_task/ @home-assistant/core /homeassistant/components/air_quality/ @home-assistant/core /tests/components/air_quality/ @home-assistant/core /homeassistant/components/airgradient/ @airgradienthq @joostlek @@ -89,8 +91,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/amazon_devices/ @chemelli74 -/tests/components/amazon_devices/ @chemelli74 +/homeassistant/components/alexa_devices/ @chemelli74 +/tests/components/alexa_devices/ @chemelli74 /homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot @@ -1274,8 +1276,8 @@ build.json @home-assistant/supervisor /tests/components/rehlko/ @bdraco @peterager /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core -/homeassistant/components/remote_calendar/ @Thomas55555 -/tests/components/remote_calendar/ @Thomas55555 +/homeassistant/components/remote_calendar/ @Thomas55555 @allenporter +/tests/components/remote_calendar/ @Thomas55555 @allenporter /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet /homeassistant/components/renson/ @jimmyd-be diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index b9d98832705..6fd48c4809c 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -38,8 +38,7 @@ def validate_python() -> None: def ensure_config_path(config_dir: str) -> None: """Validate the configuration directory.""" - # pylint: disable-next=import-outside-toplevel - from . import config as config_util + from . import config as config_util # noqa: PLC0415 lib_dir = os.path.join(config_dir, "deps") @@ -80,8 +79,7 @@ def ensure_config_path(config_dir: str) -> None: def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" - # pylint: disable-next=import-outside-toplevel - from . import config as config_util + from . import config as config_util # noqa: PLC0415 parser = argparse.ArgumentParser( description="Home Assistant: Observe, Control, Automate.", @@ -177,8 +175,7 @@ def main() -> int: validate_os() if args.script is not None: - # pylint: disable-next=import-outside-toplevel - from . import scripts + from . import scripts # noqa: PLC0415 return scripts.run(args.script) @@ -188,8 +185,7 @@ def main() -> int: ensure_config_path(config_dir) - # pylint: disable-next=import-outside-toplevel - from . import config, runner + from . import config, runner # noqa: PLC0415 safe_mode = config.safe_mode_enabled(config_dir) diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index b60a3012aac..978758bebb1 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__) def _generate_secret() -> str: """Generate a secret.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 return str(pyotp.random_base32()) def _generate_random() -> int: """Generate a 32 digit number.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 return int(pyotp.random_base32(length=32, chars=list("1234567890"))) def _generate_otp(secret: str, count: int) -> str: """Generate one time password.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 return str(pyotp.HOTP(secret).at(count)) def _verify_otp(secret: str, otp: str, count: int) -> bool: """Verify one time password.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 return bool(pyotp.HOTP(secret).verify(otp, count)) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 625b273f39a..b344043b832 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG" def _generate_qr_code(data: str) -> str: """Generate a base64 PNG string represent QR Code image of data.""" - import pyqrcode # pylint: disable=import-outside-toplevel + import pyqrcode # noqa: PLC0415 qr_code = pyqrcode.create(data) @@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str: def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]: """Generate a secret, url, and QR code.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 ota_secret = pyotp.random_base32() url = pyotp.totp.TOTP(ota_secret).provisioning_uri( @@ -107,7 +107,7 @@ class TotpAuthModule(MultiFactorAuthModule): def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str: """Create a ota_secret for user.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 ota_secret: str = secret or pyotp.random_base32() @@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule): def _validate_2fa(self, user_id: str, code: str) -> bool: """Validate two factor authentication code.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr] # even we cannot find user, we still do verify @@ -196,7 +196,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]): Return self.async_show_form(step_id='init') if user_input is None. Return self.async_create_entry(data={'result': result}) if finish. """ - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 errors: dict[str, str] = {} diff --git a/homeassistant/backports/enum.py b/homeassistant/backports/enum.py deleted file mode 100644 index 8b823f47e22..00000000000 --- a/homeassistant/backports/enum.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Enum backports from standard lib. - -This file contained the backport of the StrEnum of Python 3.11. - -Since we have dropped support for Python 3.10, we can remove this backport. -This file is kept for now to avoid breaking custom components that might -import it. -""" - -from __future__ import annotations - -from enum import StrEnum as _StrEnum -from functools import partial - -from homeassistant.helpers.deprecation import ( - DeprecatedAlias, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) - -# StrEnum deprecated as of 2024.5 use enum.StrEnum instead. -_DEPRECATED_StrEnum = DeprecatedAlias(_StrEnum, "enum.StrEnum", "2025.5") - -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py deleted file mode 100644 index 1b032c65966..00000000000 --- a/homeassistant/backports/functools.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Functools backports from standard lib. - -This file contained the backport of the cached_property implementation of Python 3.12. - -Since we have dropped support for Python 3.11, we can remove this backport. -This file is kept for now to avoid breaking custom components that might -import it. -""" - -from __future__ import annotations - -# pylint: disable-next=hass-deprecated-import -from functools import cached_property as _cached_property, partial - -from homeassistant.helpers.deprecation import ( - DeprecatedAlias, - all_with_deprecated_constants, - check_if_deprecated_constant, - dir_with_deprecated_constants, -) - -# cached_property deprecated as of 2024.5 use functools.cached_property instead. -_DEPRECATED_cached_property = DeprecatedAlias( - _cached_property, "functools.cached_property", "2025.5" -) - -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 55aeaef2554..810c1f1e8d2 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -394,7 +394,7 @@ async def async_setup_hass( def open_hass_ui(hass: core.HomeAssistant) -> None: """Open the UI.""" - import webbrowser # pylint: disable=import-outside-toplevel + import webbrowser # noqa: PLC0415 if hass.config.api is None or "frontend" not in hass.config.components: _LOGGER.warning("Cannot launch the UI because frontend not loaded") @@ -561,8 +561,7 @@ async def async_enable_logging( if not log_no_color: try: - # pylint: disable-next=import-outside-toplevel - from colorlog import ColoredFormatter + from colorlog import ColoredFormatter # noqa: PLC0415 # basicConfig must be called after importing colorlog in order to # ensure that the handlers it sets up wraps the correct streams. @@ -606,7 +605,7 @@ async def async_enable_logging( ) threading.excepthook = lambda args: logging.getLogger().exception( "Uncaught thread exception", - exc_info=( # type: ignore[arg-type] + exc_info=( # type: ignore[arg-type] # noqa: LOG014 args.exc_type, args.exc_value, args.exc_traceback, @@ -1060,5 +1059,5 @@ async def _async_setup_multi_components( _LOGGER.error( "Error setting up integration %s - received exception", domain, - exc_info=(type(result), result, result.__traceback__), + exc_info=(type(result), result, result.__traceback__), # noqa: LOG014 ) diff --git a/homeassistant/brands/amazon.json b/homeassistant/brands/amazon.json index d2e25468388..126b69c848d 100644 --- a/homeassistant/brands/amazon.json +++ b/homeassistant/brands/amazon.json @@ -3,7 +3,7 @@ "name": "Amazon", "integrations": [ "alexa", - "amazon_devices", + "alexa_devices", "amazon_polly", "aws", "aws_s3", diff --git a/homeassistant/components/abode/services.py b/homeassistant/components/abode/services.py index ffbdeb326f9..7862b3e6dfe 100644 --- a/homeassistant/components/abode/services.py +++ b/homeassistant/components/abode/services.py @@ -6,7 +6,7 @@ from jaraco.abode.exceptions import Exception as AbodeException import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send @@ -70,6 +70,7 @@ def _trigger_automation(call: ServiceCall) -> None: dispatcher_send(call.hass, signal) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Home Assistant services.""" diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py new file mode 100644 index 00000000000..8b3d6e04966 --- /dev/null +++ b/homeassistant/components/ai_task/__init__.py @@ -0,0 +1,125 @@ +"""Integration to offer AI tasks to Home Assistant.""" + +import logging + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import ( + HassJobType, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.helpers import config_validation as cv, storage +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType + +from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature +from .entity import AITaskEntity +from .http import async_setup as async_setup_conversation_http +from .task import GenTextTask, GenTextTaskResult, async_generate_text + +__all__ = [ + "DOMAIN", + "AITaskEntity", + "AITaskEntityFeature", + "GenTextTask", + "GenTextTaskResult", + "async_generate_text", + "async_setup", + "async_setup_entry", + "async_unload_entry", +] + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Register the process service.""" + entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass) + hass.data[DATA_COMPONENT] = entity_component + hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) + await hass.data[DATA_PREFERENCES].async_load() + async_setup_conversation_http(hass) + hass.services.async_register( + DOMAIN, + "generate_text", + async_service_generate_text, + schema=vol.Schema( + { + vol.Required("task_name"): cv.string, + vol.Optional("entity_id"): cv.entity_id, + vol.Required("instructions"): cv.string, + } + ), + supports_response=SupportsResponse.ONLY, + job_type=HassJobType.Coroutinefunction, + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) + + +async def async_service_generate_text(call: ServiceCall) -> ServiceResponse: + """Run the run task service.""" + result = await async_generate_text(hass=call.hass, **call.data) + return result.as_dict() # type: ignore[return-value] + + +class AITaskPreferences: + """AI Task preferences.""" + + KEYS = ("gen_text_entity_id",) + + gen_text_entity_id: str | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the preferences.""" + self._store: storage.Store[dict[str, str | None]] = storage.Store( + hass, 1, DOMAIN + ) + + async def async_load(self) -> None: + """Load the data from the store.""" + data = await self._store.async_load() + if data is None: + return + for key in self.KEYS: + setattr(self, key, data[key]) + + @callback + def async_set_preferences( + self, + *, + gen_text_entity_id: str | None | UndefinedType = UNDEFINED, + ) -> None: + """Set the preferences.""" + changed = False + for key, value in (("gen_text_entity_id", gen_text_entity_id),): + if value is not UNDEFINED: + if getattr(self, key) != value: + setattr(self, key, value) + changed = True + + if not changed: + return + + self._store.async_delay_save(self.as_dict, 10) + + @callback + def as_dict(self) -> dict[str, str | None]: + """Get the current preferences.""" + return {key: getattr(self, key) for key in self.KEYS} diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py new file mode 100644 index 00000000000..69786178583 --- /dev/null +++ b/homeassistant/components/ai_task/const.py @@ -0,0 +1,29 @@ +"""Constants for the AI Task integration.""" + +from __future__ import annotations + +from enum import IntFlag +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import AITaskPreferences + from .entity import AITaskEntity + +DOMAIN = "ai_task" +DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN) +DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences") + +DEFAULT_SYSTEM_PROMPT = ( + "You are a Home Assistant expert and help users with their tasks." +) + + +class AITaskEntityFeature(IntFlag): + """Supported features of the AI task entity.""" + + GENERATE_TEXT = 1 + """Generate text based on instructions.""" diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py new file mode 100644 index 00000000000..88ce8144fb7 --- /dev/null +++ b/homeassistant/components/ai_task/entity.py @@ -0,0 +1,103 @@ +"""Entity for the AI Task integration.""" + +from collections.abc import AsyncGenerator +import contextlib +from typing import final + +from propcache.api import cached_property + +from homeassistant.components.conversation import ( + ChatLog, + UserContent, + async_get_chat_log, +) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.helpers import llm +from homeassistant.helpers.chat_session import async_get_chat_session +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature +from .task import GenTextTask, GenTextTaskResult + + +class AITaskEntity(RestoreEntity): + """Entity that supports conversations.""" + + _attr_should_poll = False + _attr_supported_features = AITaskEntityFeature(0) + __last_activity: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_activity is None: + return None + return self.__last_activity + + @cached_property + def supported_features(self) -> AITaskEntityFeature: + """Flag supported features.""" + return self._attr_supported_features + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_activity = state.state + + @final + @contextlib.asynccontextmanager + async def _async_get_ai_task_chat_log( + self, + task: GenTextTask, + ) -> AsyncGenerator[ChatLog]: + """Context manager used to manage the ChatLog used during an AI Task.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + async_get_chat_session(self.hass) as session, + async_get_chat_log( + self.hass, + session, + None, + ) as chat_log, + ): + await chat_log.async_provide_llm_data( + llm.LLMContext( + platform=self.platform.domain, + context=None, + language=None, + assistant=DOMAIN, + device_id=None, + ), + user_llm_prompt=DEFAULT_SYSTEM_PROMPT, + ) + + chat_log.async_add_user_content(UserContent(task.instructions)) + + yield chat_log + + @final + async def internal_async_generate_text( + self, + task: GenTextTask, + ) -> GenTextTaskResult: + """Run a gen text task.""" + self.__last_activity = dt_util.utcnow().isoformat() + self.async_write_ha_state() + async with self._async_get_ai_task_chat_log(task) as chat_log: + return await self._async_generate_text(task, chat_log) + + async def _async_generate_text( + self, + task: GenTextTask, + chat_log: ChatLog, + ) -> GenTextTaskResult: + """Handle a gen text task.""" + raise NotImplementedError diff --git a/homeassistant/components/ai_task/http.py b/homeassistant/components/ai_task/http.py new file mode 100644 index 00000000000..6d44a4e8d3c --- /dev/null +++ b/homeassistant/components/ai_task/http.py @@ -0,0 +1,54 @@ +"""HTTP endpoint for AI Task integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DATA_PREFERENCES + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the HTTP API for the conversation integration.""" + websocket_api.async_register_command(hass, websocket_get_preferences) + websocket_api.async_register_command(hass, websocket_set_preferences) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ai_task/preferences/get", + } +) +@callback +def websocket_get_preferences( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get AI task preferences.""" + preferences = hass.data[DATA_PREFERENCES] + connection.send_result(msg["id"], preferences.as_dict()) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ai_task/preferences/set", + vol.Optional("gen_text_entity_id"): vol.Any(str, None), + } +) +@websocket_api.require_admin +@callback +def websocket_set_preferences( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set AI task preferences.""" + preferences = hass.data[DATA_PREFERENCES] + msg.pop("type") + msg_id = msg.pop("id") + preferences.async_set_preferences(**msg) + connection.send_result(msg_id, preferences.as_dict()) diff --git a/homeassistant/components/ai_task/icons.json b/homeassistant/components/ai_task/icons.json new file mode 100644 index 00000000000..cb09e5c8f5d --- /dev/null +++ b/homeassistant/components/ai_task/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "generate_text": { + "service": "mdi:file-star-four-points-outline" + } + } +} diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json new file mode 100644 index 00000000000..c685410530d --- /dev/null +++ b/homeassistant/components/ai_task/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ai_task", + "name": "AI Task", + "codeowners": ["@home-assistant/core"], + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/ai_task", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml new file mode 100644 index 00000000000..32715bf77d7 --- /dev/null +++ b/homeassistant/components/ai_task/services.yaml @@ -0,0 +1,19 @@ +generate_text: + fields: + task_name: + example: "home summary" + required: true + selector: + text: + instructions: + example: "Generate a funny notification that garage door was left open" + required: true + selector: + text: + entity_id: + required: false + selector: + entity: + domain: ai_task + supported_features: + - ai_task.AITaskEntityFeature.GENERATE_TEXT diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json new file mode 100644 index 00000000000..1cdbf20ba4f --- /dev/null +++ b/homeassistant/components/ai_task/strings.json @@ -0,0 +1,22 @@ +{ + "services": { + "generate_text": { + "name": "Generate text", + "description": "Use AI to run a task that generates text.", + "fields": { + "task_name": { + "name": "Task Name", + "description": "Name of the task." + }, + "instructions": { + "name": "Instructions", + "description": "Instructions on what needs to be done." + }, + "entity_id": { + "name": "Entity ID", + "description": "Entity ID to run the task on. If not provided, the preferred entity will be used." + } + } + } + } +} diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py new file mode 100644 index 00000000000..d0c59fdd09a --- /dev/null +++ b/homeassistant/components/ai_task/task.py @@ -0,0 +1,71 @@ +"""AI tasks to be handled by agents.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant + +from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature + + +async def async_generate_text( + hass: HomeAssistant, + *, + task_name: str, + entity_id: str | None = None, + instructions: str, +) -> GenTextTaskResult: + """Run a task in the AI Task integration.""" + if entity_id is None: + entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id + + if entity_id is None: + raise ValueError("No entity_id provided and no preferred entity set") + + entity = hass.data[DATA_COMPONENT].get_entity(entity_id) + if entity is None: + raise ValueError(f"AI Task entity {entity_id} not found") + + if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features: + raise ValueError(f"AI Task entity {entity_id} does not support generating text") + + return await entity.internal_async_generate_text( + GenTextTask( + name=task_name, + instructions=instructions, + ) + ) + + +@dataclass(slots=True) +class GenTextTask: + """Gen text task to be processed.""" + + name: str + """Name of the task.""" + + instructions: str + """Instructions on what needs to be done.""" + + def __str__(self) -> str: + """Return task as a string.""" + return f"" + + +@dataclass(slots=True) +class GenTextTaskResult: + """Result of gen text task.""" + + conversation_id: str + """Unique identifier for the conversation.""" + + text: str + """Generated text.""" + + def as_dict(self) -> dict[str, str]: + """Return result as a dict.""" + return { + "conversation_id": self.conversation_id, + "text": self.text, + } diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index de60ef84efa..19ebb096a31 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -39,14 +39,14 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() try: - location_point_valid = await test_location( + location_point_valid = await check_location( websession, user_input["api_key"], user_input["latitude"], user_input["longitude"], ) if not location_point_valid: - location_nearest_valid = await test_location( + location_nearest_valid = await check_location( websession, user_input["api_key"], user_input["latitude"], @@ -88,7 +88,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): ) -async def test_location( +async def check_location( client: ClientSession, api_key: str, latitude: float, diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index a3e4cebe771..ff30fb2f2ae 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -37,30 +37,35 @@ 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", @@ -68,40 +73,47 @@ 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", @@ -110,18 +122,21 @@ 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, ), } diff --git a/homeassistant/components/amazon_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py similarity index 91% rename from homeassistant/components/amazon_devices/__init__.py rename to homeassistant/components/alexa_devices/__init__.py index 1db41d335ef..7a4139a65da 100644 --- a/homeassistant/components/amazon_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -1,4 +1,4 @@ -"""Amazon Devices integration.""" +"""Alexa 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 Amazon Devices platform.""" + """Set up Alexa Devices platform.""" coordinator = AmazonDevicesCoordinator(hass, entry) diff --git a/homeassistant/components/amazon_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py similarity index 93% rename from homeassistant/components/amazon_devices/binary_sensor.py rename to homeassistant/components/alexa_devices/binary_sensor.py index ab1fadc7548..16cf73aee9f 100644 --- a/homeassistant/components/amazon_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): - """Amazon Devices binary sensor entity description.""" + """Alexa 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 Amazon Devices binary sensors based on a config entry.""" + """Set up Alexa Devices binary sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/amazon_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py similarity index 95% rename from homeassistant/components/amazon_devices/config_flow.py rename to homeassistant/components/alexa_devices/config_flow.py index d0c3d067cee..5add7ceb711 100644 --- a/homeassistant/components/amazon_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Amazon Devices integration.""" +"""Config flow for Alexa 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 Amazon Devices.""" + """Handle a config flow for Alexa Devices.""" async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/amazon_devices/const.py b/homeassistant/components/alexa_devices/const.py similarity index 60% rename from homeassistant/components/amazon_devices/const.py rename to homeassistant/components/alexa_devices/const.py index b8cf2c264b1..ca0290a10bc 100644 --- a/homeassistant/components/amazon_devices/const.py +++ b/homeassistant/components/alexa_devices/const.py @@ -1,8 +1,8 @@ -"""Amazon Devices constants.""" +"""Alexa Devices constants.""" import logging _LOGGER = logging.getLogger(__package__) -DOMAIN = "amazon_devices" +DOMAIN = "alexa_devices" CONF_LOGIN_DATA = "login_data" diff --git a/homeassistant/components/amazon_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py similarity index 95% rename from homeassistant/components/amazon_devices/coordinator.py rename to homeassistant/components/alexa_devices/coordinator.py index 48e31cb3f94..8e58441d46c 100644 --- a/homeassistant/components/amazon_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -1,4 +1,4 @@ -"""Support for Amazon Devices.""" +"""Support for Alexa Devices.""" from datetime import timedelta @@ -23,7 +23,7 @@ type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator] class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): - """Base coordinator for Amazon Devices.""" + """Base coordinator for Alexa Devices.""" config_entry: AmazonConfigEntry diff --git a/homeassistant/components/amazon_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py similarity index 97% rename from homeassistant/components/amazon_devices/diagnostics.py rename to homeassistant/components/alexa_devices/diagnostics.py index e9a0773cd3f..0c4cb794416 100644 --- a/homeassistant/components/amazon_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for Amazon Devices integration.""" +"""Diagnostics support for Alexa Devices integration.""" from __future__ import annotations diff --git a/homeassistant/components/amazon_devices/entity.py b/homeassistant/components/alexa_devices/entity.py similarity index 95% rename from homeassistant/components/amazon_devices/entity.py rename to homeassistant/components/alexa_devices/entity.py index 962e2f55ae6..f539079602f 100644 --- a/homeassistant/components/amazon_devices/entity.py +++ b/homeassistant/components/alexa_devices/entity.py @@ -1,4 +1,4 @@ -"""Defines a base Amazon Devices entity.""" +"""Defines a base Alexa 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 Amazon Devices entity.""" + """Defines a base Alexa Devices entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/amazon_devices/icons.json b/homeassistant/components/alexa_devices/icons.json similarity index 100% rename from homeassistant/components/amazon_devices/icons.json rename to homeassistant/components/alexa_devices/icons.json diff --git a/homeassistant/components/amazon_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json similarity index 67% rename from homeassistant/components/amazon_devices/manifest.json rename to homeassistant/components/alexa_devices/manifest.json index f63893c1598..a2bb423860b 100644 --- a/homeassistant/components/amazon_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -1,12 +1,12 @@ { - "domain": "amazon_devices", - "name": "Amazon Devices", + "domain": "alexa_devices", + "name": "Alexa Devices", "codeowners": ["@chemelli74"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/amazon_devices", + "documentation": "https://www.home-assistant.io/integrations/alexa_devices", "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.0.6"] + "requirements": ["aioamazondevices==3.1.14"] } diff --git a/homeassistant/components/amazon_devices/notify.py b/homeassistant/components/alexa_devices/notify.py similarity index 83% rename from homeassistant/components/amazon_devices/notify.py rename to homeassistant/components/alexa_devices/notify.py index 3762a7a3264..46db294377a 100644 --- a/homeassistant/components/amazon_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any, Final from aioamazondevices.api import AmazonDevice, AmazonEchoApi +from aioamazondevices.const import SPEAKER_GROUP_FAMILY from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription from homeassistant.core import HomeAssistant @@ -20,8 +21,9 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) class AmazonNotifyEntityDescription(NotifyEntityDescription): - """Amazon Devices notify entity description.""" + """Alexa Devices notify entity description.""" + is_supported: Callable[[AmazonDevice], bool] = lambda _device: True method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]] subkey: str @@ -31,6 +33,7 @@ NOTIFY: Final = ( key="speak", translation_key="speak", subkey="AUDIO_PLAYER", + is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY, method=lambda api, device, message: api.call_alexa_speak(device, message), ), AmazonNotifyEntityDescription( @@ -49,7 +52,7 @@ async def async_setup_entry( entry: AmazonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up Amazon Devices notification entity based on a config entry.""" + """Set up Alexa Devices notification entity based on a config entry.""" coordinator = entry.runtime_data @@ -58,6 +61,7 @@ async def async_setup_entry( for sensor_desc in NOTIFY for serial_num in coordinator.data if sensor_desc.subkey in coordinator.data[serial_num].capabilities + and sensor_desc.is_supported(coordinator.data[serial_num]) ) diff --git a/homeassistant/components/amazon_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml similarity index 100% rename from homeassistant/components/amazon_devices/quality_scale.yaml rename to homeassistant/components/alexa_devices/quality_scale.yaml diff --git a/homeassistant/components/amazon_devices/strings.json b/homeassistant/components/alexa_devices/strings.json similarity index 75% rename from homeassistant/components/amazon_devices/strings.json rename to homeassistant/components/alexa_devices/strings.json index 47e6234cd9c..9d615b248ed 100644 --- a/homeassistant/components/amazon_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -12,16 +12,16 @@ "step": { "user": { "data": { - "country": "[%key:component::amazon_devices::common::data_country%]", + "country": "[%key:component::alexa_devices::common::data_country%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "code": "[%key:component::amazon_devices::common::data_description_code%]" + "code": "[%key:component::alexa_devices::common::data_description_code%]" }, "data_description": { - "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%]" + "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%]" } } }, diff --git a/homeassistant/components/amazon_devices/switch.py b/homeassistant/components/alexa_devices/switch.py similarity index 95% rename from homeassistant/components/amazon_devices/switch.py rename to homeassistant/components/alexa_devices/switch.py index 428ef3e3b45..b8f78134feb 100644 --- a/homeassistant/components/amazon_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -20,7 +20,7 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) class AmazonSwitchEntityDescription(SwitchEntityDescription): - """Amazon Devices switch entity description.""" + """Alexa 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 Amazon Devices switches based on a config entry.""" + """Set up Alexa Devices switches based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/amcrest/services.py b/homeassistant/components/amcrest/services.py index 1ba869ce2d5..084761c4978 100644 --- a/homeassistant/components/amcrest/services.py +++ b/homeassistant/components/amcrest/services.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import async_extract_entity_ids @@ -15,6 +15,7 @@ from .const import CAMERAS, DATA_AMCREST, DOMAIN from .helpers import service_signal +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Amcrest IP Camera services.""" diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 3e79be0b169..f17294fe0e7 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -366,15 +366,35 @@ class AnthropicConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() + await self._async_handle_chat_log(chat_log) + + response_content = chat_log.content[-1] + if not isinstance(response_content, conversation.AssistantContent): + raise TypeError("Last message must be an assistant message") + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_speech(response_content.content or "") + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.entry.options + tools: list[ToolParam] | None = None if chat_log.llm_api: tools = [ @@ -424,7 +444,7 @@ class AnthropicConversationEntity( [ content async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, + self.entity_id, _transform_stream(chat_log, stream, messages), ) if not isinstance(content, conversation.AssistantContent) @@ -435,17 +455,6 @@ class AnthropicConversationEntity( if not chat_log.unresponded_tool_results: break - response_content = chat_log.content[-1] - if not isinstance(response_content, conversation.AssistantContent): - raise TypeError("Last message must be an assistant message") - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response_content.content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index a3faf6b0268..5076b537467 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -35,6 +36,7 @@ SENSORS: dict[str, SensorEntityDescription] = { "alarmdel": SensorEntityDescription( key="alarmdel", translation_key="alarm_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "ambtemp": SensorEntityDescription( key="ambtemp", @@ -47,15 +49,18 @@ SENSORS: dict[str, SensorEntityDescription] = { key="apc", translation_key="apc_status", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "apcmodel": SensorEntityDescription( key="apcmodel", translation_key="apc_model", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "badbatts": SensorEntityDescription( key="badbatts", translation_key="bad_batteries", + entity_category=EntityCategory.DIAGNOSTIC, ), "battdate": SensorEntityDescription( key="battdate", @@ -82,6 +87,7 @@ SENSORS: dict[str, SensorEntityDescription] = { key="cable", translation_key="cable_type", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "cumonbatt": SensorEntityDescription( key="cumonbatt", @@ -94,52 +100,63 @@ SENSORS: dict[str, SensorEntityDescription] = { key="date", translation_key="date", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "dipsw": SensorEntityDescription( key="dipsw", translation_key="dip_switch_settings", + entity_category=EntityCategory.DIAGNOSTIC, ), "dlowbatt": SensorEntityDescription( key="dlowbatt", translation_key="low_battery_signal", + entity_category=EntityCategory.DIAGNOSTIC, ), "driver": SensorEntityDescription( key="driver", translation_key="driver", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "dshutd": SensorEntityDescription( key="dshutd", translation_key="shutdown_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "dwake": SensorEntityDescription( key="dwake", translation_key="wake_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "end apc": SensorEntityDescription( key="end apc", translation_key="date_and_time", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "extbatts": SensorEntityDescription( key="extbatts", translation_key="external_batteries", + entity_category=EntityCategory.DIAGNOSTIC, ), "firmware": SensorEntityDescription( key="firmware", translation_key="firmware_version", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "hitrans": SensorEntityDescription( key="hitrans", translation_key="transfer_high", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "hostname": SensorEntityDescription( key="hostname", translation_key="hostname", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "humidity": SensorEntityDescription( key="humidity", @@ -163,10 +180,12 @@ SENSORS: dict[str, SensorEntityDescription] = { key="lastxfer", translation_key="last_transfer", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "linefail": SensorEntityDescription( key="linefail", translation_key="line_failure", + entity_category=EntityCategory.DIAGNOSTIC, ), "linefreq": SensorEntityDescription( key="linefreq", @@ -198,15 +217,18 @@ SENSORS: dict[str, SensorEntityDescription] = { translation_key="transfer_low", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "mandate": SensorEntityDescription( key="mandate", translation_key="manufacture_date", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "masterupd": SensorEntityDescription( key="masterupd", translation_key="master_update", + entity_category=EntityCategory.DIAGNOSTIC, ), "maxlinev": SensorEntityDescription( key="maxlinev", @@ -217,11 +239,13 @@ SENSORS: dict[str, SensorEntityDescription] = { "maxtime": SensorEntityDescription( key="maxtime", translation_key="max_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "mbattchg": SensorEntityDescription( key="mbattchg", translation_key="max_battery_charge", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "minlinev": SensorEntityDescription( key="minlinev", @@ -232,41 +256,48 @@ SENSORS: dict[str, SensorEntityDescription] = { "mintimel": SensorEntityDescription( key="mintimel", translation_key="min_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "model": SensorEntityDescription( key="model", translation_key="model", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "nombattv": SensorEntityDescription( key="nombattv", translation_key="battery_nominal_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nominv": SensorEntityDescription( key="nominv", translation_key="nominal_input_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nomoutv": SensorEntityDescription( key="nomoutv", translation_key="nominal_output_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nompower": SensorEntityDescription( key="nompower", translation_key="nominal_output_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, ), "nomapnt": SensorEntityDescription( key="nomapnt", translation_key="nominal_apparent_power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, ), "numxfers": SensorEntityDescription( key="numxfers", @@ -291,21 +322,25 @@ SENSORS: dict[str, SensorEntityDescription] = { key="reg1", translation_key="register_1_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "reg2": SensorEntityDescription( key="reg2", translation_key="register_2_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "reg3": SensorEntityDescription( key="reg3", translation_key="register_3_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "retpct": SensorEntityDescription( key="retpct", translation_key="restore_capacity", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "selftest": SensorEntityDescription( key="selftest", @@ -315,20 +350,24 @@ SENSORS: dict[str, SensorEntityDescription] = { key="sense", translation_key="sensitivity", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "serialno": SensorEntityDescription( key="serialno", translation_key="serial_number", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "starttime": SensorEntityDescription( key="starttime", translation_key="startup_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "statflag": SensorEntityDescription( key="statflag", translation_key="online_status", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "status": SensorEntityDescription( key="status", @@ -337,6 +376,7 @@ SENSORS: dict[str, SensorEntityDescription] = { "stesti": SensorEntityDescription( key="stesti", translation_key="self_test_interval", + entity_category=EntityCategory.DIAGNOSTIC, ), "timeleft": SensorEntityDescription( key="timeleft", @@ -360,23 +400,28 @@ SENSORS: dict[str, SensorEntityDescription] = { key="upsname", translation_key="ups_name", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "version": SensorEntityDescription( key="version", translation_key="version", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "xoffbat": SensorEntityDescription( key="xoffbat", translation_key="transfer_from_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), "xoffbatt": SensorEntityDescription( key="xoffbatt", translation_key="transfer_from_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), "xonbatt": SensorEntityDescription( key="xonbatt", translation_key="transfer_to_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index c2f0d44a6f8..667f2132fc8 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -89,7 +89,7 @@ class ArubaDeviceScanner(DeviceScanner): def get_aruba_data(self) -> dict[str, dict[str, str]] | None: """Retrieve data from Aruba Access Point and return parsed result.""" - connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa" + connect = f"ssh {self.username}@{self.host}" ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8") query = ssh.expect( [ diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 3338f223bc9..6bfbdfb33a8 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -1,13 +1,23 @@ """Base class for assist satellite entities.""" +from dataclasses import asdict import logging from pathlib import Path +from typing import Any +from hassil.util import ( + PUNCTUATION_END, + PUNCTUATION_END_WORD, + PUNCTUATION_START, + PUNCTUATION_START_WORD, +) import voluptuous as vol from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -23,6 +33,7 @@ from .const import ( ) from .entity import ( AssistSatelliteAnnouncement, + AssistSatelliteAnswer, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, @@ -34,6 +45,7 @@ from .websocket_api import async_register_websocket_api __all__ = [ "DOMAIN", "AssistSatelliteAnnouncement", + "AssistSatelliteAnswer", "AssistSatelliteConfiguration", "AssistSatelliteEntity", "AssistSatelliteEntityDescription", @@ -86,6 +98,62 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_start_conversation", [AssistSatelliteEntityFeature.START_CONVERSATION], ) + + async def handle_ask_question(call: ServiceCall) -> dict[str, Any]: + """Handle a Show View service call.""" + satellite_entity_id: str = call.data[ATTR_ENTITY_ID] + satellite_entity: AssistSatelliteEntity | None = component.get_entity( + satellite_entity_id + ) + if satellite_entity is None: + raise HomeAssistantError( + f"Invalid Assist satellite entity id: {satellite_entity_id}" + ) + + ask_question_args = { + "question": call.data.get("question"), + "question_media_id": call.data.get("question_media_id"), + "preannounce": call.data.get("preannounce", False), + "answers": call.data.get("answers"), + } + + if preannounce_media_id := call.data.get("preannounce_media_id"): + ask_question_args["preannounce_media_id"] = preannounce_media_id + + answer = await satellite_entity.async_internal_ask_question(**ask_question_args) + + if answer is None: + raise HomeAssistantError("No answer from satellite") + + return asdict(answer) + + hass.services.async_register( + domain=DOMAIN, + service="ask_question", + service_func=handle_ask_question, + schema=vol.All( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Optional("question"): str, + vol.Optional("question_media_id"): str, + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, + vol.Optional("answers"): [ + { + vol.Required("id"): str, + vol.Required("sentences"): vol.All( + cv.ensure_list, + [cv.string], + has_one_non_empty_item, + has_no_punctuation, + ), + } + ], + }, + cv.has_at_least_one_key("question", "question_media_id"), + ), + supports_response=SupportsResponse.ONLY, + ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) @@ -110,3 +178,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.data[DATA_COMPONENT].async_unload_entry(entry) + + +def has_no_punctuation(value: list[str]) -> list[str]: + """Validate result does not contain punctuation.""" + for sentence in value: + if ( + PUNCTUATION_START.search(sentence) + or PUNCTUATION_END.search(sentence) + or PUNCTUATION_START_WORD.search(sentence) + or PUNCTUATION_END_WORD.search(sentence) + ): + raise vol.Invalid("sentence should not contain punctuation") + + return value + + +def has_one_non_empty_item(value: list[str]) -> list[str]: + """Validate result has at least one item.""" + if len(value) < 1: + raise vol.Invalid("at least one sentence is required") + + for sentence in value: + if not sentence: + raise vol.Invalid("sentences cannot be empty") + + return value diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index dc20c7650d7..e7a10ef63f6 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -4,12 +4,16 @@ from abc import abstractmethod import asyncio from collections.abc import AsyncIterable import contextlib -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum import logging import time from typing import Any, Literal, final +from hassil import Intents, recognize +from hassil.expression import Expression, ListReference, Sequence +from hassil.intents import WildcardSlotList + from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( OPTION_PREFERRED, @@ -105,6 +109,20 @@ class AssistSatelliteAnnouncement: """Media ID to be played before announcement.""" +@dataclass +class AssistSatelliteAnswer: + """Answer to a question.""" + + id: str | None + """Matched answer id or None if no answer was matched.""" + + sentence: str + """Raw sentence text from user response.""" + + slots: dict[str, Any] = field(default_factory=dict) + """Matched slots from answer.""" + + class AssistSatelliteEntity(entity.Entity): """Entity encapsulating the state and functionality of an Assist satellite.""" @@ -122,6 +140,7 @@ class AssistSatelliteEntity(entity.Entity): _wake_word_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None + _ask_question_future: asyncio.Future[str | None] | None = None __assist_satellite_state = AssistSatelliteState.IDLE @@ -309,6 +328,112 @@ class AssistSatelliteEntity(entity.Entity): """Start a conversation from the satellite.""" raise NotImplementedError + async def async_internal_ask_question( + self, + question: str | None = None, + question_media_id: str | None = None, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, + answers: list[dict[str, Any]] | None = None, + ) -> AssistSatelliteAnswer | None: + """Ask a question and get a user's response from the satellite. + + If question_media_id is not provided, question is synthesized to audio + with the selected pipeline. + + If question_media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + If preannounce is True, a sound is played before the start message or media. + If preannounce_media_id is provided, it overrides the default sound. + + Calls async_start_conversation. + """ + await self._cancel_running_pipeline() + + if question is None: + question = "" + + announcement = await self._resolve_announcement_media_id( + question, + question_media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, + ) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + self._set_state(AssistSatelliteState.RESPONDING) + self._ask_question_future = asyncio.Future() + + try: + # Wait for announcement to finish + await self.async_start_conversation(announcement) + + # Wait for response text + response_text = await self._ask_question_future + if response_text is None: + raise HomeAssistantError("No answer from question") + + if not answers: + return AssistSatelliteAnswer(id=None, sentence=response_text) + + return self._question_response_to_answer(response_text, answers) + finally: + self._is_announcing = False + self._set_state(AssistSatelliteState.IDLE) + self._ask_question_future = None + + def _question_response_to_answer( + self, response_text: str, answers: list[dict[str, Any]] + ) -> AssistSatelliteAnswer: + """Match text to a pre-defined set of answers.""" + + # Build intents and match + intents = Intents.from_dict( + { + "language": self.hass.config.language, + "intents": { + "QuestionIntent": { + "data": [ + { + "sentences": answer["sentences"], + "metadata": {"answer_id": answer["id"]}, + } + for answer in answers + ] + } + }, + } + ) + + # Assume slot list references are wildcards + wildcard_names: set[str] = set() + for intent in intents.intents.values(): + for intent_data in intent.data: + for sentence in intent_data.sentences: + _collect_list_references(sentence, wildcard_names) + + for wildcard_name in wildcard_names: + intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name) + + # Match response text + result = recognize(response_text, intents) + if result is None: + # No match + return AssistSatelliteAnswer(id=None, sentence=response_text) + + assert result.intent_metadata + return AssistSatelliteAnswer( + id=result.intent_metadata["answer_id"], + sentence=response_text, + slots={ + entity_name: entity.value + for entity_name, entity in result.entities.items() + }, + ) + async def async_accept_pipeline_from_satellite( self, audio_stream: AsyncIterable[bytes], @@ -351,6 +476,11 @@ class AssistSatelliteEntity(entity.Entity): self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END)) return + if (self._ask_question_future is not None) and ( + start_stage == PipelineStage.STT + ): + end_stage = PipelineStage.STT + device_id = self.registry_entry.device_id if self.registry_entry else None # Refresh context if necessary @@ -433,6 +563,16 @@ class AssistSatelliteEntity(entity.Entity): self._set_state(AssistSatelliteState.IDLE) elif event.type is PipelineEventType.STT_START: self._set_state(AssistSatelliteState.LISTENING) + elif event.type is PipelineEventType.STT_END: + # Intercepting text for ask question + if ( + (self._ask_question_future is not None) + and (not self._ask_question_future.done()) + and event.data + ): + self._ask_question_future.set_result( + event.data.get("stt_output", {}).get("text") + ) elif event.type is PipelineEventType.INTENT_START: self._set_state(AssistSatelliteState.PROCESSING) elif event.type is PipelineEventType.TTS_START: @@ -443,6 +583,12 @@ class AssistSatelliteEntity(entity.Entity): if not self._run_has_tts: self._set_state(AssistSatelliteState.IDLE) + if (self._ask_question_future is not None) and ( + not self._ask_question_future.done() + ): + # No text for ask question + self._ask_question_future.set_result(None) + self.on_pipeline_event(event) @callback @@ -577,3 +723,15 @@ class AssistSatelliteEntity(entity.Entity): media_id_source=media_id_source, preannounce_media_id=preannounce_media_id, ) + + +def _collect_list_references(expression: Expression, list_names: set[str]) -> None: + """Collect list reference names recursively.""" + if isinstance(expression, Sequence): + seq: Sequence = expression + for item in seq.items: + _collect_list_references(item, list_names) + elif isinstance(expression, ListReference): + # {list} + list_ref: ListReference = expression + list_names.add(list_ref.slot_name) diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index 1ed29541621..fc2589ea506 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -10,6 +10,9 @@ }, "start_conversation": { "service": "mdi:forum" + }, + "ask_question": { + "service": "mdi:microphone-question" } } } diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index 68a3ceafd4f..97362f157e4 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["assist_pipeline", "http", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", - "quality_scale": "internal" + "quality_scale": "internal", + "requirements": ["hassil==2.2.3"] } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index d88710c4c4e..c5484e22dad 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -54,3 +54,35 @@ start_conversation: required: false selector: text: +ask_question: + fields: + entity_id: + required: true + selector: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + question: + required: false + example: "What kind of music would you like to play?" + default: "" + selector: + text: + question_media_id: + required: false + selector: + text: + preannounce: + required: false + default: true + selector: + boolean: + preannounce_media_id: + required: false + selector: + text: + answers: + required: false + selector: + object: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index b69711c7106..e0bf2bcfb94 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -59,6 +59,36 @@ "description": "Custom media ID to play before the start message or media." } } + }, + "ask_question": { + "name": "Ask question", + "description": "Asks a question and gets the user's response.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Assist satellite entity to ask the question on." + }, + "question": { + "name": "Question", + "description": "The question to ask." + }, + "question_media_id": { + "name": "Question media ID", + "description": "The media ID of the question to use instead of text-to-speech." + }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the start message or media." + }, + "preannounce_media_id": { + "name": "Preannounce media ID", + "description": "Custom media ID to play before the start message or media." + }, + "answers": { + "name": "Answers", + "description": "Possible answers to the question." + } + } } } } diff --git a/homeassistant/components/automation/helpers.py b/homeassistant/components/automation/helpers.py index c529fbd504e..d90054252a4 100644 --- a/homeassistant/components/automation/helpers.py +++ b/homeassistant/components/automation/helpers.py @@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "automation_blueprints" def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool: """Return True if any automation references the blueprint.""" - from . import automations_with_blueprint # pylint: disable=import-outside-toplevel + from . import automations_with_blueprint # noqa: PLC0415 return len(automations_with_blueprint(hass, blueprint_path)) > 0 @@ -28,8 +28,7 @@ async def _reload_blueprint_automations( @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get automation blueprints.""" - # pylint: disable-next=import-outside-toplevel - from .config import AUTOMATION_BLUEPRINT_SCHEMA + from .config import AUTOMATION_BLUEPRINT_SCHEMA # noqa: PLC0415 return blueprint.DomainBlueprints( hass, diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index daf9337a8a8..51503230530 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -94,8 +94,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not with_hassio: reader_writer = CoreBackupReaderWriter(hass) else: - # pylint: disable-next=import-outside-toplevel, hass-component-root-import - from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter + # pylint: disable-next=hass-component-root-import + from homeassistant.components.hassio.backup import ( # noqa: PLC0415 + SupervisorBackupReaderWriter, + ) reader_writer = SupervisorBackupReaderWriter(hass) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index d328849e6fe..2620b3fb6fd 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -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 setup_services +from .services import async_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.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index dd5d1e37627..1f748bd9f63 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -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 +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -21,34 +21,36 @@ SERVICE_SEND_PIN_SCHEMA = vol.Schema( ) -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], +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: + """Set up the services for the Blink integration.""" hass.services.async_register( DOMAIN, SERVICE_SEND_PIN, - send_pin, + _send_pin, schema=SERVICE_SEND_PIN_SCHEMA, ) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 4130606ff5c..0bbdfae50e4 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.12.4"] + "requirements": ["bthome-ble==3.13.1"] } diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index ee9d1cbc94f..4286e7462cc 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -240,6 +240,10 @@ async def _async_get_stream_image( height: int | None = None, wait_for_next_keyframe: bool = False, ) -> bytes | None: + if (provider := camera._webrtc_provider) and ( # noqa: SLF001 + image := await provider.async_get_image(camera, width=width, height=height) + ) is not None: + return image if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features: camera.stream = await camera.async_create_stream() if camera.stream: @@ -494,19 +498,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> CameraEntityFeature: - """Return the supported features as CameraEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: - new_features = CameraEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" @@ -700,9 +691,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - self.__supports_stream = ( - self.supported_features_compat & CameraEntityFeature.STREAM - ) + self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -731,7 +720,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]] ) -> _T | None: """Get first provider that supports this camera.""" - if CameraEntityFeature.STREAM not in self.supported_features_compat: + if CameraEntityFeature.STREAM not in self.supported_features: return None return await fn(self.hass, self) @@ -781,7 +770,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def camera_capabilities(self) -> CameraCapabilities: """Return the camera capabilities.""" frontend_stream_types = set() - if CameraEntityFeature.STREAM in self.supported_features_compat: + if CameraEntityFeature.STREAM in self.supported_features: if self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) @@ -801,8 +790,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ super().async_write_ha_state() if self.__supports_stream != ( - supports_stream := self.supported_features_compat - & CameraEntityFeature.STREAM + supports_stream := self.supported_features & CameraEntityFeature.STREAM ): self.__supports_stream = supports_stream self._invalidate_camera_capabilities_cache() diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 9ad50430f83..c2de5eac0a0 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -156,6 +156,15 @@ class CameraWebRTCProvider(ABC): """Close the session.""" return ## This is an optional method so we need a default here. + async def async_get_image( + self, + camera: Camera, + width: int | None = None, + height: int | None = None, + ) -> bytes | None: + """Get an image from the camera.""" + return None + @callback def async_register_webrtc_provider( diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 03acaa08294..790579d6a73 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -27,7 +27,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_suggest_report_issue from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import TemperatureConverter @@ -106,11 +105,6 @@ DEFAULT_MAX_HUMIDITY = 99 CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] -# Can be removed in 2025.1 after deprecation period of the new feature flags -CHECK_TURN_ON_OFF_FEATURE_FLAG = ( - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF -) - SET_TEMPERATURE_SCHEMA = vol.All( cv.has_at_least_one_key( ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW @@ -535,26 +529,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return modes_str: str = ", ".join(modes) if modes else "" translation_key = f"not_valid_{mode_type}_mode" - if mode_type == "hvac": - report_issue = async_suggest_report_issue( - self.hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - _LOGGER.warning( - ( - "%s::%s sets the hvac_mode %s which is not " - "valid for this entity with modes: %s. " - "This will stop working in 2025.4 and raise an error instead. " - "Please %s" - ), - self.platform.platform_name, - self.__class__.__name__, - mode, - modes_str, - report_issue, - ) - return raise ServiceValidationError( translation_domain=DOMAIN, translation_key=translation_key, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index bd6ed083650..ad0bccb25ce 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -258,6 +258,9 @@ "not_valid_preset_mode": { "message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}." }, + "not_valid_hvac_mode": { + "message": "HVAC mode {mode} is not valid. Valid HVAC modes are: {modes}." + }, "not_valid_swing_mode": { "message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}." }, diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index faee244a074..b5c73e08f3e 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.101.0"], + "requirements": ["hass-nabucasa==0.103.0"], "single_config_entry": true } diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 50bfbe651ef..b0031e4d5ee 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -9,12 +9,11 @@ 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__) @@ -45,28 +44,10 @@ class CommandLineNotificationService(BaseNotificationService): def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a command line.""" - 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 not (command := render_template_args(self.hass, self.command)): + return - 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) + LOGGER.debug("Running with message: %s", message) with subprocess.Popen( # noqa: S602 # shell by design command, diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 5ce50edc4e7..dfc31b4581b 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -19,7 +19,6 @@ 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 @@ -37,7 +36,7 @@ from .const import ( LOGGER, TRIGGER_ENTITY_OPTIONS, ) -from .utils import async_check_output_or_log +from .utils import async_check_output_or_log, render_template_args DEFAULT_NAME = "Command Sensor" @@ -222,32 +221,6 @@ class CommandSensorData: async def async_update(self) -> None: """Get the latest data with a shell command.""" - 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) + if not (command := render_template_args(self.hass, self.command)): + return self.value = await async_check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index c1926546950..607340c4853 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -3,9 +3,13 @@ from __future__ import annotations import asyncio -import logging -_LOGGER = logging.getLogger(__name__) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers.template import Template + +from .const import LOGGER + _EXEC_FAILED_CODE = 127 @@ -18,7 +22,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 @@ -26,14 +30,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, @@ -53,12 +57,39 @@ 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 diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index df5771fe5bb..3d84d6edd69 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -54,10 +54,10 @@ class Control4RuntimeData: type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] -async def call_c4_api_retry(func, *func_args): +async def call_c4_api_retry(func, *func_args): # noqa: RET503 """Call C4 API function and retry on failure.""" # Ruff doesn't understand this loop - the exception is always raised after the retries - for i in range(API_RETRY_TIMES): # noqa: RET503 + for i in range(API_RETRY_TIMES): try: return await func(*func_args) except client_exceptions.ClientError as exception: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index fff2c00641f..cf62704b34d 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -271,7 +271,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) # Temporary migration. We can remove this in 2024.10 - from homeassistant.components.assist_pipeline import ( # pylint: disable=import-outside-toplevel + from homeassistant.components.assist_pipeline import ( # noqa: PLC0415 async_migrate_engine, ) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index c78f41f3c5c..6322bdb4435 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -14,12 +14,11 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import chat_session, intent, llm, template +from homeassistant.helpers import chat_session, frame, intent, llm, template from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType from . import trace -from .const import DOMAIN from .models import ConversationInput, ConversationResult DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs") @@ -359,7 +358,7 @@ class ChatLog: self, llm_context: llm.LLMContext, prompt: str, - language: str, + language: str | None, user_name: str | None = None, ) -> str: try: @@ -373,7 +372,7 @@ class ChatLog: ) except TemplateError as err: LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=language) + intent_response = intent.IntentResponse(language=language or "") intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, "Sorry, I had a problem with my template", @@ -392,15 +391,25 @@ class ChatLog: user_llm_prompt: str | None = None, ) -> None: """Set the LLM system prompt.""" - llm_context = llm.LLMContext( - platform=conversing_domain, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=DOMAIN, - device_id=user_input.device_id, + frame.report_usage( + "ChatLog.async_update_llm_data", + breaks_in_ha_version="2026.1", + ) + return await self.async_provide_llm_data( + llm_context=user_input.as_llm_context(conversing_domain), + user_llm_hass_api=user_llm_hass_api, + user_llm_prompt=user_llm_prompt, + user_extra_system_prompt=user_input.extra_system_prompt, ) + async def async_provide_llm_data( + self, + llm_context: llm.LLMContext, + user_llm_hass_api: str | list[str] | None = None, + user_llm_prompt: str | None = None, + user_extra_system_prompt: str | None = None, + ) -> None: + """Set the LLM system prompt.""" llm_api: llm.APIInstance | None = None if user_llm_hass_api: @@ -414,10 +423,12 @@ class ChatLog: LOGGER.error( "Error getting LLM API %s for %s: %s", user_llm_hass_api, - conversing_domain, + llm_context.platform, err, ) - intent_response = intent.IntentResponse(language=user_input.language) + intent_response = intent.IntentResponse( + language=llm_context.language or "" + ) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, "Error preparing LLM API", @@ -431,10 +442,10 @@ class ChatLog: user_name: str | None = None if ( - user_input.context - and user_input.context.user_id + llm_context.context + and llm_context.context.user_id and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) + user := await self.hass.auth.async_get_user(llm_context.context.user_id) ) ): user_name = user.name @@ -444,7 +455,7 @@ class ChatLog: await self._async_expand_prompt_template( llm_context, (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), - user_input.language, + llm_context.language, user_name, ) ) @@ -456,14 +467,14 @@ class ChatLog: await self._async_expand_prompt_template( llm_context, llm.BASE_PROMPT, - user_input.language, + llm_context.language, user_name, ) ) if extra_system_prompt := ( # Take new system prompt if one was given - user_input.extra_system_prompt or self.extra_system_prompt + user_extra_system_prompt or self.extra_system_prompt ): prompt_parts.append(extra_system_prompt) diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 00097f5b4d3..dac1fb862ec 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -7,7 +7,9 @@ from dataclasses import dataclass from typing import Any, Literal from homeassistant.core import Context -from homeassistant.helpers import intent +from homeassistant.helpers import intent, llm + +from .const import DOMAIN @dataclass(frozen=True) @@ -56,6 +58,16 @@ class ConversationInput: "extra_system_prompt": self.extra_system_prompt, } + def as_llm_context(self, conversing_domain: str) -> llm.LLMContext: + """Return input as an LLM context.""" + return llm.LLMContext( + platform=conversing_domain, + context=self.context, + language=self.language, + assistant=DOMAIN, + device_id=self.device_id, + ) + @dataclass(slots=True) class ConversationResult: diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 85069b425e3..a77c8bf8ba3 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: - if type(features) is int: - new_features = CoverEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features return features supported_features = ( diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 26597c195e7..af10bf7e3c3 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -164,8 +164,6 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - if hvac_mode not in self._attr_hvac_modes: - raise ValueError(f"Unsupported HVAC mode {hvac_mode}") if len(self._attr_hvac_modes) == 2: # Only allow turn on and off thermostat await self.hub.api.sensors.thermostat.set_config( diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index b8dc948913f..20a1edf734d 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry -from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS +from .const import DOMAIN, GATEWAY_SERIAL_PATTERN, PLATFORMS type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]] @@ -32,10 +32,16 @@ async def async_setup_entry( credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid) if not credentials_valid: - raise ConfigEntryAuthFailed + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) if await hass.async_add_executor_job(mydevolo.maintenance): - raise ConfigEntryNotReady + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="maintenance", + ) gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids) @@ -69,7 +75,11 @@ async def async_setup_entry( ) ) except GatewayOfflineError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_failed", + translation_placeholders={"gateway_id": gateway_id}, + ) from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -91,7 +101,9 @@ async def async_unload_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, + config_entry: DevoloHomeControlConfigEntry, + device_entry: DeviceEntry, ) -> bool: """Remove a config entry from a device.""" return True diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index be853e2d89d..a5a8086ba47 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -45,5 +45,16 @@ "name": "Brightness" } } + }, + "exceptions": { + "connection_failed": { + "message": "Failed to connect to devolo Home Control central unit {gateway_id}." + }, + "invalid_auth": { + "message": "Authentication failed. Please re-authenticaticate with your mydevolo account." + }, + "maintenance": { + "message": "devolo Home Control is currently in maintenance mode." + } } } diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 15ff0e5ac2a..ad3d3e1cffa 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -87,6 +87,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module ): """Representation of a devolo device tracker.""" + _attr_has_entity_name = True _attr_translation_key = "device_tracker" def __init__( @@ -99,6 +100,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module super().__init__(coordinator) self._device = device self._attr_mac_address = mac + self._attr_name = mac @property def extra_state_attributes(self) -> dict[str, str]: diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 5f1ba2a13ef..c795c7ed2ed 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["discord"], - "requirements": ["nextcord==2.6.0"] + "requirements": ["nextcord==3.1.0"] } diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index e004b386e02..6008fb83e1b 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.4.0"] + "requirements": ["aiodns==3.5.0"] } diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index 19f6e827fb0..cce8c9d65b0 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -10,7 +10,7 @@ import threading import requests import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -108,8 +108,7 @@ def download_file(service: ServiceCall) -> None: _LOGGER.debug("%s -> %s", url, final_path) with open(final_path, "wb") as fil: - for chunk in req.iter_content(1024): - fil.write(chunk) + fil.writelines(req.iter_content(1024)) _LOGGER.debug("Downloading of %s done", url) service.hass.bus.fire( @@ -141,6 +140,7 @@ def download_file(service: ServiceCall) -> None: threading.Thread(target=do_download).start() +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register the services for the downloader component.""" async_register_admin_service( diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 7c85a63cc78..32bf5d3ba15 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -2,9 +2,9 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Generic from deebot_client.capabilities import CapabilityEvent +from deebot_client.events.base import Event from deebot_client.events.water_info import MopAttachedEvent from homeassistant.components.binary_sensor import ( @@ -16,15 +16,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) -class EcovacsBinarySensorEntityDescription( +class EcovacsBinarySensorEntityDescription[EventT: Event]( BinarySensorEntityDescription, EcovacsCapabilityEntityDescription, - Generic[EventT], ): """Class describing Deebot binary sensor entity.""" @@ -55,7 +54,7 @@ async def async_setup_entry( ) -class EcovacsBinarySensor( +class EcovacsBinarySensor[EventT: Event]( EcovacsDescriptionEntity[CapabilityEvent[EventT]], BinarySensorEntity, ): diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py index 36103be4d11..85a788d7afe 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, Generic, TypeVar +from typing import Any from deebot_client.capabilities import Capabilities from deebot_client.device import Device @@ -18,11 +18,8 @@ from homeassistant.helpers.entity import Entity, EntityDescription from .const import DOMAIN -CapabilityEntity = TypeVar("CapabilityEntity") -EventT = TypeVar("EventT", bound=Event) - -class EcovacsEntity(Entity, Generic[CapabilityEntity]): +class EcovacsEntity[CapabilityEntityT](Entity): """Ecovacs entity.""" _attr_should_poll = False @@ -32,7 +29,7 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]): def __init__( self, device: Device, - capability: CapabilityEntity, + capability: CapabilityEntityT, **kwargs: Any, ) -> None: """Initialize entity.""" @@ -80,7 +77,7 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]): self._subscribe(AvailabilityEvent, on_available) - def _subscribe( + def _subscribe[EventT: Event]( self, event_type: type[EventT], callback: Callable[[EventT], Coroutine[Any, Any, None]], @@ -98,13 +95,13 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]): self._device.events.request_refresh(event_type) -class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]): +class EcovacsDescriptionEntity[CapabilityEntityT](EcovacsEntity[CapabilityEntityT]): """Ecovacs entity.""" def __init__( self, device: Device, - capability: CapabilityEntity, + capability: CapabilityEntityT, entity_description: EntityDescription, **kwargs: Any, ) -> None: @@ -114,13 +111,12 @@ class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]): @dataclass(kw_only=True, frozen=True) -class EcovacsCapabilityEntityDescription( +class EcovacsCapabilityEntityDescription[CapabilityEntityT]( EntityDescription, - Generic[CapabilityEntity], ): """Ecovacs entity description.""" - capability_fn: Callable[[Capabilities], CapabilityEntity | None] + capability_fn: Callable[[Capabilities], CapabilityEntityT | None] class EcovacsLegacyEntity(Entity): diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 8a7388da735..97739f698d9 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==13.3.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"] } diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index 1fbf65aec65..513a0d350f6 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -4,10 +4,10 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic from deebot_client.capabilities import CapabilitySet from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent +from deebot_client.events.base import Event from homeassistant.components.number import ( NumberEntity, @@ -23,16 +23,14 @@ from .entity import ( EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, - EventT, ) from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) -class EcovacsNumberEntityDescription( +class EcovacsNumberEntityDescription[EventT: Event]( NumberEntityDescription, EcovacsCapabilityEntityDescription, - Generic[EventT], ): """Ecovacs number entity description.""" @@ -94,7 +92,7 @@ async def async_setup_entry( async_add_entities(entities) -class EcovacsNumberEntity( +class EcovacsNumberEntity[EventT: Event]( EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]], NumberEntity, ): diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index deddb7e252a..84f86fdd2cd 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -2,11 +2,12 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic +from typing import Any from deebot_client.capabilities import CapabilitySetTypes from deebot_client.device import Device from deebot_client.events import WorkModeEvent +from deebot_client.events.base import Event from deebot_client.events.water_info import WaterAmountEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -15,15 +16,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity from .util import get_name_key, get_supported_entities @dataclass(kw_only=True, frozen=True) -class EcovacsSelectEntityDescription( +class EcovacsSelectEntityDescription[EventT: Event]( SelectEntityDescription, EcovacsCapabilityEntityDescription, - Generic[EventT], ): """Ecovacs select entity description.""" @@ -66,7 +66,7 @@ async def async_setup_entry( async_add_entities(entities) -class EcovacsSelectEntity( +class EcovacsSelectEntity[EventT: Event]( EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]], SelectEntity, ): diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 98f3783b231..e84485228e4 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic +from typing import Any from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType from deebot_client.device import Device @@ -46,16 +46,14 @@ from .entity import ( EcovacsDescriptionEntity, EcovacsEntity, EcovacsLegacyEntity, - EventT, ) from .util import get_name_key, get_options, get_supported_entities @dataclass(kw_only=True, frozen=True) -class EcovacsSensorEntityDescription( +class EcovacsSensorEntityDescription[EventT: Event]( EcovacsCapabilityEntityDescription, SensorEntityDescription, - Generic[EventT], ): """Ecovacs sensor entity description.""" diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index faa471e44b1..28b61c4c0e1 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.12"] + "requirements": ["pysml==0.1.5"] } diff --git a/homeassistant/components/eheimdigital/number.py b/homeassistant/components/eheimdigital/number.py index 03f27aa82df..53382e3aead 100644 --- a/homeassistant/components/eheimdigital/number.py +++ b/homeassistant/components/eheimdigital/number.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Generic, TypeVar, override +from typing import Any, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice @@ -30,16 +30,16 @@ from .entity import EheimDigitalEntity, exception_handler PARALLEL_UPDATES = 0 -_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) - @dataclass(frozen=True, kw_only=True) -class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]): +class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice]( + NumberEntityDescription +): """Class describing EHEIM Digital sensor entities.""" - value_fn: Callable[[_DeviceT_co], float | None] - set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]] - uom_fn: Callable[[_DeviceT_co], str] | None = None + value_fn: Callable[[_DeviceT], float | None] + set_value_fn: Callable[[_DeviceT, float], Awaitable[None]] + uom_fn: Callable[[_DeviceT], str] | None = None CLASSICVARIO_DESCRIPTIONS: tuple[ @@ -136,7 +136,7 @@ async def async_setup_entry( device_address: dict[str, EheimDigitalDevice], ) -> None: """Set up the number entities for one or multiple devices.""" - entities: list[EheimDigitalNumber[EheimDigitalDevice]] = [] + entities: list[EheimDigitalNumber[Any]] = [] for device in device_address.values(): if isinstance(device, EheimDigitalClassicVario): entities.extend( @@ -163,18 +163,18 @@ async def async_setup_entry( async_setup_device_entities(coordinator.hub.devices) -class EheimDigitalNumber( - EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co] +class EheimDigitalNumber[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], NumberEntity ): """Represent a EHEIM Digital number entity.""" - entity_description: EheimDigitalNumberDescription[_DeviceT_co] + entity_description: EheimDigitalNumberDescription[_DeviceT] def __init__( self, coordinator: EheimDigitalUpdateCoordinator, - device: _DeviceT_co, - description: EheimDigitalNumberDescription[_DeviceT_co], + device: _DeviceT, + description: EheimDigitalNumberDescription[_DeviceT], ) -> None: """Initialize an EHEIM Digital number entity.""" super().__init__(coordinator, device) diff --git a/homeassistant/components/eheimdigital/select.py b/homeassistant/components/eheimdigital/select.py index 41ab13e3bd4..5c42055441a 100644 --- a/homeassistant/components/eheimdigital/select.py +++ b/homeassistant/components/eheimdigital/select.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Generic, TypeVar, override +from typing import Any, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice @@ -17,15 +17,15 @@ from .entity import EheimDigitalEntity, exception_handler PARALLEL_UPDATES = 0 -_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) - @dataclass(frozen=True, kw_only=True) -class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]): +class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice]( + SelectEntityDescription +): """Class describing EHEIM Digital select entities.""" - value_fn: Callable[[_DeviceT_co], str | None] - set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]] + value_fn: Callable[[_DeviceT], str | None] + set_value_fn: Callable[[_DeviceT, str], Awaitable[None]] CLASSICVARIO_DESCRIPTIONS: tuple[ @@ -59,7 +59,7 @@ async def async_setup_entry( device_address: dict[str, EheimDigitalDevice], ) -> None: """Set up the number entities for one or multiple devices.""" - entities: list[EheimDigitalSelect[EheimDigitalDevice]] = [] + entities: list[EheimDigitalSelect[Any]] = [] for device in device_address.values(): if isinstance(device, EheimDigitalClassicVario): entities.extend( @@ -75,18 +75,18 @@ async def async_setup_entry( async_setup_device_entities(coordinator.hub.devices) -class EheimDigitalSelect( - EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co] +class EheimDigitalSelect[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], SelectEntity ): """Represent an EHEIM Digital select entity.""" - entity_description: EheimDigitalSelectDescription[_DeviceT_co] + entity_description: EheimDigitalSelectDescription[_DeviceT] def __init__( self, coordinator: EheimDigitalUpdateCoordinator, - device: _DeviceT_co, - description: EheimDigitalSelectDescription[_DeviceT_co], + device: _DeviceT, + description: EheimDigitalSelectDescription[_DeviceT], ) -> None: """Initialize an EHEIM Digital select entity.""" super().__init__(coordinator, device) diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py index 3d809cc14dc..82038b40865 100644 --- a/homeassistant/components/eheimdigital/sensor.py +++ b/homeassistant/components/eheimdigital/sensor.py @@ -2,7 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Generic, TypeVar, override +from typing import Any, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice @@ -20,14 +20,14 @@ from .entity import EheimDigitalEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 -_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) - @dataclass(frozen=True, kw_only=True) -class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]): +class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice]( + SensorEntityDescription +): """Class describing EHEIM Digital sensor entities.""" - value_fn: Callable[[_DeviceT_co], float | str | None] + value_fn: Callable[[_DeviceT], float | str | None] CLASSICVARIO_DESCRIPTIONS: tuple[ @@ -75,7 +75,7 @@ async def async_setup_entry( device_address: dict[str, EheimDigitalDevice], ) -> None: """Set up the light entities for one or multiple devices.""" - entities: list[EheimDigitalSensor[EheimDigitalDevice]] = [] + entities: list[EheimDigitalSensor[Any]] = [] for device in device_address.values(): if isinstance(device, EheimDigitalClassicVario): entities += [ @@ -91,18 +91,18 @@ async def async_setup_entry( async_setup_device_entities(coordinator.hub.devices) -class EheimDigitalSensor( - EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co] +class EheimDigitalSensor[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], SensorEntity ): """Represent a EHEIM Digital sensor entity.""" - entity_description: EheimDigitalSensorDescription[_DeviceT_co] + entity_description: EheimDigitalSensorDescription[_DeviceT] def __init__( self, coordinator: EheimDigitalUpdateCoordinator, - device: _DeviceT_co, - description: EheimDigitalSensorDescription[_DeviceT_co], + device: _DeviceT, + description: EheimDigitalSensorDescription[_DeviceT], ) -> None: """Initialize an EHEIM Digital number entity.""" super().__init__(coordinator, device) diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py index 49834c827b9..f14a4150eff 100644 --- a/homeassistant/components/eheimdigital/time.py +++ b/homeassistant/components/eheimdigital/time.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import time -from typing import Generic, TypeVar, final, override +from typing import Any, final, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice @@ -19,15 +19,13 @@ from .entity import EheimDigitalEntity, exception_handler PARALLEL_UPDATES = 0 -_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) - @dataclass(frozen=True, kw_only=True) -class EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]): +class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescription): """Class describing EHEIM Digital time entities.""" - value_fn: Callable[[_DeviceT_co], time | None] - set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]] + value_fn: Callable[[_DeviceT], time | None] + set_value_fn: Callable[[_DeviceT, time], Awaitable[None]] CLASSICVARIO_DESCRIPTIONS: tuple[ @@ -79,7 +77,7 @@ async def async_setup_entry( device_address: dict[str, EheimDigitalDevice], ) -> None: """Set up the time entities for one or multiple devices.""" - entities: list[EheimDigitalTime[EheimDigitalDevice]] = [] + entities: list[EheimDigitalTime[Any]] = [] for device in device_address.values(): if isinstance(device, EheimDigitalClassicVario): entities.extend( @@ -103,18 +101,18 @@ async def async_setup_entry( @final -class EheimDigitalTime( - EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co] +class EheimDigitalTime[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], TimeEntity ): """Represent an EHEIM Digital time entity.""" - entity_description: EheimDigitalTimeDescription[_DeviceT_co] + entity_description: EheimDigitalTimeDescription[_DeviceT] def __init__( self, coordinator: EheimDigitalUpdateCoordinator, - device: _DeviceT_co, - description: EheimDigitalTimeDescription[_DeviceT_co], + device: _DeviceT, + description: EheimDigitalTimeDescription[_DeviceT], ) -> None: """Initialize an EHEIM Digital time entity.""" super().__init__(coordinator, device) diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 5e0a2ef168d..903c16543bb 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/elkm1/services.py b/homeassistant/components/elkm1/services.py index 622ce65ae5e..bfdd968680c 100644 --- a/homeassistant/components/elkm1/services.py +++ b/homeassistant/components/elkm1/services.py @@ -63,6 +63,7 @@ def _set_time_service(service: ServiceCall) -> None: _async_get_elk_panel(service).set_time(dt_util.now()) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Create ElkM1 services.""" diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 4493f944db3..b4be3cf5ee9 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING from eq3btsmart import Thermostat from eq3btsmart.exceptions import Eq3Exception -from eq3btsmart.thermostat_config import ThermostatConfig from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -53,12 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: f"[{eq3_config.mac_address}] Device could not be found" ) - thermostat = Thermostat( - thermostat_config=ThermostatConfig( - mac_address=mac_address, - ), - ble_device=device, - ) + thermostat = Thermostat(mac_address=device) # type: ignore[arg-type] entry.runtime_data = Eq3ConfigEntryData( eq3_config=eq3_config, thermostat=thermostat diff --git a/homeassistant/components/eq3btsmart/binary_sensor.py b/homeassistant/components/eq3btsmart/binary_sensor.py index 55b1f4d6ced..8cec495f017 100644 --- a/homeassistant/components/eq3btsmart/binary_sensor.py +++ b/homeassistant/components/eq3btsmart/binary_sensor.py @@ -2,7 +2,6 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from eq3btsmart.models import Status @@ -80,7 +79,4 @@ class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity): def is_on(self) -> bool: """Return the state of the binary sensor.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 738efa99187..c11328c7ec3 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -1,9 +1,16 @@ """Platform for eQ-3 climate entities.""" +from datetime import timedelta import logging from typing import Any -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode +from eq3btsmart.const import ( + EQ3_DEFAULT_AWAY_TEMP, + EQ3_MAX_TEMP, + EQ3_OFF_TEMP, + Eq3OperationMode, + Eq3Preset, +) from eq3btsmart.exceptions import Eq3Exception from homeassistant.components.climate import ( @@ -20,9 +27,11 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util from . import Eq3ConfigEntry from .const import ( + DEFAULT_AWAY_HOURS, EQ_TO_HA_HVAC, HA_TO_EQ_HVAC, CurrentTemperatureSelector, @@ -57,8 +66,8 @@ class Eq3Climate(Eq3Entity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_min_temp = EQ3BT_OFF_TEMP - _attr_max_temp = EQ3BT_MAX_TEMP + _attr_min_temp = EQ3_OFF_TEMP + _attr_max_temp = EQ3_MAX_TEMP _attr_precision = PRECISION_HALVES _attr_hvac_modes = list(HA_TO_EQ_HVAC.keys()) _attr_preset_modes = list(Preset) @@ -70,38 +79,21 @@ class Eq3Climate(Eq3Entity, ClimateEntity): _target_temperature: float | None = None @callback - def _async_on_updated(self) -> None: - """Handle updated data from the thermostat.""" - - if self._thermostat.status is not None: - self._async_on_status_updated() - - if self._thermostat.device_data is not None: - self._async_on_device_updated() - - super()._async_on_updated() - - @callback - def _async_on_status_updated(self) -> None: + def _async_on_status_updated(self, data: Any) -> None: """Handle updated status from the thermostat.""" - if self._thermostat.status is None: - return - - self._target_temperature = self._thermostat.status.target_temperature.value + self._target_temperature = self._thermostat.status.target_temperature self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode] self._attr_current_temperature = self._get_current_temperature() self._attr_target_temperature = self._get_target_temperature() self._attr_preset_mode = self._get_current_preset_mode() self._attr_hvac_action = self._get_current_hvac_action() + super()._async_on_status_updated(data) @callback - def _async_on_device_updated(self) -> None: + def _async_on_device_updated(self, data: Any) -> None: """Handle updated device data from the thermostat.""" - if self._thermostat.device_data is None: - return - device_registry = dr.async_get(self.hass) if device := device_registry.async_get_device( connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, @@ -109,8 +101,9 @@ class Eq3Climate(Eq3Entity, ClimateEntity): device_registry.async_update_device( device.id, sw_version=str(self._thermostat.device_data.firmware_version), - serial_number=self._thermostat.device_data.device_serial.value, + serial_number=self._thermostat.device_data.device_serial, ) + super()._async_on_device_updated(data) def _get_current_temperature(self) -> float | None: """Return the current temperature.""" @@ -119,17 +112,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case CurrentTemperatureSelector.NOTHING: return None case CurrentTemperatureSelector.VALVE: - if self._thermostat.status is None: - return None - return float(self._thermostat.status.valve_temperature) case CurrentTemperatureSelector.UI: return self._target_temperature case CurrentTemperatureSelector.DEVICE: - if self._thermostat.status is None: - return None - - return float(self._thermostat.status.target_temperature.value) + return float(self._thermostat.status.target_temperature) case CurrentTemperatureSelector.ENTITY: state = self.hass.states.get(self._eq3_config.external_temp_sensor) if state is not None: @@ -147,16 +134,12 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case TargetTemperatureSelector.TARGET: return self._target_temperature case TargetTemperatureSelector.LAST_REPORTED: - if self._thermostat.status is None: - return None - - return float(self._thermostat.status.target_temperature.value) + return float(self._thermostat.status.target_temperature) def _get_current_preset_mode(self) -> str: """Return the current preset mode.""" - if (status := self._thermostat.status) is None: - return PRESET_NONE + status = self._thermostat.status if status.is_window_open: return Preset.WINDOW_OPEN if status.is_boost: @@ -165,7 +148,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): return Preset.LOW_BATTERY if status.is_away: return Preset.AWAY - if status.operation_mode is OperationMode.ON: + if status.operation_mode is Eq3OperationMode.ON: return Preset.OPEN if status.presets is None: return PRESET_NONE @@ -179,10 +162,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): def _get_current_hvac_action(self) -> HVACAction: """Return the current hvac action.""" - if ( - self._thermostat.status is None - or self._thermostat.status.operation_mode is OperationMode.OFF - ): + if self._thermostat.status.operation_mode is Eq3OperationMode.OFF: return HVACAction.OFF if self._thermostat.status.valve == 0: return HVACAction.IDLE @@ -227,7 +207,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): """Set new target hvac mode.""" if hvac_mode is HVACMode.OFF: - await self.async_set_temperature(temperature=EQ3BT_OFF_TEMP) + await self.async_set_temperature(temperature=EQ3_OFF_TEMP) try: await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode]) @@ -241,10 +221,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case Preset.BOOST: await self._thermostat.async_set_boost(True) case Preset.AWAY: - await self._thermostat.async_set_away(True) + away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS) + await self._thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP) case Preset.ECO: await self._thermostat.async_set_preset(Eq3Preset.ECO) case Preset.COMFORT: await self._thermostat.async_set_preset(Eq3Preset.COMFORT) case Preset.OPEN: - await self._thermostat.async_set_mode(OperationMode.ON) + await self._thermostat.async_set_mode(Eq3OperationMode.ON) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index a5f7ea2ff95..33698d2d076 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -2,7 +2,7 @@ from enum import Enum -from eq3btsmart.const import OperationMode +from eq3btsmart.const import Eq3OperationMode from homeassistant.components.climate import ( PRESET_AWAY, @@ -34,17 +34,17 @@ ENTITY_KEY_AWAY_UNTIL = "away_until" GET_DEVICE_TIMEOUT = 5 # seconds -EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { - OperationMode.OFF: HVACMode.OFF, - OperationMode.ON: HVACMode.HEAT, - OperationMode.AUTO: HVACMode.AUTO, - OperationMode.MANUAL: HVACMode.HEAT, +EQ_TO_HA_HVAC: dict[Eq3OperationMode, HVACMode] = { + Eq3OperationMode.OFF: HVACMode.OFF, + Eq3OperationMode.ON: HVACMode.HEAT, + Eq3OperationMode.AUTO: HVACMode.AUTO, + Eq3OperationMode.MANUAL: HVACMode.HEAT, } HA_TO_EQ_HVAC = { - HVACMode.OFF: OperationMode.OFF, - HVACMode.AUTO: OperationMode.AUTO, - HVACMode.HEAT: OperationMode.MANUAL, + HVACMode.OFF: Eq3OperationMode.OFF, + HVACMode.AUTO: Eq3OperationMode.AUTO, + HVACMode.HEAT: Eq3OperationMode.MANUAL, } @@ -81,6 +81,7 @@ class TargetTemperatureSelector(str, Enum): DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET DEFAULT_SCAN_INTERVAL = 10 # seconds +DEFAULT_AWAY_HOURS = 30 * 24 SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index e68545c08c7..e8dbb934289 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -1,5 +1,10 @@ """Base class for all eQ-3 entities.""" +from typing import Any + +from eq3btsmart import Eq3Exception +from eq3btsmart.const import Eq3Event + from homeassistant.core import callback from homeassistant.helpers.device_registry import ( CONNECTION_BLUETOOTH, @@ -45,7 +50,15 @@ class Eq3Entity(Entity): async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - self._thermostat.register_update_callback(self._async_on_updated) + self._thermostat.register_callback( + Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated + ) + self._thermostat.register_callback( + Eq3Event.STATUS_RECEIVED, self._async_on_status_updated + ) + self._thermostat.register_callback( + Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated + ) self.async_on_remove( async_dispatcher_connect( @@ -65,10 +78,25 @@ class Eq3Entity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" - self._thermostat.unregister_update_callback(self._async_on_updated) + self._thermostat.unregister_callback( + Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated + ) + self._thermostat.unregister_callback( + Eq3Event.STATUS_RECEIVED, self._async_on_status_updated + ) + self._thermostat.unregister_callback( + Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated + ) - def _async_on_updated(self) -> None: - """Handle updated data from the thermostat.""" + @callback + def _async_on_status_updated(self, data: Any) -> None: + """Handle updated status from the thermostat.""" + + self.async_write_ha_state() + + @callback + def _async_on_device_updated(self, data: Any) -> None: + """Handle updated device data from the thermostat.""" self.async_write_ha_state() @@ -90,4 +118,9 @@ class Eq3Entity(Entity): def available(self) -> bool: """Whether the entity is available.""" - return self._thermostat.status is not None and self._attr_available + try: + _ = self._thermostat.status + except Eq3Exception: + return False + + return self._attr_available diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 889401ffc3e..62128077f2f 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.16.0"] + "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"] } diff --git a/homeassistant/components/eq3btsmart/number.py b/homeassistant/components/eq3btsmart/number.py index c3cbd8eae31..c9601a4437e 100644 --- a/homeassistant/components/eq3btsmart/number.py +++ b/homeassistant/components/eq3btsmart/number.py @@ -1,17 +1,12 @@ """Platform for eq3 number entities.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import TYPE_CHECKING from eq3btsmart import Thermostat -from eq3btsmart.const import ( - EQ3BT_MAX_OFFSET, - EQ3BT_MAX_TEMP, - EQ3BT_MIN_OFFSET, - EQ3BT_MIN_TEMP, -) -from eq3btsmart.models import Presets +from eq3btsmart.const import EQ3_MAX_OFFSET, EQ3_MAX_TEMP, EQ3_MIN_OFFSET, EQ3_MIN_TEMP +from eq3btsmart.models import Presets, Status from homeassistant.components.number import ( NumberDeviceClass, @@ -42,7 +37,7 @@ class Eq3NumberEntityDescription(NumberEntityDescription): value_func: Callable[[Presets], float] value_set_func: Callable[ [Thermostat], - Callable[[float], Awaitable[None]], + Callable[[float], Coroutine[None, None, Status]], ] mode: NumberMode = NumberMode.BOX entity_category: EntityCategory | None = EntityCategory.CONFIG @@ -51,44 +46,44 @@ class Eq3NumberEntityDescription(NumberEntityDescription): NUMBER_ENTITY_DESCRIPTIONS = [ Eq3NumberEntityDescription( key=ENTITY_KEY_COMFORT, - value_func=lambda presets: presets.comfort_temperature.value, + value_func=lambda presets: presets.comfort_temperature, value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature, translation_key=ENTITY_KEY_COMFORT, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_ECO, - value_func=lambda presets: presets.eco_temperature.value, + value_func=lambda presets: presets.eco_temperature, value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature, translation_key=ENTITY_KEY_ECO, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, - value_func=lambda presets: presets.window_open_temperature.value, + value_func=lambda presets: presets.window_open_temperature, value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature, translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_OFFSET, - value_func=lambda presets: presets.offset_temperature.value, + value_func=lambda presets: presets.offset_temperature, value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset, translation_key=ENTITY_KEY_OFFSET, - native_min_value=EQ3BT_MIN_OFFSET, - native_max_value=EQ3BT_MAX_OFFSET, + native_min_value=EQ3_MIN_OFFSET, + native_max_value=EQ3_MAX_OFFSET, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, @@ -96,7 +91,7 @@ NUMBER_ENTITY_DESCRIPTIONS = [ Eq3NumberEntityDescription( key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration, - value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60, + value_func=lambda presets: presets.window_open_time.total_seconds() / 60, translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, native_min_value=0, native_max_value=60, @@ -137,7 +132,6 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity): """Return the state of the entity.""" if TYPE_CHECKING: - assert self._thermostat.status is not None assert self._thermostat.status.presets is not None return self.entity_description.value_func(self._thermostat.status.presets) @@ -152,7 +146,7 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity): """Return whether the entity is available.""" return ( - self._thermostat.status is not None + super().available and self._thermostat.status.presets is not None and self._attr_available ) diff --git a/homeassistant/components/eq3btsmart/schemas.py b/homeassistant/components/eq3btsmart/schemas.py index 643bb4a02a6..daeed5a05e3 100644 --- a/homeassistant/components/eq3btsmart/schemas.py +++ b/homeassistant/components/eq3btsmart/schemas.py @@ -1,12 +1,12 @@ """Voluptuous schemas for eq3btsmart.""" -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_MIN_TEMP +from eq3btsmart.const import EQ3_MAX_TEMP, EQ3_MIN_TEMP import voluptuous as vol from homeassistant.const import CONF_MAC from homeassistant.helpers import config_validation as cv -SCHEMA_TEMPERATURE = vol.Range(min=EQ3BT_MIN_TEMP, max=EQ3BT_MAX_TEMP) +SCHEMA_TEMPERATURE = vol.Range(min=EQ3_MIN_TEMP, max=EQ3_MAX_TEMP) SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string}) SCHEMA_MAC = vol.Schema( { diff --git a/homeassistant/components/eq3btsmart/sensor.py b/homeassistant/components/eq3btsmart/sensor.py index aab3cbf1925..0f61ef22452 100644 --- a/homeassistant/components/eq3btsmart/sensor.py +++ b/homeassistant/components/eq3btsmart/sensor.py @@ -3,7 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING from eq3btsmart.models import Status @@ -40,9 +39,7 @@ SENSOR_ENTITY_DESCRIPTIONS = [ Eq3SensorEntityDescription( key=ENTITY_KEY_AWAY_UNTIL, translation_key=ENTITY_KEY_AWAY_UNTIL, - value_func=lambda status: ( - status.away_until.value if status.away_until else None - ), + value_func=lambda status: (status.away_until if status.away_until else None), device_class=SensorDeviceClass.DATE, ), ] @@ -78,7 +75,4 @@ class Eq3SensorEntity(Eq3Entity, SensorEntity): def native_value(self) -> int | datetime | None: """Return the value reported by the sensor.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/switch.py b/homeassistant/components/eq3btsmart/switch.py index 61da133cb71..0d5521fee32 100644 --- a/homeassistant/components/eq3btsmart/switch.py +++ b/homeassistant/components/eq3btsmart/switch.py @@ -1,26 +1,45 @@ """Platform for eq3 switch entities.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from datetime import timedelta +from functools import partial +from typing import Any from eq3btsmart import Thermostat +from eq3btsmart.const import EQ3_DEFAULT_AWAY_TEMP, Eq3OperationMode from eq3btsmart.models import Status from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util from . import Eq3ConfigEntry -from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK +from .const import ( + DEFAULT_AWAY_HOURS, + ENTITY_KEY_AWAY, + ENTITY_KEY_BOOST, + ENTITY_KEY_LOCK, +) from .entity import Eq3Entity +async def async_set_away(thermostat: Thermostat, enable: bool) -> Status: + """Backport old async_set_away behavior.""" + + if not enable: + return await thermostat.async_set_mode(Eq3OperationMode.AUTO) + + away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS) + return await thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP) + + @dataclass(frozen=True, kw_only=True) class Eq3SwitchEntityDescription(SwitchEntityDescription): """Entity description for eq3 switch entities.""" - toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]] + toggle_func: Callable[[Thermostat], Callable[[bool], Coroutine[None, None, Status]]] value_func: Callable[[Status], bool] @@ -40,7 +59,7 @@ SWITCH_ENTITY_DESCRIPTIONS = [ Eq3SwitchEntityDescription( key=ENTITY_KEY_AWAY, translation_key=ENTITY_KEY_AWAY, - toggle_func=lambda thermostat: thermostat.async_set_away, + toggle_func=lambda thermostat: partial(async_set_away, thermostat), value_func=lambda status: status.is_away, ), ] @@ -88,7 +107,4 @@ class Eq3SwitchEntity(Eq3Entity, SwitchEntity): def is_on(self) -> bool: """Return the state of the switch.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 073a1ec8ae9..f6367165400 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -60,6 +60,7 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START, VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END, VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: PipelineEventType.INTENT_PROGRESS, VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, @@ -282,6 +283,12 @@ class EsphomeAssistSatellite( elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: + data_to_send = { + "tts_start_streaming": "1" + if (event.data and event.data.get("tts_start_streaming")) + else "0", + } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None data_to_send = { @@ -332,7 +339,7 @@ class EsphomeAssistSatellite( } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: assert event.data is not None - if tts_output := event.data["tts_output"]: + if tts_output := event.data.get("tts_output"): path = tts_output["url"] url = async_process_play_media_url(self.hass, path) data_to_send = {"url": url} diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 5f879edf005..a12af89aca2 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -63,9 +63,7 @@ class ESPHomeDashboardManager: if not (data := self._data) or not (info := data.get("info")): return if is_hassio(self._hass): - from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel - get_addons_info, - ) + from homeassistant.components.hassio import get_addons_info # noqa: PLC0415 if (addons := get_addons_info(self._hass)) is not None and info[ "addon_slug" diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9b70aba4de1..6142b9ce5ec 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==32.2.1", + "aioesphomeapi==32.2.4", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/homeassistant/components/ffmpeg/services.py b/homeassistant/components/ffmpeg/services.py index ad7946869ec..6b522799f4f 100644 --- a/homeassistant/components/ffmpeg/services.py +++ b/homeassistant/components/ffmpeg/services.py @@ -5,7 +5,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -35,6 +35,7 @@ async def _async_service_handle(service: ServiceCall) -> None: async_dispatcher_send(service.hass, SIGNAL_FFMPEG_RESTART, entity_ids) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register FFmpeg services.""" diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index 0008b56345e..e2027120d43 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -28,45 +28,36 @@ async def async_setup_entry( ) -> None: """Set up the Fibaro covers.""" controller = entry.runtime_data - async_add_entities( - [FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]], - True, - ) + + 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) -class FibaroCover(FibaroEntity, CoverEntity): - """Representation a Fibaro Cover.""" +class PositionableFibaroCover(FibaroEntity, CoverEntity): + """Representation of a fibaro cover which supports positioning.""" def __init__(self, fibaro_device: DeviceModel) -> None: - """Initialize the Vera device.""" + """Initialize the 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): + def bound(position: int | None) -> int | None: """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() @@ -74,20 +65,15 @@ class FibaroCover(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. - 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" + 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 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: + if self.current_cover_position is not None: closed = self.current_cover_position == 0 self._attr_is_closed = closed @@ -96,7 +82,7 @@ class FibaroCover(FibaroEntity, CoverEntity): self.set_level(cast(int, kwargs.get(ATTR_POSITION))) def set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" + """Move the slats to a specific position.""" self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION))) def open_cover(self, **kwargs: Any) -> None: @@ -118,3 +104,62 @@ class FibaroCover(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") diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 446b9b9f7ff..a82769bf9ee 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -83,8 +83,8 @@ class FibaroLight(FibaroEntity, LightEntity): ) supports_dimming = ( fibaro_device.has_interface("levelChange") - and "setValue" in fibaro_device.actions - ) + or fibaro_device.type == "com.fibaro.multilevelSwitch" + ) and "setValue" in fibaro_device.actions if supports_color and supports_white_v: self._attr_supported_color_modes = {ColorMode.RGBW} diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 9029a8265bb..37e1259a35c 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "auth": { "title": "Link Fitbit" diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 0145dea27bb..4e4660bc545 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -125,8 +125,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Async function to set mode to climate.""" - if hvac_mode not in SUPPORTED_HVAC_MODES: - raise ValueError(f"Got unsupported hvac_mode {hvac_mode}") payload = {"heatingCoolingState": HVAC_INVERT_MAP[hvac_mode]} await put_state( diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 9610fe4b34d..faf82b4b516 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -33,7 +33,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up fritzboxtools integration.""" - await async_setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index 02e6c91f4bf..bba80eadf98 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -10,7 +10,7 @@ from fritzconnection.core.exceptions import ( from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_extract_config_entry_ids @@ -64,7 +64,8 @@ async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: ) from ex -async def async_setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Fritz integration.""" hass.services.async_register( diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9a0627f9f42..9694c299b23 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -364,8 +364,7 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: if dev_repo_path is not None: return pathlib.Path(dev_repo_path) / "hass_frontend" # Keep import here so that we can import frontend without installing reqs - # pylint: disable-next=import-outside-toplevel - import hass_frontend + import hass_frontend # noqa: PLC0415 return hass_frontend.where() diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7282482f329..efb4891debf 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.0"] + "requirements": ["home-assistant-frontend==20250531.3"] } diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index 772e7f79242..dd9fde88683 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -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.""" - await async_setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index ac6faf76a9d..4a57572f4ed 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -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 +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -23,71 +23,73 @@ from .const import ( from .coordinator import FullyKioskDataUpdateCoordinator -async def async_setup_services(hass: HomeAssistant) -> None: +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: """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( @@ -107,7 +109,7 @@ async 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( { diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index 9989af9a75c..ca6e9d5e67f 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 31acdd2de50..4e15b93330c 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,8 +1,11 @@ """The go2rtc component.""" +from __future__ import annotations + import logging import shutil +from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from awesomeversion import AwesomeVersion from go2rtc_client import Go2RtcRestClient @@ -32,7 +35,7 @@ from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOM from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import ( config_validation as cv, discovery_flow, @@ -98,6 +101,7 @@ CONFIG_SCHEMA = vol.Schema( _DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) +type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -151,13 +155,14 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None: await hass.config_entries.async_remove(entry.entry_id) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool: """Set up go2rtc from a config entry.""" - url = hass.data[_DATA_GO2RTC] + url = hass.data[_DATA_GO2RTC] + session = async_get_clientsession(hass) + client = Go2RtcRestClient(session, url) # Validate the server URL try: - client = Go2RtcRestClient(async_get_clientsession(hass), url) version = await client.validate_server_version() if version < AwesomeVersion(RECOMMENDED_VERSION): ir.async_create_issue( @@ -188,13 +193,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False - provider = WebRTCProvider(hass, url) - async_register_webrtc_provider(hass, provider) + provider = entry.runtime_data = WebRTCProvider(hass, url, session, client) + entry.async_on_unload(async_register_webrtc_provider(hass, provider)) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool: """Unload a go2rtc config entry.""" + await entry.runtime_data.teardown() return True @@ -206,12 +212,18 @@ async def _get_binary(hass: HomeAssistant) -> str | None: class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" - def __init__(self, hass: HomeAssistant, url: str) -> None: + def __init__( + self, + hass: HomeAssistant, + url: str, + session: ClientSession, + rest_client: Go2RtcRestClient, + ) -> None: """Initialize the WebRTC provider.""" self._hass = hass self._url = url - self._session = async_get_clientsession(hass) - self._rest_client = Go2RtcRestClient(self._session, url) + self._session = session + self._rest_client = rest_client self._sessions: dict[str, Go2RtcWsClient] = {} @property @@ -232,32 +244,16 @@ class WebRTCProvider(CameraWebRTCProvider): send_message: WebRTCSendMessage, ) -> None: """Handle the WebRTC offer and return the answer via the provided callback.""" + try: + await self._update_stream_source(camera) + except HomeAssistantError as err: + send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err))) + return + self._sessions[session_id] = ws_client = Go2RtcWsClient( self._session, self._url, source=camera.entity_id ) - if not (stream_source := await camera.stream_source()): - send_message( - WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") - ) - return - - streams = await self._rest_client.streams.list() - - if (stream := streams.get(camera.entity_id)) is None or not any( - stream_source == producer.url for producer in stream.producers - ): - await self._rest_client.streams.add( - camera.entity_id, - [ - stream_source, - # We are setting any ffmpeg rtsp related logs to debug - # Connection problems to the camera will be logged by the first stream - # Therefore setting it to debug will not hide any important logs - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - ], - ) - @callback def on_messages(message: ReceiveMessages) -> None: """Handle messages.""" @@ -291,3 +287,48 @@ class WebRTCProvider(CameraWebRTCProvider): """Close the session.""" ws_client = self._sessions.pop(session_id) self._hass.async_create_task(ws_client.close()) + + async def async_get_image( + self, + camera: Camera, + width: int | None = None, + height: int | None = None, + ) -> bytes | None: + """Get an image from the camera.""" + await self._update_stream_source(camera) + return await self._rest_client.get_jpeg_snapshot( + camera.entity_id, width, height + ) + + async def _update_stream_source(self, camera: Camera) -> None: + """Update the stream source in go2rtc config if needed.""" + if not (stream_source := await camera.stream_source()): + await self.teardown() + raise HomeAssistantError("Camera has no stream source") + + if not self.async_is_supported(stream_source): + await self.teardown() + raise HomeAssistantError("Stream source is not supported by go2rtc") + + streams = await self._rest_client.streams.list() + + if (stream := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream.producers + ): + await self._rest_client.streams.add( + camera.entity_id, + [ + stream_source, + # We are setting any ffmpeg rtsp related logs to debug + # Connection problems to the camera will be logged by the first stream + # Therefore setting it to debug will not hide any important logs + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", + ], + ) + + async def teardown(self) -> None: + """Tear down the provider.""" + for ws_client in self._sessions.values(): + await ws_client.close() + self._sessions.clear() diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 7b5f8955947..67441930f7a 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -109,6 +109,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="timestamp", translation_key="timestamp", + device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index fecd245869a..1acfa3a2ad1 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"] + "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"] } diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 4f3e27af27e..7ac16ab0af6 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 4309a99c0ca..6d4c9e1d219 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -212,8 +212,7 @@ class AbstractConfig(ABC): def async_enable_report_state(self) -> None: """Enable proactive mode.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from .report_state import async_enable_report_state + from .report_state import async_enable_report_state # noqa: PLC0415 if self._unsub_report_state is None: self._unsub_report_state = async_enable_report_state(self.hass, self) @@ -395,8 +394,7 @@ class AbstractConfig(ABC): async def _handle_local_webhook(self, hass, webhook_id, request): """Handle an incoming local SDK message.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from . import smart_home + from . import smart_home # noqa: PLC0415 self._local_last_active = utcnow() @@ -655,8 +653,9 @@ class GoogleEntity: if "matter" in self.hass.config.components and any( x for x in device_entry.identifiers if x[0] == "matter" ): - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.matter import get_matter_device_info + from homeassistant.components.matter import ( # noqa: PLC0415 + get_matter_device_info, + ) # Import matter can block the event loop for multiple seconds # so we import it here to avoid blocking the event loop during diff --git a/homeassistant/components/google_assistant_sdk/services.py b/homeassistant/components/google_assistant_sdk/services.py index 7f0227bf040..981f4d8ba5c 100644 --- a/homeassistant/components/google_assistant_sdk/services.py +++ b/homeassistant/components/google_assistant_sdk/services.py @@ -11,6 +11,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.helpers import config_validation as cv @@ -49,6 +50,7 @@ async def _send_text_command(call: ServiceCall) -> ServiceResponse: return None +@callback def async_setup_services(hass: HomeAssistant) -> None: """Add the services for Google Assistant SDK.""" diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 885ff0aad71..2622333e15f 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index e6658fb08e9..3dc958b7dfc 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 79d092a60c3..7e9ca550275 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -45,7 +45,10 @@ CONF_IMAGE_FILENAME = "image_filename" CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = ( + Platform.CONVERSATION, + Platform.TTS, +) type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 239b3ff763e..831e7d8f508 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -6,9 +6,11 @@ DOMAIN = "google_generative_ai_conversation" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" +ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash" +RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index c466101e7e4..00199f5fe1f 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -2,63 +2,18 @@ from __future__ import annotations -import codecs -from collections.abc import AsyncGenerator, Callable -from dataclasses import replace -from typing import Any, Literal, cast - -from google.genai.errors import APIError, ClientError -from google.genai.types import ( - AutomaticFunctionCallingConfig, - Content, - FunctionDeclaration, - GenerateContentConfig, - GenerateContentResponse, - GoogleSearch, - HarmCategory, - Part, - SafetySetting, - Schema, - Tool, -) -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - CONF_CHAT_MODEL, - CONF_DANGEROUS_BLOCK_THRESHOLD, - CONF_HARASSMENT_BLOCK_THRESHOLD, - CONF_HATE_BLOCK_THRESHOLD, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_SEXUAL_BLOCK_THRESHOLD, - CONF_TEMPERATURE, - CONF_TOP_K, - CONF_TOP_P, - CONF_USE_GOOGLE_SEARCH_TOOL, - DOMAIN, - LOGGER, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_HARM_BLOCK_THRESHOLD, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_TOP_K, - RECOMMENDED_TOP_P, -) - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 - -ERROR_GETTING_RESPONSE = ( - "Sorry, I had a problem getting a response from Google Generative AI." -) +from .const import CONF_PROMPT, DOMAIN, LOGGER +from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -71,265 +26,18 @@ async def async_setup_entry( async_add_entities([agent]) -SUPPORTED_SCHEMA_KEYS = { - # Gemini API does not support all of the OpenAPI schema - # SoT: https://ai.google.dev/api/caching#Schema - "type", - "format", - "description", - "nullable", - "enum", - "max_items", - "min_items", - "properties", - "required", - "items", -} - - -def _camel_to_snake(name: str) -> str: - """Convert camel case to snake case.""" - return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") - - -def _format_schema(schema: dict[str, Any]) -> Schema: - """Format the schema to be compatible with Gemini API.""" - if subschemas := schema.get("allOf"): - for subschema in subschemas: # Gemini API does not support allOf keys - if "type" in subschema: # Fallback to first subschema with 'type' field - return _format_schema(subschema) - return _format_schema( - subschemas[0] - ) # Or, if not found, to any of the subschemas - - result = {} - for key, val in schema.items(): - key = _camel_to_snake(key) - if key not in SUPPORTED_SCHEMA_KEYS: - continue - if key == "type": - val = val.upper() - elif key == "format": - # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema - # formats that are not supported are ignored - if schema.get("type") == "string" and val not in ("enum", "date-time"): - continue - if schema.get("type") == "number" and val not in ("float", "double"): - continue - if schema.get("type") == "integer" and val not in ("int32", "int64"): - continue - if schema.get("type") not in ("string", "number", "integer"): - continue - elif key == "items": - val = _format_schema(val) - elif key == "properties": - val = {k: _format_schema(v) for k, v in val.items()} - result[key] = val - - if result.get("enum") and result.get("type") != "STRING": - # enum is only allowed for STRING type. This is safe as long as the schema - # contains vol.Coerce for the respective type, for example: - # vol.All(vol.Coerce(int), vol.In([1, 2, 3])) - result["type"] = "STRING" - result["enum"] = [str(item) for item in result["enum"]] - - if result.get("type") == "OBJECT" and not result.get("properties"): - # An object with undefined properties is not supported by Gemini API. - # Fallback to JSON string. This will probably fail for most tools that want it, - # but we don't have a better fallback strategy so far. - result["properties"] = {"json": {"type": "STRING"}} - result["required"] = [] - return cast(Schema, result) - - -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> Tool: - """Format tool specification.""" - - if tool.parameters.schema: - parameters = _format_schema( - convert(tool.parameters, custom_serializer=custom_serializer) - ) - else: - parameters = None - - return Tool( - function_declarations=[ - FunctionDeclaration( - name=tool.name, - description=tool.description, - parameters=parameters, - ) - ] - ) - - -def _escape_decode(value: Any) -> Any: - """Recursively call codecs.escape_decode on all values.""" - if isinstance(value, str): - return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] - if isinstance(value, list): - return [_escape_decode(item) for item in value] - if isinstance(value, dict): - return {k: _escape_decode(v) for k, v in value.items()} - return value - - -def _create_google_tool_response_parts( - parts: list[conversation.ToolResultContent], -) -> list[Part]: - """Create Google tool response parts.""" - return [ - Part.from_function_response( - name=tool_result.tool_name, response=tool_result.tool_result - ) - for tool_result in parts - ] - - -def _create_google_tool_response_content( - content: list[conversation.ToolResultContent], -) -> Content: - """Create a Google tool response content.""" - return Content( - role="user", - parts=_create_google_tool_response_parts(content), - ) - - -def _convert_content( - content: conversation.UserContent - | conversation.AssistantContent - | conversation.SystemContent, -) -> Content: - """Convert HA content to Google content.""" - if content.role != "assistant" or not content.tool_calls: - role = "model" if content.role == "assistant" else content.role - return Content( - role=role, - parts=[ - Part.from_text(text=content.content if content.content else ""), - ], - ) - - # Handle the Assistant content with tool calls. - assert type(content) is conversation.AssistantContent - parts: list[Part] = [] - - if content.content: - parts.append(Part.from_text(text=content.content)) - - if content.tool_calls: - parts.extend( - [ - Part.from_function_call( - name=tool_call.tool_name, - args=_escape_decode(tool_call.tool_args), - ) - for tool_call in content.tool_calls - ] - ) - - return Content(role="model", parts=parts) - - -async def _transform_stream( - result: AsyncGenerator[GenerateContentResponse], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - new_message = True - try: - async for response in result: - LOGGER.debug("Received response chunk: %s", response) - chunk: conversation.AssistantContentDeltaDict = {} - - if new_message: - chunk["role"] = "assistant" - new_message = False - - # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. - if response.prompt_feedback or not response.candidates: - reason = ( - response.prompt_feedback.block_reason_message - if response.prompt_feedback - else "unknown" - ) - raise HomeAssistantError( - f"The message got blocked due to content violations, reason: {reason}" - ) - - candidate = response.candidates[0] - - if ( - candidate.finish_reason is not None - and candidate.finish_reason != "STOP" - ): - # The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason - LOGGER.error( - "Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason", - candidate.finish_reason, - ) - raise HomeAssistantError( - f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}" - ) - - response_parts = ( - candidate.content.parts - if candidate.content is not None and candidate.content.parts is not None - else [] - ) - - content = "".join([part.text for part in response_parts if part.text]) - tool_calls = [] - for part in response_parts: - if not part.function_call: - continue - tool_call = part.function_call - tool_name = tool_call.name if tool_call.name else "" - tool_args = _escape_decode(tool_call.args) - tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - ) - - if tool_calls: - chunk["tool_calls"] = tool_calls - - chunk["content"] = content - yield chunk - except ( - APIError, - ValueError, - ) as err: - LOGGER.error("Error sending message: %s %s", type(err), err) - if isinstance(err, APIError): - message = err.message - else: - message = type(err).__name__ - error = f"{ERROR_GETTING_RESPONSE}: {message}" - raise HomeAssistantError(error) from err - - class GoogleGenerativeAIConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + GoogleGenerativeAILLMBaseEntity, ): """Google Generative AI conversation agent.""" - _attr_has_entity_name = True - _attr_name = None _attr_supports_streaming = True def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" - self.entry = entry - self._genai_client = entry.runtime_data - self._attr_unique_id = entry.entry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, - manufacturer="Google", - model="Generative AI", - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry) if self.entry.options.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -356,13 +64,6 @@ class GoogleGenerativeAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - def _fix_tool_name(self, tool_name: str) -> str: - """Fix tool name if needed.""" - # The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool - # name. This makes sure when it incorrectly changes the name, that we change it - # back for HA to call. - return tool_name if tool_name != "HasListAddItem" else "HassListAddItem" - async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -372,162 +73,16 @@ class GoogleGenerativeAIConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() - tools: list[Tool | Callable[..., Any]] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - # Using search grounding allows the model to retrieve information from the web, - # however, it may interfere with how the model decides to use some tools, or entities - # for example weather entity may be disregarded if the model chooses to Google it. - if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: - tools = tools or [] - tools.append(Tool(google_search=GoogleSearch())) - - model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - # Avoid INVALID_ARGUMENT Developer instruction is not enabled for - supports_system_instruction = ( - "gemma" not in model_name - and "gemini-2.0-flash-preview-image-generation" not in model_name - ) - - prompt_content = cast( - conversation.SystemContent, - chat_log.content[0], - ) - - if prompt_content.content: - prompt = prompt_content.content - else: - raise HomeAssistantError("Invalid prompt content") - - messages: list[Content] = [] - - # Google groups tool results, we do not. Group them before sending. - tool_results: list[conversation.ToolResultContent] = [] - - for chat_content in chat_log.content[1:-1]: - if chat_content.role == "tool_result": - tool_results.append(chat_content) - continue - - if ( - not isinstance(chat_content, conversation.ToolResultContent) - and chat_content.content == "" - ): - # Skipping is not possible since the number of function calls need to match the number of function responses - # and skipping one would mean removing the other and hence this would prevent a proper chat log - chat_content = replace(chat_content, content=" ") - - if tool_results: - messages.append(_create_google_tool_response_content(tool_results)) - tool_results.clear() - - messages.append(_convert_content(chat_content)) - - # The SDK requires the first message to be a user message - # This is not the case if user used `start_conversation` - # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 - if messages and messages[0].role != "user": - messages.insert( - 0, - Content(role="user", parts=[Part.from_text(text=" ")]), - ) - - if tool_results: - messages.append(_create_google_tool_response_content(tool_results)) - generateContentConfig = GenerateContentConfig( - temperature=self.entry.options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), - top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - max_output_tokens=self.entry.options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - safety_settings=[ - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, - threshold=self.entry.options.get( - CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HARASSMENT, - threshold=self.entry.options.get( - CONF_HARASSMENT_BLOCK_THRESHOLD, - RECOMMENDED_HARM_BLOCK_THRESHOLD, - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, - threshold=self.entry.options.get( - CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, - threshold=self.entry.options.get( - CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - ], - tools=tools or None, - system_instruction=prompt if supports_system_instruction else None, - automatic_function_calling=AutomaticFunctionCallingConfig( - disable=True, maximum_remote_calls=None - ), - ) - - if not supports_system_instruction: - messages = [ - Content(role="user", parts=[Part.from_text(text=prompt)]), - Content(role="model", parts=[Part.from_text(text="Ok")]), - *messages, - ] - chat = self._genai_client.aio.chats.create( - model=model_name, history=messages, config=generateContentConfig - ) - chat_request: str | list[Part] = user_input.text - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - chat_response_generator = await chat.send_message_stream( - message=chat_request - ) - except ( - APIError, - ClientError, - ValueError, - ) as err: - LOGGER.error("Error sending message: %s %s", type(err), err) - error = ERROR_GETTING_RESPONSE - raise HomeAssistantError(error) from err - - chat_request = _create_google_tool_response_parts( - [ - content - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, - _transform_stream(chat_response_generator), - ) - if isinstance(content, conversation.ToolResultContent) - ] - ) - - if not chat_log.unresponded_tool_results: - break + await self._async_handle_chat_log(chat_log) response = intent.IntentResponse(language=user_input.language) if not isinstance(chat_log.content[-1], conversation.AssistantContent): @@ -535,7 +90,7 @@ class GoogleGenerativeAIConversationEntity( "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", chat_log.content[-1], ) - raise HomeAssistantError(f"{ERROR_GETTING_RESPONSE}") + raise HomeAssistantError(ERROR_GETTING_RESPONSE) response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( response=response, diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py new file mode 100644 index 00000000000..7eef3dbacff --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -0,0 +1,475 @@ +"""Conversation support for the Google Generative AI Conversation integration.""" + +from __future__ import annotations + +import codecs +from collections.abc import AsyncGenerator, Callable +from dataclasses import replace +from typing import Any, cast + +from google.genai.errors import APIError, ClientError +from google.genai.types import ( + AutomaticFunctionCallingConfig, + Content, + FunctionDeclaration, + GenerateContentConfig, + GenerateContentResponse, + GoogleSearch, + HarmCategory, + Part, + SafetySetting, + Schema, + Tool, +) +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from .const import ( + CONF_CHAT_MODEL, + CONF_DANGEROUS_BLOCK_THRESHOLD, + CONF_HARASSMENT_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD, + CONF_MAX_TOKENS, + CONF_SEXUAL_BLOCK_THRESHOLD, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, +) + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +ERROR_GETTING_RESPONSE = ( + "Sorry, I had a problem getting a response from Google Generative AI." +) + + +SUPPORTED_SCHEMA_KEYS = { + # Gemini API does not support all of the OpenAPI schema + # SoT: https://ai.google.dev/api/caching#Schema + "type", + "format", + "description", + "nullable", + "enum", + "max_items", + "min_items", + "properties", + "required", + "items", +} + + +def _camel_to_snake(name: str) -> str: + """Convert camel case to snake case.""" + return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") + + +def _format_schema(schema: dict[str, Any]) -> Schema: + """Format the schema to be compatible with Gemini API.""" + if subschemas := schema.get("allOf"): + for subschema in subschemas: # Gemini API does not support allOf keys + if "type" in subschema: # Fallback to first subschema with 'type' field + return _format_schema(subschema) + return _format_schema( + subschemas[0] + ) # Or, if not found, to any of the subschemas + + result = {} + for key, val in schema.items(): + key = _camel_to_snake(key) + if key not in SUPPORTED_SCHEMA_KEYS: + continue + if key == "type": + val = val.upper() + elif key == "format": + # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema + # formats that are not supported are ignored + if schema.get("type") == "string" and val not in ("enum", "date-time"): + continue + if schema.get("type") == "number" and val not in ("float", "double"): + continue + if schema.get("type") == "integer" and val not in ("int32", "int64"): + continue + if schema.get("type") not in ("string", "number", "integer"): + continue + elif key == "items": + val = _format_schema(val) + elif key == "properties": + val = {k: _format_schema(v) for k, v in val.items()} + result[key] = val + + if result.get("enum") and result.get("type") != "STRING": + # enum is only allowed for STRING type. This is safe as long as the schema + # contains vol.Coerce for the respective type, for example: + # vol.All(vol.Coerce(int), vol.In([1, 2, 3])) + result["type"] = "STRING" + result["enum"] = [str(item) for item in result["enum"]] + + if result.get("type") == "OBJECT" and not result.get("properties"): + # An object with undefined properties is not supported by Gemini API. + # Fallback to JSON string. This will probably fail for most tools that want it, + # but we don't have a better fallback strategy so far. + result["properties"] = {"json": {"type": "STRING"}} + result["required"] = [] + return cast(Schema, result) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> Tool: + """Format tool specification.""" + + if tool.parameters.schema: + parameters = _format_schema( + convert(tool.parameters, custom_serializer=custom_serializer) + ) + else: + parameters = None + + return Tool( + function_declarations=[ + FunctionDeclaration( + name=tool.name, + description=tool.description, + parameters=parameters, + ) + ] + ) + + +def _escape_decode(value: Any) -> Any: + """Recursively call codecs.escape_decode on all values.""" + if isinstance(value, str): + return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] + if isinstance(value, list): + return [_escape_decode(item) for item in value] + if isinstance(value, dict): + return {k: _escape_decode(v) for k, v in value.items()} + return value + + +def _create_google_tool_response_parts( + parts: list[conversation.ToolResultContent], +) -> list[Part]: + """Create Google tool response parts.""" + return [ + Part.from_function_response( + name=tool_result.tool_name, response=tool_result.tool_result + ) + for tool_result in parts + ] + + +def _create_google_tool_response_content( + content: list[conversation.ToolResultContent], +) -> Content: + """Create a Google tool response content.""" + return Content( + role="user", + parts=_create_google_tool_response_parts(content), + ) + + +def _convert_content( + content: ( + conversation.UserContent + | conversation.AssistantContent + | conversation.SystemContent + ), +) -> Content: + """Convert HA content to Google content.""" + if content.role != "assistant" or not content.tool_calls: + role = "model" if content.role == "assistant" else content.role + return Content( + role=role, + parts=[ + Part.from_text(text=content.content if content.content else ""), + ], + ) + + # Handle the Assistant content with tool calls. + assert type(content) is conversation.AssistantContent + parts: list[Part] = [] + + if content.content: + parts.append(Part.from_text(text=content.content)) + + if content.tool_calls: + parts.extend( + [ + Part.from_function_call( + name=tool_call.tool_name, + args=_escape_decode(tool_call.tool_args), + ) + for tool_call in content.tool_calls + ] + ) + + return Content(role="model", parts=parts) + + +async def _transform_stream( + result: AsyncGenerator[GenerateContentResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + new_message = True + try: + async for response in result: + LOGGER.debug("Received response chunk: %s", response) + chunk: conversation.AssistantContentDeltaDict = {} + + if new_message: + chunk["role"] = "assistant" + new_message = False + + # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. + if response.prompt_feedback or not response.candidates: + reason = ( + response.prompt_feedback.block_reason_message + if response.prompt_feedback + else "unknown" + ) + raise HomeAssistantError( + f"The message got blocked due to content violations, reason: {reason}" + ) + + candidate = response.candidates[0] + + if ( + candidate.finish_reason is not None + and candidate.finish_reason != "STOP" + ): + # The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason + LOGGER.error( + "Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason", + candidate.finish_reason, + ) + raise HomeAssistantError( + f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}" + ) + + response_parts = ( + candidate.content.parts + if candidate.content is not None and candidate.content.parts is not None + else [] + ) + + content = "".join([part.text for part in response_parts if part.text]) + tool_calls = [] + for part in response_parts: + if not part.function_call: + continue + tool_call = part.function_call + tool_name = tool_call.name if tool_call.name else "" + tool_args = _escape_decode(tool_call.args) + tool_calls.append( + llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + ) + + if tool_calls: + chunk["tool_calls"] = tool_calls + + chunk["content"] = content + yield chunk + except ( + APIError, + ValueError, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + if isinstance(err, APIError): + message = err.message + else: + message = type(err).__name__ + error = f"{ERROR_GETTING_RESPONSE}: {message}" + raise HomeAssistantError(error) from err + + +class GoogleGenerativeAILLMBaseEntity(Entity): + """Google Generative AI base entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the agent.""" + self.entry = entry + self._genai_client = entry.runtime_data + self._attr_unique_id = entry.entry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.entry.options + + tools: list[Tool | Callable[..., Any]] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + # Using search grounding allows the model to retrieve information from the web, + # however, it may interfere with how the model decides to use some tools, or entities + # for example weather entity may be disregarded if the model chooses to Google it. + if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: + tools = tools or [] + tools.append(Tool(google_search=GoogleSearch())) + + model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Avoid INVALID_ARGUMENT Developer instruction is not enabled for + supports_system_instruction = ( + "gemma" not in model_name + and "gemini-2.0-flash-preview-image-generation" not in model_name + ) + + prompt_content = cast( + conversation.SystemContent, + chat_log.content[0], + ) + + if prompt_content.content: + prompt = prompt_content.content + else: + raise HomeAssistantError("Invalid prompt content") + + messages: list[Content] = [] + + # Google groups tool results, we do not. Group them before sending. + tool_results: list[conversation.ToolResultContent] = [] + + for chat_content in chat_log.content[1:-1]: + if chat_content.role == "tool_result": + tool_results.append(chat_content) + continue + + if ( + not isinstance(chat_content, conversation.ToolResultContent) + and chat_content.content == "" + ): + # Skipping is not possible since the number of function calls need to match the number of function responses + # and skipping one would mean removing the other and hence this would prevent a proper chat log + chat_content = replace(chat_content, content=" ") + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + tool_results.clear() + + messages.append(_convert_content(chat_content)) + + # The SDK requires the first message to be a user message + # This is not the case if user used `start_conversation` + # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 + if messages and messages[0].role != "user": + messages.insert( + 0, + Content(role="user", parts=[Part.from_text(text=" ")]), + ) + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + generateContentConfig = GenerateContentConfig( + temperature=self.entry.options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), + top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + max_output_tokens=self.entry.options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + safety_settings=[ + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=self.entry.options.get( + CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=self.entry.options.get( + CONF_HARASSMENT_BLOCK_THRESHOLD, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=self.entry.options.get( + CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=self.entry.options.get( + CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + ], + tools=tools or None, + system_instruction=prompt if supports_system_instruction else None, + automatic_function_calling=AutomaticFunctionCallingConfig( + disable=True, maximum_remote_calls=None + ), + ) + + if not supports_system_instruction: + messages = [ + Content(role="user", parts=[Part.from_text(text=prompt)]), + Content(role="model", parts=[Part.from_text(text="Ok")]), + *messages, + ] + chat = self._genai_client.aio.chats.create( + model=model_name, history=messages, config=generateContentConfig + ) + user_message = chat_log.content[-1] + assert isinstance(user_message, conversation.UserContent) + chat_request: str | list[Part] = user_message.content + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + chat_response_generator = await chat.send_message_stream( + message=chat_request + ) + except ( + APIError, + ClientError, + ValueError, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + error = ERROR_GETTING_RESPONSE + raise HomeAssistantError(error) from err + + chat_request = _create_google_tool_response_parts( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_response_generator), + ) + if isinstance(content, conversation.ToolResultContent) + ] + ) + + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py new file mode 100644 index 00000000000..160048e4897 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -0,0 +1,216 @@ +"""Text to speech support for Google Generative AI.""" + +from __future__ import annotations + +from contextlib import suppress +import io +import logging +from typing import Any +import wave + +from google.genai import types + +from homeassistant.components.tts import ( + ATTR_VOICE, + TextToSpeechEntity, + TtsAudioType, + Voice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up TTS entity.""" + tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry) + async_add_entities([tts_entity]) + + +class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): + """Google Generative AI text-to-speech entity.""" + + _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] + # See https://ai.google.dev/gemini-api/docs/speech-generation#languages + _attr_supported_languages = [ + "ar-EG", + "bn-BD", + "de-DE", + "en-IN", + "en-US", + "es-US", + "fr-FR", + "hi-IN", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "mr-IN", + "nl-NL", + "pl-PL", + "pt-BR", + "ro-RO", + "ru-RU", + "ta-IN", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "vi-VN", + ] + _attr_default_language = "en-US" + # See https://ai.google.dev/gemini-api/docs/speech-generation#voices + _supported_voices = [ + Voice(voice.split(" ", 1)[0].lower(), voice) + for voice in ( + "Zephyr (Bright)", + "Puck (Upbeat)", + "Charon (Informative)", + "Kore (Firm)", + "Fenrir (Excitable)", + "Leda (Youthful)", + "Orus (Firm)", + "Aoede (Breezy)", + "Callirrhoe (Easy-going)", + "Autonoe (Bright)", + "Enceladus (Breathy)", + "Iapetus (Clear)", + "Umbriel (Easy-going)", + "Algieba (Smooth)", + "Despina (Smooth)", + "Erinome (Clear)", + "Algenib (Gravelly)", + "Rasalgethi (Informative)", + "Laomedeia (Upbeat)", + "Achernar (Soft)", + "Alnilam (Firm)", + "Schedar (Even)", + "Gacrux (Mature)", + "Pulcherrima (Forward)", + "Achird (Friendly)", + "Zubenelgenubi (Casual)", + "Vindemiatrix (Gentle)", + "Sadachbia (Lively)", + "Sadaltager (Knowledgeable)", + "Sulafat (Warm)", + ) + ] + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize Google Generative AI Conversation speech entity.""" + self.entry = entry + self._attr_name = "Google Generative AI TTS" + self._attr_unique_id = f"{entry.entry_id}_tts" + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + self._genai_client = entry.runtime_data + self._default_voice_id = self._supported_voices[0].voice_id + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return self._supported_voices + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + try: + response = self._genai_client.models.generate_content( + model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL), + contents=message, + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=options.get( + ATTR_VOICE, self._default_voice_id + ) + ) + ) + ), + ), + ) + + data = response.candidates[0].content.parts[0].inline_data.data + mime_type = response.candidates[0].content.parts[0].inline_data.mime_type + except Exception as exc: + _LOGGER.warning( + "Error during processing of TTS request %s", exc, exc_info=True + ) + raise HomeAssistantError(exc) from exc + return "wav", self._convert_to_wav(data, mime_type) + + def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes: + """Generate a WAV file header for the given audio data and parameters. + + Args: + audio_data: The raw audio data as a bytes object. + mime_type: Mime type of the audio data. + + Returns: + A bytes object representing the WAV file header. + + """ + parameters = self._parse_audio_mime_type(mime_type) + + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(parameters["bits_per_sample"] // 8) + wf.setframerate(parameters["rate"]) + wf.writeframes(audio_data) + + return wav_buffer.getvalue() + + def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]: + """Parse bits per sample and rate from an audio MIME type string. + + Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". + + Args: + mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). + + Returns: + A dictionary with "bits_per_sample" and "rate" keys. Values will be + integers if found, otherwise None. + + """ + if not mime_type.startswith("audio/L"): + _LOGGER.warning("Received unexpected MIME type %s", mime_type) + raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") + + bits_per_sample = 16 + rate = 24000 + + # Extract rate from parameters + parts = mime_type.split(";") + for param in parts: # Skip the main type part + param = param.strip() + if param.lower().startswith("rate="): + # Handle cases like "rate=" with no value or non-integer value and keep rate as default + with suppress(ValueError, IndexError): + rate_str = param.split("=", 1)[1] + rate = int(rate_str) + elif param.startswith("audio/L"): + # Keep bits_per_sample as default if conversion fails + with suppress(ValueError, IndexError): + bits_per_sample = int(param.split("L", 1)[1]) + + return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 4b530eef605..d1294564438 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -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 - await async_setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/google_mail/services.py b/homeassistant/components/google_mail/services.py index 2a81f7e6c51..129e04590d9 100644 --- a/homeassistant/components/google_mail/services.py +++ b/homeassistant/components/google_mail/services.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from googleapiclient.http import HttpRequest import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_config_entry_ids @@ -46,56 +46,57 @@ SERVICE_VACATION_SCHEMA = vol.All( ) -async def async_setup_services(hass: HomeAssistant) -> None: +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: """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, ) diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 759242593ff..c856b0d3329 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index ab4fb86af5a..c30259416e5 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -77,85 +78,85 @@ def _read_file_contents( return results +async def _async_handle_upload(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + config_entry: GooglePhotosConfigEntry | None = ( + call.hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + scopes = config_entry.data["token"]["scope"].split(" ") + if UPLOAD_SCOPE not in scopes: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="missing_upload_permission", + translation_placeholders={"target": DOMAIN}, + ) + coordinator = config_entry.runtime_data + client_api = coordinator.client + upload_tasks = [] + file_results = await call.hass.async_add_executor_job( + _read_file_contents, call.hass, call.data[CONF_FILENAME] + ) + + album = call.data[CONF_ALBUM] + try: + album_id = await coordinator.get_or_create_album(album) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="create_album_error", + translation_placeholders={"message": str(err)}, + ) from err + + for mime_type, content in file_results: + upload_tasks.append(client_api.upload_content(content, mime_type)) + try: + upload_results = await asyncio.gather(*upload_tasks) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"message": str(err)}, + ) from err + try: + upload_result = await client_api.create_media_items( + [ + NewMediaItem(SimpleMediaItem(upload_token=upload_result.upload_token)) + for upload_result in upload_results + ], + album_id=album_id, + ) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": str(err)}, + ) from err + if call.return_response: + return { + "media_items": [ + {"media_item_id": item_result.media_item.id} + for item_result in upload_result.new_media_item_results + if item_result.media_item and item_result.media_item.id + ], + "album_id": album_id, + } + return None + + +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register Google Photos services.""" - async def async_handle_upload(call: ServiceCall) -> ServiceResponse: - """Generate content from text and optionally images.""" - config_entry: GooglePhotosConfigEntry | None = ( - hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) - ) - if not config_entry: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="integration_not_found", - translation_placeholders={"target": DOMAIN}, - ) - scopes = config_entry.data["token"]["scope"].split(" ") - if UPLOAD_SCOPE not in scopes: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="missing_upload_permission", - translation_placeholders={"target": DOMAIN}, - ) - coordinator = config_entry.runtime_data - client_api = coordinator.client - upload_tasks = [] - file_results = await hass.async_add_executor_job( - _read_file_contents, hass, call.data[CONF_FILENAME] - ) - - album = call.data[CONF_ALBUM] - try: - album_id = await coordinator.get_or_create_album(album) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="create_album_error", - translation_placeholders={"message": str(err)}, - ) from err - - for mime_type, content in file_results: - upload_tasks.append(client_api.upload_content(content, mime_type)) - try: - upload_results = await asyncio.gather(*upload_tasks) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="upload_error", - translation_placeholders={"message": str(err)}, - ) from err - try: - upload_result = await client_api.create_media_items( - [ - NewMediaItem( - SimpleMediaItem(upload_token=upload_result.upload_token) - ) - for upload_result in upload_results - ], - album_id=album_id, - ) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="api_error", - translation_placeholders={"message": str(err)}, - ) from err - if call.return_response: - return { - "media_items": [ - {"media_item_id": item_result.media_item.id} - for item_result in upload_result.new_media_item_results - if item_result.media_item and item_result.media_item.id - ], - "album_id": album_id, - } - return None - hass.services.async_register( DOMAIN, UPLOAD_SERVICE, - async_handle_upload, + _async_handle_upload, schema=UPLOAD_SERVICE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 5695192dd27..503f27d8125 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_sheets/services.py b/homeassistant/components/google_sheets/services.py index ea0c1e5a4ed..6425aec4eb0 100644 --- a/homeassistant/components/google_sheets/services.py +++ b/homeassistant/components/google_sheets/services.py @@ -13,7 +13,7 @@ from gspread.utils import ValueInputOption import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ConfigEntrySelector @@ -76,6 +76,7 @@ async def _async_append_to_sheet(call: ServiceCall) -> None: await call.hass.async_add_executor_job(_append_to_sheet, call, entry) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Add the services for Google Sheets.""" diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index 406c4440d00..9a5ed48767d 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index b58678f6d30..3a7ef8a1ec8 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 8ef12a38f1c..38833f26932 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -35,6 +35,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -249,55 +250,203 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: return entry -def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 - """Set up services for Habitica integration.""" +async def _cast_skill(call: ServiceCall) -> ServiceResponse: + """Skill action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data - async def cast_skill(call: ServiceCall) -> ServiceResponse: - """Skill action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data + skill = SKILL_MAP[call.data[ATTR_SKILL]] + cost = COST_MAP[call.data[ATTR_SKILL]] - skill = SKILL_MAP[call.data[ATTR_SKILL]] - cost = COST_MAP[call.data[ATTR_SKILL]] + try: + task_id = next( + task.id + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e - try: - task_id = next( - task.id - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - ) - except StopIteration as e: + try: + response = await coordinator.habitica.cast_skill(skill, task_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_enough_mana", + translation_placeholders={ + "cost": cost, + "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP", + }, + ) from e + except NotFoundError as e: + # could also be task not found, but the task is looked up + # before the request, so most likely wrong skill selected + # or the skill hasn't been unlocked yet. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="skill_not_found", + translation_placeholders={"skill": call.data[ATTR_SKILL]}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + await coordinator.async_request_refresh() + return asdict(response.data) + + +async def _manage_quests(call: ServiceCall) -> ServiceResponse: + """Accept, reject, start, leave or cancel quests.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + FUNC_MAP = { + SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, + SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, + SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, + SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest, + SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest, + SERVICE_START_QUEST: coordinator.habitica.start_quest, + } + + func = FUNC_MAP[call.service] + + try: + response = await func() + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_action_unallowed" + ) from e + except NotFoundError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_not_found" + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return asdict(response.data) + + +async def _score_task(call: ServiceCall) -> ServiceResponse: + """Score a task action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + direction = ( + Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP + ) + try: + task_id, task_value = next( + (task.id, task.value) + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e + + if TYPE_CHECKING: + assert task_id + try: + response = await coordinator.habitica.update_score(task_id, direction) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + if task_value is not None: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e - - try: - response = await coordinator.habitica.cast_skill(skill, task_id) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="not_enough_mana", + translation_key="not_enough_gold", translation_placeholders={ - "cost": cost, - "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP", + "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP", + "cost": f"{task_value:.2f} GP", }, ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + await coordinator.async_request_refresh() + return asdict(response.data) + + +async def _transformation(call: ServiceCall) -> ServiceResponse: + """User a transformation item on a player character.""" + + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + item = ITEMID_MAP[call.data[ATTR_ITEM]] + # check if target is self + if call.data[ATTR_TARGET] in ( + str(coordinator.data.user.id), + coordinator.data.user.profile.name, + coordinator.data.user.auth.local.username, + ): + target_id = coordinator.data.user.id + else: + # check if target is a party member + try: + party = await coordinator.habitica.get_group_members(public_fields=True) except NotFoundError as e: - # could also be task not found, but the task is looked up - # before the request, so most likely wrong skill selected - # or the skill hasn't been unlocked yet. raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="skill_not_found", - translation_placeholders={"skill": call.data[ATTR_SKILL]}, + translation_key="party_not_found", ) from e except HabiticaException as e: raise HomeAssistantError( @@ -311,86 +460,125 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_key="service_call_exception", translation_placeholders={"reason": str(e)}, ) from e - else: - await coordinator.async_request_refresh() - return asdict(response.data) + try: + target_id = next( + member.id + for member in party.data + if member.id + and call.data[ATTR_TARGET].lower() + in ( + str(member.id), + str(member.auth.local.username).lower(), + str(member.profile.name).lower(), + ) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="target_not_found", + translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, + ) from e + try: + response = await coordinator.habitica.cast_skill(item, target_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": call.data[ATTR_ITEM]}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return asdict(response.data) - async def manage_quests(call: ServiceCall) -> ServiceResponse: - """Accept, reject, start, leave or cancel quests.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - FUNC_MAP = { - SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, - SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, - SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, - SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest, - SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest, - SERVICE_START_QUEST: coordinator.habitica.start_quest, +async def _get_tasks(call: ServiceCall) -> ServiceResponse: + """Get tasks action.""" + + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + response: list[TaskData] = coordinator.data.tasks + + if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}: + response = [task for task in response if task.Type in types] + + if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}: + response = [task for task in response if task.priority in priority] + + if tasks := call.data.get(ATTR_TASK): + response = [ + task + for task in response + if str(task.id) in tasks or task.alias in tasks or task.text in tasks + ] + + if tags := call.data.get(ATTR_TAG): + tag_ids = { + tag.id + for tag in coordinator.data.user.tags + if (tag.name and tag.name.lower()) + in (tag.lower() for tag in tags) # Case-insensitive matching + and tag.id } - func = FUNC_MAP[call.service] + response = [ + task + for task in response + if any(tag_id in task.tags for tag_id in tag_ids if task.tags) + ] + if keyword := call.data.get(ATTR_KEYWORD): + keyword = keyword.lower() + response = [ + task + for task in response + if (task.text and keyword in task.text.lower()) + or (task.notes and keyword in task.notes.lower()) + or any(keyword in item.text.lower() for item in task.checklist) + ] + result: dict[str, Any] = { + "tasks": [task.to_dict(omit_none=False) for task in response] + } + return result + + +async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 + """Create or update task action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + await coordinator.async_refresh() + is_update = call.service in ( + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + SERVICE_UPDATE_DAILY, + ) + task_type = SERVICE_TASK_TYPE_MAP[call.service] + current_task = None + + if is_update: try: - response = await func() - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_action_unallowed" - ) from e - except NotFoundError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_not_found" - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return asdict(response.data) - - for service in ( - SERVICE_ABORT_QUEST, - SERVICE_ACCEPT_QUEST, - SERVICE_CANCEL_QUEST, - SERVICE_LEAVE_QUEST, - SERVICE_REJECT_QUEST, - SERVICE_START_QUEST, - ): - hass.services.async_register( - DOMAIN, - service, - manage_quests, - schema=SERVICE_MANAGE_QUEST_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - async def score_task(call: ServiceCall) -> ServiceResponse: - """Score a task action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - - direction = ( - Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP - ) - try: - task_id, task_value = next( - (task.id, task.value) + current_task = next( + task for task in coordinator.data.tasks if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is task_type ) except StopIteration as e: raise ServiceValidationError( @@ -399,69 +587,48 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, ) from e - if TYPE_CHECKING: - assert task_id - try: - response = await coordinator.habitica.update_score(task_id, direction) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - if task_value is not None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="not_enough_gold", - translation_placeholders={ - "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP", - "cost": f"{task_value:.2f} GP", - }, - ) from e - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": e.error.message}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - await coordinator.async_request_refresh() - return asdict(response.data) + data = Task() - async def transformation(call: ServiceCall) -> ServiceResponse: - """User a transformation item on a player character.""" + if not is_update: + data["type"] = task_type - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data + if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): + data["text"] = text + + if (notes := call.data.get(ATTR_NOTES)) is not None: + data["notes"] = notes + + tags = cast(list[str], call.data.get(ATTR_TAG)) + remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) + + if tags or remove_tags: + update_tags = set(current_task.tags) if current_task else set() + user_tags = { + tag.name.lower(): tag.id + for tag in coordinator.data.user.tags + if tag.id and tag.name + } + + if tags: + # Creates new tag if it doesn't exist + async def create_tag(tag_name: str) -> UUID: + tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id + if TYPE_CHECKING: + assert tag_id + return tag_id - item = ITEMID_MAP[call.data[ATTR_ITEM]] - # check if target is self - if call.data[ATTR_TARGET] in ( - str(coordinator.data.user.id), - coordinator.data.user.profile.name, - coordinator.data.user.auth.local.username, - ): - target_id = coordinator.data.user.id - else: - # check if target is a party member try: - party = await coordinator.habitica.get_group_members(public_fields=True) - except NotFoundError as e: - raise ServiceValidationError( + update_tags.update( + { + user_tags.get(tag_name.lower()) or (await create_tag(tag_name)) + for tag_name in tags + } + ) + except TooManyRequestsError as e: + raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="party_not_found", + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except HabiticaException as e: raise HomeAssistantError( @@ -475,378 +642,218 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_key="service_call_exception", translation_placeholders={"reason": str(e)}, ) from e - try: - target_id = next( - member.id - for member in party.data - if member.id - and call.data[ATTR_TARGET].lower() - in ( - str(member.id), - str(member.auth.local.username).lower(), - str(member.profile.name).lower(), - ) + + if remove_tags: + update_tags.difference_update( + { + user_tags[tag_name.lower()] + for tag_name in remove_tags + if tag_name.lower() in user_tags + } + ) + + data["tags"] = list(update_tags) + + if (alias := call.data.get(ATTR_ALIAS)) is not None: + data["alias"] = alias + + if (cost := call.data.get(ATTR_COST)) is not None: + data["value"] = cost + + if priority := call.data.get(ATTR_PRIORITY): + data["priority"] = TaskPriority[priority] + + if frequency := call.data.get(ATTR_FREQUENCY): + data["frequency"] = frequency + else: + frequency = current_task.frequency if current_task else Frequency.WEEKLY + + if up_down := call.data.get(ATTR_UP_DOWN): + data["up"] = "up" in up_down + data["down"] = "down" in up_down + + if counter_up := call.data.get(ATTR_COUNTER_UP): + data["counterUp"] = counter_up + + if counter_down := call.data.get(ATTR_COUNTER_DOWN): + data["counterDown"] = counter_down + + if due_date := call.data.get(ATTR_DATE): + data["date"] = datetime.combine(due_date, time()) + + if call.data.get(ATTR_CLEAR_DATE): + data["date"] = None + + checklist = current_task.checklist if current_task else [] + + if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): + checklist.extend( + Checklist(completed=False, id=uuid4(), text=item) + for item in add_checklist_item + if not any(i.text == item for i in checklist) + ) + if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): + checklist = [ + item for item in checklist if item.text not in remove_checklist_item + ] + + if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in score_checklist_item: + item.completed = True + + if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in unscore_checklist_item: + item.completed = False + if ( + add_checklist_item + or remove_checklist_item + or score_checklist_item + or unscore_checklist_item + ): + data["checklist"] = checklist + + reminders = current_task.reminders if current_task else [] + + if add_reminders := call.data.get(ATTR_REMINDER): + if task_type is TaskType.TODO: + existing_reminder_datetimes = { + r.time.replace(tzinfo=None) for r in reminders + } + + reminders.extend( + Reminders(id=uuid4(), time=r) + for r in add_reminders + if r not in existing_reminder_datetimes + ) + if task_type is TaskType.DAILY: + existing_reminder_times = { + r.time.time().replace(microsecond=0, second=0) for r in reminders + } + + reminders.extend( + Reminders( + id=uuid4(), + time=datetime.combine(date.today(), r, tzinfo=UTC), ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="target_not_found", - translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, - ) from e - try: - response = await coordinator.habitica.cast_skill(item, target_id) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: + for r in add_reminders + if r not in existing_reminder_times + ) + + if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): + if task_type is TaskType.TODO: + reminders = list( + filter( + lambda r: r.time.replace(tzinfo=None) not in remove_reminder, + reminders, + ) + ) + if task_type is TaskType.DAILY: + reminders = list( + filter( + lambda r: r.time.time().replace(second=0, microsecond=0) + not in remove_reminder, + reminders, + ) + ) + + if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): + reminders = [] + + if add_reminders or remove_reminder or clear_reminders: + data["reminders"] = reminders + + if start_date := call.data.get(ATTR_START_DATE): + data["startDate"] = datetime.combine(start_date, time()) + else: + start_date = ( + current_task.startDate + if current_task and current_task.startDate + else dt_util.start_of_local_day() + ) + if repeat := call.data.get(ATTR_REPEAT): + if frequency is Frequency.WEEKLY: + data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) + else: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="item_not_found", - translation_placeholders={"item": call.data[ATTR_ITEM]}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( + translation_key="frequency_not_weekly", + ) + if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): + if frequency is not Frequency.MONTHLY: + raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e + translation_key="frequency_not_monthly", + ) + + if repeat_monthly == "day_of_week": + weekday = start_date.weekday() + data["weeksOfMonth"] = [(start_date.day - 1) // 7] + data["repeat"] = Repeat( + **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} + ) + data["daysOfMonth"] = [] + else: - return asdict(response.data) + data["daysOfMonth"] = [start_date.day] + data["weeksOfMonth"] = [] - async def get_tasks(call: ServiceCall) -> ServiceResponse: - """Get tasks action.""" + if interval := call.data.get(ATTR_INTERVAL): + data["everyX"] = interval - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - response: list[TaskData] = coordinator.data.tasks - - if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}: - response = [task for task in response if task.Type in types] - - if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}: - response = [task for task in response if task.priority in priority] - - if tasks := call.data.get(ATTR_TASK): - response = [ - task - for task in response - if str(task.id) in tasks or task.alias in tasks or task.text in tasks - ] - - if tags := call.data.get(ATTR_TAG): - tag_ids = { - tag.id - for tag in coordinator.data.user.tags - if (tag.name and tag.name.lower()) - in (tag.lower() for tag in tags) # Case-insensitive matching - and tag.id - } - - response = [ - task - for task in response - if any(tag_id in task.tags for tag_id in tag_ids if task.tags) - ] - if keyword := call.data.get(ATTR_KEYWORD): - keyword = keyword.lower() - response = [ - task - for task in response - if (task.text and keyword in task.text.lower()) - or (task.notes and keyword in task.notes.lower()) - or any(keyword in item.text.lower() for item in task.checklist) - ] - result: dict[str, Any] = { - "tasks": [task.to_dict(omit_none=False) for task in response] - } - - return result - - async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 - """Create or update task action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - await coordinator.async_refresh() - is_update = call.service in ( - SERVICE_UPDATE_HABIT, - SERVICE_UPDATE_REWARD, - SERVICE_UPDATE_TODO, - SERVICE_UPDATE_DAILY, - ) - task_type = SERVICE_TASK_TYPE_MAP[call.service] - current_task = None + if streak := call.data.get(ATTR_STREAK): + data["streak"] = streak + try: if is_update: - try: - current_task = next( - task - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is task_type - ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e - - data = Task() - - if not is_update: - data["type"] = task_type - - if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): - data["text"] = text - - if (notes := call.data.get(ATTR_NOTES)) is not None: - data["notes"] = notes - - tags = cast(list[str], call.data.get(ATTR_TAG)) - remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) - - if tags or remove_tags: - update_tags = set(current_task.tags) if current_task else set() - user_tags = { - tag.name.lower(): tag.id - for tag in coordinator.data.user.tags - if tag.id and tag.name - } - - if tags: - # Creates new tag if it doesn't exist - async def create_tag(tag_name: str) -> UUID: - tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id - if TYPE_CHECKING: - assert tag_id - return tag_id - - try: - update_tags.update( - { - user_tags.get(tag_name.lower()) - or (await create_tag(tag_name)) - for tag_name in tags - } - ) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - - if remove_tags: - update_tags.difference_update( - { - user_tags[tag_name.lower()] - for tag_name in remove_tags - if tag_name.lower() in user_tags - } - ) - - data["tags"] = list(update_tags) - - if (alias := call.data.get(ATTR_ALIAS)) is not None: - data["alias"] = alias - - if (cost := call.data.get(ATTR_COST)) is not None: - data["value"] = cost - - if priority := call.data.get(ATTR_PRIORITY): - data["priority"] = TaskPriority[priority] - - if frequency := call.data.get(ATTR_FREQUENCY): - data["frequency"] = frequency + if TYPE_CHECKING: + assert current_task + assert current_task.id + response = await coordinator.habitica.update_task(current_task.id, data) else: - frequency = current_task.frequency if current_task else Frequency.WEEKLY + response = await coordinator.habitica.create_task(data) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return response.data.to_dict(omit_none=True) - if up_down := call.data.get(ATTR_UP_DOWN): - data["up"] = "up" in up_down - data["down"] = "down" in up_down - if counter_up := call.data.get(ATTR_COUNTER_UP): - data["counterUp"] = counter_up +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Habitica integration.""" - if counter_down := call.data.get(ATTR_COUNTER_DOWN): - data["counterDown"] = counter_down - - if due_date := call.data.get(ATTR_DATE): - data["date"] = datetime.combine(due_date, time()) - - if call.data.get(ATTR_CLEAR_DATE): - data["date"] = None - - checklist = current_task.checklist if current_task else [] - - if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): - checklist.extend( - Checklist(completed=False, id=uuid4(), text=item) - for item in add_checklist_item - if not any(i.text == item for i in checklist) - ) - if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): - checklist = [ - item for item in checklist if item.text not in remove_checklist_item - ] - - if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): - for item in checklist: - if item.text in score_checklist_item: - item.completed = True - - if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): - for item in checklist: - if item.text in unscore_checklist_item: - item.completed = False - if ( - add_checklist_item - or remove_checklist_item - or score_checklist_item - or unscore_checklist_item - ): - data["checklist"] = checklist - - reminders = current_task.reminders if current_task else [] - - if add_reminders := call.data.get(ATTR_REMINDER): - if task_type is TaskType.TODO: - existing_reminder_datetimes = { - r.time.replace(tzinfo=None) for r in reminders - } - - reminders.extend( - Reminders(id=uuid4(), time=r) - for r in add_reminders - if r not in existing_reminder_datetimes - ) - if task_type is TaskType.DAILY: - existing_reminder_times = { - r.time.time().replace(microsecond=0, second=0) for r in reminders - } - - reminders.extend( - Reminders( - id=uuid4(), - time=datetime.combine(date.today(), r, tzinfo=UTC), - ) - for r in add_reminders - if r not in existing_reminder_times - ) - - if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): - if task_type is TaskType.TODO: - reminders = list( - filter( - lambda r: r.time.replace(tzinfo=None) not in remove_reminder, - reminders, - ) - ) - if task_type is TaskType.DAILY: - reminders = list( - filter( - lambda r: r.time.time().replace(second=0, microsecond=0) - not in remove_reminder, - reminders, - ) - ) - - if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): - reminders = [] - - if add_reminders or remove_reminder or clear_reminders: - data["reminders"] = reminders - - if start_date := call.data.get(ATTR_START_DATE): - data["startDate"] = datetime.combine(start_date, time()) - else: - start_date = ( - current_task.startDate - if current_task and current_task.startDate - else dt_util.start_of_local_day() - ) - if repeat := call.data.get(ATTR_REPEAT): - if frequency is Frequency.WEEKLY: - data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) - else: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="frequency_not_weekly", - ) - if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): - if frequency is not Frequency.MONTHLY: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="frequency_not_monthly", - ) - - if repeat_monthly == "day_of_week": - weekday = start_date.weekday() - data["weeksOfMonth"] = [(start_date.day - 1) // 7] - data["repeat"] = Repeat( - **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} - ) - data["daysOfMonth"] = [] - - else: - data["daysOfMonth"] = [start_date.day] - data["weeksOfMonth"] = [] - - if interval := call.data.get(ATTR_INTERVAL): - data["everyX"] = interval - - if streak := call.data.get(ATTR_STREAK): - data["streak"] = streak - - try: - if is_update: - if TYPE_CHECKING: - assert current_task - assert current_task.id - response = await coordinator.habitica.update_task(current_task.id, data) - else: - response = await coordinator.habitica.create_task(data) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return response.data.to_dict(omit_none=True) + for service in ( + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, + ): + hass.services.async_register( + DOMAIN, + service, + _manage_quests, + schema=SERVICE_MANAGE_QUEST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) for service in ( SERVICE_UPDATE_DAILY, @@ -857,7 +864,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, service, - create_or_update_task, + _create_or_update_task, schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -870,7 +877,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, service, - create_or_update_task, + _create_or_update_task, schema=SERVICE_CREATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -878,7 +885,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_CAST_SKILL, - cast_skill, + _cast_skill, schema=SERVICE_CAST_SKILL_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -886,14 +893,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_SCORE_HABIT, - score_task, + _score_task, schema=SERVICE_SCORE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_SCORE_REWARD, - score_task, + _score_task, schema=SERVICE_SCORE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -901,14 +908,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_TRANSFORMATION, - transformation, + _transformation, schema=SERVICE_TRANSFORMATION_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_GET_TASKS, - get_tasks, + _get_tasks, schema=SERVICE_GET_TASKS_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 041877e3944..6772034e53f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -9,8 +9,10 @@ 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 @@ -56,7 +58,6 @@ 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 @@ -233,6 +234,17 @@ 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.""" @@ -554,7 +566,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = coordinator - system_info = await async_get_system_info(hass) + arch = await _get_arch() def deprecated_setup_issue() -> None: os_info = get_os_info(hass) @@ -562,20 +574,19 @@ 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") - supported_board = board in {"rpi3", "rpi4", "tinker", "odroid-xu4", "rpi2"} - if is_haos and arch == "armv7" and supported_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): issue_id = "deprecated_os_" - if board in {"rpi3", "rpi4"}: + if unsupported_os_on_board: issue_id += "aarch64" - elif board in {"tinker", "odroid-xu4", "rpi2"}: + elif unsupported_board: 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, @@ -584,9 +595,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "installation_guide": "https://www.home-assistant.io/installation/", }, ) - deprecated_architecture = False - if arch in {"i386", "armhf"} or (arch == "armv7" and not supported_board): - deprecated_architecture = True + bit32 = _is_32_bit() + deprecated_architecture = bit32 and not ( + unsupported_board or unsupported_os_on_board + ) if not is_haos or deprecated_architecture: issue_id = "deprecated" if not is_haos: @@ -597,7 +609,6 @@ 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, diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py index 65a3ba38485..f44ee0700fc 100644 --- a/homeassistant/components/hassio/update_helper.py +++ b/homeassistant/components/hassio/update_helper.py @@ -29,8 +29,7 @@ async def update_addon( client = get_supervisor_client(hass) if backup: - # pylint: disable-next=import-outside-toplevel - from .backup import backup_addon_before_update + from .backup import backup_addon_before_update # noqa: PLC0415 await backup_addon_before_update(hass, addon, addon_name, installed_version) @@ -50,8 +49,7 @@ async def update_core(hass: HomeAssistant, version: str | None, backup: bool) -> client = get_supervisor_client(hass) if backup: - # pylint: disable-next=import-outside-toplevel - from .backup import backup_core_before_update + from .backup import backup_core_before_update # noqa: PLC0415 await backup_core_before_update(hass) @@ -71,8 +69,7 @@ async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> N client = get_supervisor_client(hass) if backup: - # pylint: disable-next=import-outside-toplevel - from .backup import backup_core_before_update + from .backup import backup_core_before_update # noqa: PLC0415 await backup_core_before_update(hass) diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 525da15bd74..5393dfa5050 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -5,26 +5,13 @@ 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 ( - 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 .const import TRAVEL_MODE_PUBLIC from .coordinator import ( HereConfigEntry, HERERoutingDataUpdateCoordinator, HERETransitDataUpdateCoordinator, ) -from .model import HERETravelTimeConfig PLATFORMS = [Platform.SENSOR] @@ -33,29 +20,13 @@ 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, here_travel_time_config) + data_coordinator = cls(hass, config_entry, api_key) config_entry.runtime_data = data_coordinator async def _async_update_at_start(_: HomeAssistant) -> None: diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index aa36404c584..d8c698554c9 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -26,7 +26,7 @@ from here_transit import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfLength +from homeassistant.const import CONF_MODE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates @@ -34,8 +34,21 @@ 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 DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST -from .model import HERETravelTimeConfig, HERETravelTimeData +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 BACKOFF_MULTIPLIER = 1.1 @@ -47,7 +60,7 @@ type HereConfigEntry = ConfigEntry[ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]): - """here_routing DataUpdateCoordinator.""" + """HERETravelTime DataUpdateCoordinator for the routing API.""" config_entry: HereConfigEntry @@ -56,7 +69,6 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] hass: HomeAssistant, config_entry: HereConfigEntry, api_key: str, - config: HERETravelTimeConfig, ) -> None: """Initialize.""" super().__init__( @@ -67,41 +79,36 @@ 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.""" - 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 - ) + params = prepare_parameters(self.hass, self.config_entry) _LOGGER.debug( ( "Requesting route for origin: %s, destination: %s, route_mode: %s," " mode: %s, arrival: %s, departure: %s" ), - origin, - destination, - route_mode, - TransportMode(self.config.travel_mode), - arrival, - departure, + params.origin, + params.destination, + params.route_mode, + TransportMode(params.travel_mode), + params.arrival, + params.departure, ) try: response = await self._api.route( - 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, + transport_mode=TransportMode(params.travel_mode), + origin=here_routing.Place( + float(params.origin[0]), float(params.origin[1]) + ), + destination=here_routing.Place( + float(params.destination[0]), float(params.destination[1]) + ), + routing_mode=params.route_mode, + arrival_time=params.arrival, + departure_time=params.departure, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -128,8 +135,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] def _parse_routing_response(self, response: dict[str, Any]) -> HERETravelTimeData: """Parse the routing response dict to a HERETravelTimeData.""" distance: float = 0.0 - duration: float = 0.0 - duration_in_traffic: float = 0.0 + duration: int = 0 + duration_in_traffic: int = 0 for section in response["routes"][0]["sections"]: distance += DistanceConverter.convert( @@ -162,8 +169,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] destination_name = names[0]["value"] return HERETravelTimeData( attribution=None, - duration=round(duration / 60), - duration_in_traffic=round(duration_in_traffic / 60), + duration=duration, + duration_in_traffic=duration_in_traffic, distance=distance, origin=f"{mapped_origin_lat},{mapped_origin_lon}", destination=f"{mapped_destination_lat},{mapped_destination_lon}", @@ -175,7 +182,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] class HERETransitDataUpdateCoordinator( DataUpdateCoordinator[HERETravelTimeData | None] ): - """HERETravelTime DataUpdateCoordinator.""" + """HERETravelTime DataUpdateCoordinator for the transit API.""" config_entry: HereConfigEntry @@ -184,7 +191,6 @@ class HERETransitDataUpdateCoordinator( hass: HomeAssistant, config_entry: HereConfigEntry, api_key: str, - config: HERETravelTimeConfig, ) -> None: """Initialize.""" super().__init__( @@ -195,32 +201,31 @@ 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.""" - origin, destination, arrival, departure = prepare_parameters( - self.hass, self.config - ) + params = prepare_parameters(self.hass, self.config_entry) _LOGGER.debug( ( "Requesting transit route for origin: %s, destination: %s, arrival: %s," " departure: %s" ), - origin, - destination, - arrival, - departure, + params.origin, + params.destination, + params.arrival, + params.departure, ) try: response = await self._api.route( - origin=here_transit.Place(latitude=origin[0], longitude=origin[1]), - destination=here_transit.Place( - latitude=destination[0], longitude=destination[1] + origin=here_transit.Place( + latitude=params.origin[0], longitude=params.origin[1] ), - arrival_time=arrival, - departure_time=departure, + destination=here_transit.Place( + latitude=params.destination[0], longitude=params.destination[1] + ), + arrival_time=params.arrival, + departure_time=params.departure, return_values=[ here_transit.Return.POLYLINE, here_transit.Return.TRAVEL_SUMMARY, @@ -268,13 +273,13 @@ class HERETransitDataUpdateCoordinator( UnitOfLength.METERS, UnitOfLength.KILOMETERS, ) - duration: float = sum( + duration: int = sum( section["travelSummary"]["duration"] for section in sections ) return HERETravelTimeData( attribution=attribution, - duration=round(duration / 60), - duration_in_traffic=round(duration / 60), + duration=duration, + duration_in_traffic=duration, distance=distance, origin=f"{mapped_origin_lat},{mapped_origin_lon}", destination=f"{mapped_destination_lat},{mapped_destination_lon}", @@ -285,8 +290,8 @@ class HERETransitDataUpdateCoordinator( def prepare_parameters( hass: HomeAssistant, - config: HERETravelTimeConfig, -) -> tuple[list[str], list[str], str | None, str | None]: + config_entry: HereConfigEntry, +) -> HERETravelTimeAPIParams: """Prepare parameters for the HERE api.""" def _from_entity_id(entity_id: str) -> list[str]: @@ -305,32 +310,55 @@ def prepare_parameters( return formatted_coordinates # Destination - if config.destination_entity_id is not None: - destination = _from_entity_id(config.destination_entity_id) + if ( + destination_entity_id := config_entry.data.get(CONF_DESTINATION_ENTITY_ID) + ) is not None: + destination = _from_entity_id(str(destination_entity_id)) else: destination = [ - str(config.destination_latitude), - str(config.destination_longitude), + str(config_entry.data[CONF_DESTINATION_LATITUDE]), + str(config_entry.data[CONF_DESTINATION_LONGITUDE]), ] # Origin - if config.origin_entity_id is not None: - origin = _from_entity_id(config.origin_entity_id) + if (origin_entity_id := config_entry.data.get(CONF_ORIGIN_ENTITY_ID)) is not None: + origin = _from_entity_id(str(origin_entity_id)) else: origin = [ - str(config.origin_latitude), - str(config.origin_longitude), + str(config_entry.data[CONF_ORIGIN_LATITUDE]), + str(config_entry.data[CONF_ORIGIN_LONGITUDE]), ] # Arrival/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() + 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) - return (origin, destination, arrival, departure) + 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, + ) def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None: diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json index 0365cf51d97..9d3b622a877 100644 --- a/homeassistant/components/here_travel_time/manifest.json +++ b/homeassistant/components/here_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/here_travel_time", "iot_class": "cloud_polling", "loggers": ["here_routing", "here_transit", "homeassistant.helpers.location"], - "requirements": ["here-routing==1.0.1", "here-transit==1.2.1"] + "requirements": ["here-routing==1.2.0", "here-transit==1.2.1"] } diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index 178c0d8c805..a0534d2ff01 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -3,9 +3,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import time +from datetime import datetime from typing import TypedDict +from here_routing import RoutingMode + class HERETravelTimeData(TypedDict): """Routing information.""" @@ -21,16 +23,12 @@ class HERETravelTimeData(TypedDict): @dataclass -class HERETravelTimeConfig: - """Configuration for HereTravelTimeDataUpdateCoordinator.""" +class HERETravelTimeAPIParams: + """Configuration for polling the HERE API.""" - 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 + destination: list[str] + origin: list[str] travel_mode: str - route_mode: str - arrival: time | None - departure: time | None + route_mode: RoutingMode + arrival: datetime | None + departure: datetime | None diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index bbaabb56d46..da93c6e301e 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -55,14 +55,18 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( translation_key="duration_in_traffic", icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION_IN_TRAFFIC, state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( translation_key="distance", diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 5a5f1daf967..c76d6638730 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.74", "babel==2.15.0"] + "requirements": ["holidays==0.75", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index d4b37552fb7..8ced21ecba5 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -21,6 +21,7 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.17.1"], + "quality_scale": "platinum", + "requirements": ["aiohomeconnect==0.18.1"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/quality_scale.yaml b/homeassistant/components/home_connect/quality_scale.yaml new file mode 100644 index 00000000000..b89af885f38 --- /dev/null +++ b/homeassistant/components/home_connect/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: done + comment: | + Full polling is performed at the configuration entry setup and + device polling is performed when a CONNECTED or a PAIRED event is received. + If many CONNECTED or PAIRED events are received for a device within a short time span, + the integration will stop polling for that device and will create a repair issue. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: done + comment: | + Event entities are disabled by default to prevent user confusion regarding + which events are supported by its appliance. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + This integration doesn't have settings in its configuration flow. + repair-issues: done + stale-devices: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 1445a8eae08..99c89ec8788 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -9,7 +9,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 1433358b568..4360fa9c16e 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -4,8 +4,10 @@ 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 @@ -94,6 +96,17 @@ 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.""" @@ -403,23 +416,21 @@ 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 arch == "armv7" and installation_type == "Container": + if bit32 and installation_type == "Container": + arch = await _get_arch() ir.async_create_issue( hass, DOMAIN, - "deprecated_container_armv7", - breaks_in_ha_version="2025.12.0", + "deprecated_container", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, - translation_key="deprecated_container_armv7", + translation_key="deprecated_container", + translation_placeholders={"arch": arch}, ) - deprecated_architecture = False - if arch in {"i386", "armhf"} or ( - arch == "armv7" and installation_type != "Container" - ): - deprecated_architecture = True + deprecated_architecture = bit32 and installation_type != "Container" if deprecated_method or deprecated_architecture: issue_id = "deprecated" if deprecated_method: @@ -430,7 +441,6 @@ 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, diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 93b4105c702..940af999c4d 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -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_armv7": { + "deprecated_container": { "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", - "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." + "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." }, "deprecated_os_aarch64": { "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 2b08031405f..294ed83bad1 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -309,8 +309,7 @@ class OptionsFlowHandler(OptionsFlow, ABC): def __init__(self, config_entry: ConfigEntry) -> None: """Set up the options flow.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha.radio_manager import ( + from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, ) @@ -451,16 +450,11 @@ class OptionsFlowHandler(OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Configure the Silicon Labs Multiprotocol add-on.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha.radio_manager import ( + from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415 + from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, ) - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha.silabs_multiprotocol import ( + from homeassistant.components.zha.silabs_multiprotocol import ( # noqa: PLC0415 async_get_channel as async_get_zha_channel, ) @@ -747,11 +741,8 @@ class OptionsFlowHandler(OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Perform initial backup and reconfigure ZHA.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha.radio_manager import ( + from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415 + from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, ) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index e9eb1d86f02..0f90752733d 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo entry.runtime_data = homee entry.async_on_unload(homee.disconnect) - def _connection_update_callback(connected: bool) -> None: + async def _connection_update_callback(connected: bool) -> None: """Call when the device is notified of changes.""" if connected: _LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST]) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 4c85f52bb28..ddb16315e7d 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -28,6 +28,7 @@ class HomeeEntity(Entity): self._entry = entry node = entry.runtime_data.get_node_by_id(attribute.node_id) # Homee hub itself has node-id -1 + assert node is not None if node.id == -1: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.runtime_data.settings.uid)}, @@ -41,6 +42,8 @@ class HomeeEntity(Entity): model=get_name_for_enum(NodeProfile, node.profile), via_device=(DOMAIN, entry.runtime_data.settings.uid), ) + if attribute.name: + self._attr_name = attribute.name self._host_connected = entry.runtime_data.connected @@ -79,7 +82,7 @@ class HomeeEntity(Entity): def _on_node_updated(self, attribute: HomeeAttribute) -> None: self.schedule_update_ha_state() - def _on_connection_changed(self, connected: bool) -> None: + async def _on_connection_changed(self, connected: bool) -> None: self._host_connected = connected self.schedule_update_ha_state() @@ -166,6 +169,6 @@ class HomeeNodeEntity(Entity): def _on_node_updated(self, node: HomeeNode) -> None: self.schedule_update_ha_state() - def _on_connection_changed(self, connected: bool) -> None: + async def _on_connection_changed(self, connected: bool) -> None: self._host_connected = connected self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/event.py b/homeassistant/components/homee/event.py index 047d9e2e122..73c315e8695 100644 --- a/homeassistant/components/homee/event.py +++ b/homeassistant/components/homee/event.py @@ -1,9 +1,13 @@ """The homee event platform.""" -from pyHomee.const import AttributeType +from pyHomee.const import AttributeType, NodeProfile from pyHomee.model import HomeeAttribute -from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -13,6 +17,38 @@ from .entity import HomeeEntity PARALLEL_UPDATES = 0 +REMOTE_PROFILES = [ + NodeProfile.REMOTE, + NodeProfile.TWO_BUTTON_REMOTE, + NodeProfile.THREE_BUTTON_REMOTE, + NodeProfile.FOUR_BUTTON_REMOTE, +] + +EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = { + AttributeType.BUTTON_STATE: EventEntityDescription( + key="button_state", + device_class=EventDeviceClass.BUTTON, + event_types=["upper", "lower", "released"], + ), + AttributeType.UP_DOWN_REMOTE: EventEntityDescription( + key="up_down_remote", + device_class=EventDeviceClass.BUTTON, + event_types=[ + "released", + "up", + "down", + "stop", + "up_long", + "down_long", + "stop_long", + "c_button", + "b_button", + "a_button", + ], + ), +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomeeConfigEntry, @@ -21,30 +57,31 @@ async def async_setup_entry( """Add event entities for homee.""" async_add_entities( - HomeeEvent(attribute, config_entry) + HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type]) for node in config_entry.runtime_data.nodes for attribute in node.attributes - if attribute.type == AttributeType.UP_DOWN_REMOTE + if attribute.type in EVENT_DESCRIPTIONS + and node.profile in REMOTE_PROFILES + and not attribute.editable ) class HomeeEvent(HomeeEntity, EventEntity): """Representation of a homee event.""" - _attr_translation_key = "up_down_remote" - _attr_event_types = [ - "released", - "up", - "down", - "stop", - "up_long", - "down_long", - "stop_long", - "c_button", - "b_button", - "a_button", - ] - _attr_device_class = EventDeviceClass.BUTTON + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: EventEntityDescription, + ) -> None: + """Initialize the homee event entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + if attribute.instance > 0: + self._attr_translation_key = f"{self._attr_translation_key}_instance" + self._attr_translation_placeholders = {"instance": str(attribute.instance)} async def async_added_to_hass(self) -> None: """Add the homee event entity to home assistant.""" @@ -56,6 +93,5 @@ class HomeeEvent(HomeeEntity, EventEntity): @callback def _event_triggered(self, event: HomeeAttribute) -> None: """Handle a homee event.""" - if event.type == AttributeType.UP_DOWN_REMOTE: - self._trigger_event(self.event_types[int(event.current_value)]) - self.schedule_update_ha_state() + self._trigger_event(self.event_types[int(event.current_value)]) + self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py index 4cfc34e11fe..8b3bf58040d 100644 --- a/homeassistant/components/homee/lock.py +++ b/homeassistant/components/homee/lock.py @@ -58,9 +58,13 @@ class HomeeLock(HomeeEntity, LockEntity): AttributeChangedBy, self._attribute.changed_by ) if self._attribute.changed_by == AttributeChangedBy.USER: - changed_id = self._entry.runtime_data.get_user_by_id( + user = self._entry.runtime_data.get_user_by_id( self._attribute.changed_by_id - ).username + ) + if user is not None: + changed_id = user.username + else: + changed_id = "Unknown" return f"{changed_by_name}-{changed_id}" diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 3c2a99c30dc..16169676835 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.8"] + "requirements": ["pyHomee==1.2.10"] } diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index e2e4c6659d6..8b10b3ebb8a 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -160,12 +160,36 @@ } }, "event": { + "button_state": { + "name": "Switch", + "state_attributes": { + "event_type": { + "state": { + "upper": "Upper button", + "lower": "Lower button", + "released": "Released" + } + } + } + }, + "button_state_instance": { + "name": "Switch {instance}", + "state_attributes": { + "event_type": { + "state": { + "upper": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]", + "lower": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]", + "released": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]" + } + } + } + }, "up_down_remote": { "name": "Up/down remote", "state_attributes": { "event_type": { "state": { - "release": "Released", + "release": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]", "up": "Up", "down": "Down", "stop": "Stop", diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index 041b96963f1..5e87a1b4002 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -28,6 +28,7 @@ def get_device_class( ) -> SwitchDeviceClass: """Check device class of Switch according to node profile.""" node = config_entry.runtime_data.get_node_by_id(attribute.node_id) + assert node is not None if node.profile in [ NodeProfile.ON_OFF_PLUG, NodeProfile.METERING_PLUG, diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 0acf57fe55b..df6d4498f9c 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -355,11 +355,10 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="ignored_model") # Late imports in case BLE is not available - # pylint: disable-next=import-outside-toplevel - from aiohomekit.controller.ble.discovery import BleDiscovery - - # pylint: disable-next=import-outside-toplevel - from aiohomekit.controller.ble.manufacturer_data import HomeKitAdvertisement + from aiohomekit.controller.ble.discovery import BleDiscovery # noqa: PLC0415 + from aiohomekit.controller.ble.manufacturer_data import ( # noqa: PLC0415 + HomeKitAdvertisement, + ) mfr_data = discovery_info.manufacturer_data diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 9cf9ab28db7..30038d1f897 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -63,7 +63,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) - await async_setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 7f393cf52bd..18f169bb91b 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -216,8 +216,6 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - if hvac_mode not in self.hvac_modes: - return if hvac_mode == HVACMode.AUTO: await self._device.set_control_mode_async(HMIP_AUTOMATIC_CM) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index f3681a89110..c42ebff200d 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -128,6 +128,7 @@ class HomematicipHAP: self.config_entry.data.get(HMIPC_AUTHTOKEN), self.config_entry.data.get(HMIPC_NAME), ) + except HmipcConnectionError as err: raise ConfigEntryNotReady from err except Exception as err: # noqa: BLE001 @@ -210,41 +211,13 @@ class HomematicipHAP: for device in self.home.devices: device.fire_update_event() - async def async_connect(self) -> None: - """Start WebSocket connection.""" - tries = 0 - while True: - retry_delay = 2 ** min(tries, 8) + async def async_connect(self, home: AsyncHome) -> None: + """Connect to HomematicIP Cloud Websocket.""" + await home.enable_events() - try: - await self.home.get_current_state_async() - hmip_events = self.home.enable_events() - self.home.set_on_connected_handler(self.ws_connected_handler) - self.home.set_on_disconnected_handler(self.ws_disconnected_handler) - tries = 0 - await hmip_events - except HmipConnectionError: - _LOGGER.error( - ( - "Error connecting to HomematicIP with HAP %s. " - "Retrying in %d seconds" - ), - self.config_entry.unique_id, - retry_delay, - ) - - if self._ws_close_requested: - break - self._ws_close_requested = False - tries += 1 - - try: - self._retry_task = self.hass.async_create_task( - asyncio.sleep(retry_delay) - ) - await self._retry_task - except asyncio.CancelledError: - break + home.set_on_connected_handler(self.ws_connected_handler) + home.set_on_disconnected_handler(self.ws_disconnected_handler) + home.set_on_reconnect_handler(self.ws_reconnected_handler) async def async_reset(self) -> bool: """Close the websocket connection.""" @@ -272,14 +245,22 @@ class HomematicipHAP: async def ws_connected_handler(self) -> None: """Handle websocket connected.""" - _LOGGER.debug("WebSocket connection to HomematicIP established") + _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): await self.get_state() self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: """Handle websocket disconnection.""" - _LOGGER.warning("WebSocket connection to HomematicIP closed") + _LOGGER.warning("Websocket connection to HomematicIP Cloud closed") + self._ws_connection_closed.set() + + async def ws_reconnected_handler(self, reason: str) -> None: + """Handle websocket reconnection.""" + _LOGGER.info( + "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + reason, + ) self._ws_connection_closed.set() async def get_hap( @@ -306,6 +287,6 @@ class HomematicipHAP: home.on_update(self.async_update) home.on_create(self.async_create_entity) - hass.loop.create_task(self.async_connect()) + await self.async_connect(home) return home diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index fc4a1cb831f..d5af2859873 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.4"] + "requirements": ["homematicip==2.0.6"] } diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index a0308b14d7e..1cfb3a55552 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -12,7 +12,7 @@ from homematicip.group import HeatingGroup import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import comp_entity_ids @@ -120,7 +120,8 @@ SCHEMA_SET_HOME_COOLING_MODE = vol.Schema( ) -async def async_setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the HomematicIP Cloud services.""" @verify_domain_control(hass, DOMAIN) diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 66a40229c7e..ca591adbf5e 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,13 +4,14 @@ from __future__ import annotations from typing import Any -from homematicip.base.enums import DeviceType +from homematicip.base.enums import DeviceType, FunctionalChannelType from homematicip.device import ( BrandSwitch2, DinRailSwitch, DinRailSwitch4, FullFlushInputSwitch, HeatingSwitch2, + MotionDetectorSwitchOutdoor, MultiIOBox, OpenCollector8Module, PlugableSwitch, @@ -47,18 +48,34 @@ async def async_setup_entry( and getattr(device, "deviceType", None) != DeviceType.BRAND_SWITCH_MEASURING ): entities.append(HomematicipSwitchMeasuring(hap, device)) - elif isinstance(device, WiredSwitch8): + elif isinstance( + device, + ( + WiredSwitch8, + OpenCollector8Module, + BrandSwitch2, + PrintedCircuitBoardSwitch2, + HeatingSwitch2, + MultiIOBox, + MotionDetectorSwitchOutdoor, + DinRailSwitch, + DinRailSwitch4, + ), + ): + channel_indices = [ + ch.index + for ch in device.functionalChannels + if ch.functionalChannelType + in ( + FunctionalChannelType.SWITCH_CHANNEL, + FunctionalChannelType.MULTI_MODE_INPUT_SWITCH_CHANNEL, + ) + ] entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 9) - ) - elif isinstance(device, DinRailSwitch): - entities.append(HomematicipMultiSwitch(hap, device, channel=1)) - elif isinstance(device, DinRailSwitch4): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 5) + for channel in channel_indices ) + elif isinstance( device, ( @@ -68,24 +85,6 @@ async def async_setup_entry( ), ): entities.append(HomematicipSwitch(hap, device)) - elif isinstance(device, OpenCollector8Module): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 9) - ) - elif isinstance( - device, - ( - BrandSwitch2, - PrintedCircuitBoardSwitch2, - HeatingSwitch2, - MultiIOBox, - ), - ): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 3) - ) async_add_entities(entities) @@ -108,15 +107,15 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): @property def is_on(self) -> bool: """Return true if switch is on.""" - return self._device.functionalChannels[self._channel].on + return self.functional_channel.on async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.turn_on_async(self._channel) + await self.functional_channel.async_turn_on() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.turn_off_async(self._channel) + await self.functional_channel.async_turn_off() class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 3831146aed8..6c9530db72c 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -23,9 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - api: HomeWizardEnergy - is_battery = entry.unique_id.startswith("HWE-BAT") if entry.unique_id else False - - if (token := entry.data.get(CONF_TOKEN)) and is_battery: + if token := entry.data.get(CONF_TOKEN): api = HomeWizardEnergyV2( entry.data[CONF_IP_ADDRESS], token=token, @@ -37,8 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) - if is_battery: - await async_check_v2_support_and_create_issue(hass, entry) + await async_check_v2_support_and_create_issue(hass, entry) coordinator = HWEnergyDeviceUpdateCoordinator(hass, entry, api) try: diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index e0448edaf86..ed1c140a23b 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -8,7 +8,13 @@ import logging from homeassistant.const import Platform DOMAIN = "homewizard" -PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index c4160b0bbb0..0aee8f80078 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from typing import Any, Concatenate -from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError from homeassistant.exceptions import HomeAssistantError @@ -41,5 +41,10 @@ def homewizard_exception_handler[_HomeWizardEntityT: HomeWizardEntity, **_P]( translation_domain=DOMAIN, translation_key="api_disabled", ) from ex + except UnauthorizedError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_unauthorized", + ) from ex return handler diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 5d817fef837..9fd74fa80e4 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==8.3.3"], + "requirements": ["python-homewizard-energy==9.1.1"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/select.py b/homeassistant/components/homewizard/select.py new file mode 100644 index 00000000000..2ae37883107 --- /dev/null +++ b/homeassistant/components/homewizard/select.py @@ -0,0 +1,89 @@ +"""Support for HomeWizard select platform.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from homewizard_energy import HomeWizardEnergy +from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator +from .entity import HomeWizardEntity +from .helpers import homewizard_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class HomeWizardSelectEntityDescription(SelectEntityDescription): + """Class describing HomeWizard select entities.""" + + available_fn: Callable[[DeviceResponseEntry], bool] + create_fn: Callable[[DeviceResponseEntry], bool] + current_fn: Callable[[DeviceResponseEntry], str | None] + set_fn: Callable[[HomeWizardEnergy, str], Awaitable[Any]] + + +DESCRIPTIONS = [ + HomeWizardSelectEntityDescription( + key="battery_group_mode", + translation_key="battery_group_mode", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + options=[Batteries.Mode.ZERO, Batteries.Mode.STANDBY, Batteries.Mode.TO_FULL], + available_fn=lambda x: x.batteries is not None, + create_fn=lambda x: x.batteries is not None, + current_fn=lambda x: x.batteries.mode if x.batteries else None, + set_fn=lambda api, mode: api.batteries(mode=Batteries.Mode(mode)), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeWizardConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up HomeWizard select based on a config entry.""" + async_add_entities( + HomeWizardSelectEntity( + coordinator=entry.runtime_data, + description=description, + ) + for description in DESCRIPTIONS + if description.create_fn(entry.runtime_data.data) + ) + + +class HomeWizardSelectEntity(HomeWizardEntity, SelectEntity): + """Defines a HomeWizard select entity.""" + + entity_description: HomeWizardSelectEntityDescription + + def __init__( + self, + coordinator: HWEnergyDeviceUpdateCoordinator, + description: HomeWizardSelectEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self.coordinator.data) + + @homewizard_exception_handler + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.set_fn(self.coordinator.api, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 076e9375d24..4216ece64cb 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -152,14 +152,27 @@ "cloud_connection": { "name": "Cloud connection" } + }, + "select": { + "battery_group_mode": { + "name": "Battery group mode", + "state": { + "zero": "Zero mode", + "to_full": "Manual charge mode", + "standby": "Standby" + } + } } }, "exceptions": { "api_disabled": { "message": "The local API is disabled." }, + "api_unauthorized": { + "message": "The local API is unauthorized. Restore API access by following the instructions in the repair issue." + }, "communication_error": { - "message": "An error occurred while communicating with HomeWizard device" + "message": "An error occurred while communicating with your HomeWizard Energy device" } }, "issues": { diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 8ee27039441..cdf3347e24f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -278,8 +278,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ssl_certificate is not None and (hass.config.external_url or hass.config.internal_url) is None ): - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import ( + from homeassistant.components.cloud import ( # noqa: PLC0415 CloudNotAvailable, async_remote_ui_url, ) @@ -511,12 +510,14 @@ class HomeAssistantHTTP: ) -> None: """Register a folder or file to serve as a static path.""" frame.report_usage( - "calls hass.http.register_static_path which is deprecated because " - "it does blocking I/O in the event loop, instead " + "calls hass.http.register_static_path which " + "does blocking I/O in the event loop, instead " "call `await hass.http.async_register_static_paths(" f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`', exclude_integrations={"http"}, - core_behavior=frame.ReportBehavior.LOG, + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.ERROR, breaks_in_ha_version="2025.7", ) configs = [StaticPathConfig(url_path, path, cache_headers)] diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 821d44eebaa..71f3d54bef6 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -136,8 +136,7 @@ async def process_wrong_login(request: Request) -> None: _LOGGER.warning(log_msg) # Circular import with websocket_api - # pylint: disable=import-outside-toplevel - from homeassistant.components import persistent_notification + from homeassistant.components import persistent_notification # noqa: PLC0415 persistent_notification.async_create( hass, notification_msg, "Login attempt failed", NOTIFICATION_ID_LOGIN diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index 3fcf4aa45f9..0fd6e8bdae0 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -8,7 +8,7 @@ import logging from aiohue import HueBridgeV1, HueBridgeV2 import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import verify_domain_control @@ -25,6 +25,7 @@ from .const import ( LOGGER = logging.getLogger(__name__) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for Hue integration.""" diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 705975bb966..29a4fafb8c0 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.5.1"] + "requirements": ["aioautomower==2025.6.0"] } diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 5b815e79263..9e808c66878 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -10,7 +10,13 @@ "description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url})." }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json index 684fb276f53..6c14b2ddf6c 100644 --- a/homeassistant/components/hyperion/manifest.json +++ b/homeassistant/components/hyperion/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/hyperion", "iot_class": "local_push", "loggers": ["hyperion"], - "requirements": ["hyperion-py==0.7.5"], + "requirements": ["hyperion-py==0.7.6"], "ssdp": [ { "manufacturer": "Hyperion Open Source Ambient Lighting", diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index ea7bc9e39fa..c53754c712a 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -82,6 +82,9 @@ }, "usb_capture": { "name": "Component USB capture" + }, + "audio_capture": { + "name": "Component Audio capture" } }, "sensor": { diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index c082c685304..b1288936636 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -9,6 +9,7 @@ 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, @@ -59,6 +60,7 @@ COMPONENT_SWITCHES = [ KEY_COMPONENTID_GRABBER, KEY_COMPONENTID_LEDDEVICE, KEY_COMPONENTID_V4L, + KEY_COMPONENTID_AUDIO, ] @@ -83,6 +85,7 @@ 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] diff --git a/homeassistant/components/icloud/services.py b/homeassistant/components/icloud/services.py index 6262710460f..dbb843e8216 100644 --- a/homeassistant/components/icloud/services.py +++ b/homeassistant/components/icloud/services.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify @@ -115,6 +115,7 @@ def _get_account(hass: HomeAssistant, account_identifier: str) -> IcloudAccount: return icloud_account +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register iCloud services.""" diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index d641f8dc6b5..06be933ba6b 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -444,8 +444,9 @@ class TimerManager: timer.finish() if timer.conversation_command: - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.conversation import async_converse + from homeassistant.components.conversation import ( # noqa: PLC0415 + async_converse, + ) self.hass.async_create_background_task( async_converse( diff --git a/homeassistant/components/iotty/strings.json b/homeassistant/components/iotty/strings.json index cb0dc509d9a..cf9a8fbb877 100644 --- a/homeassistant/components/iotty/strings.json +++ b/homeassistant/components/iotty/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index c93844dd559..1ab967ecfa4 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate[astral]==1.1.0"], + "requirements": ["hdate[astral]==1.1.2"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index cb38a3797eb..91c618e1c1c 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -73,7 +73,7 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( translation_key="weekly_portion", device_class=SensorDeviceClass.ENUM, options_fn=lambda _: [str(p) for p in Parasha], - value_fn=lambda results: str(results.after_tzais_date.upcoming_shabbat.parasha), + value_fn=lambda results: results.after_tzais_date.upcoming_shabbat.parasha, ), JewishCalendarSensorDescription( key="holiday", @@ -98,17 +98,13 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( key="omer_count", translation_key="omer_count", entity_registry_enabled_default=False, - value_fn=lambda results: ( - results.after_shkia_date.omer.total_days - if results.after_shkia_date.omer - else 0 - ), + value_fn=lambda results: results.after_shkia_date.omer.total_days, ), JewishCalendarSensorDescription( key="daf_yomi", translation_key="daf_yomi", entity_registry_enabled_default=False, - value_fn=lambda results: str(results.daytime_date.daf_yomi), + value_fn=lambda results: results.daytime_date.daf_yomi, ), ) diff --git a/homeassistant/components/jewish_calendar/services.py b/homeassistant/components/jewish_calendar/services.py index a065ee9c969..6fdebe6f74d 100644 --- a/homeassistant/components/jewish_calendar/services.py +++ b/homeassistant/components/jewish_calendar/services.py @@ -15,6 +15,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -39,6 +40,7 @@ OMER_SCHEMA = vol.Schema( ) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Jewish Calendar services.""" diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index fcfca7f2492..6cfdd85c6b7 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -1,6 +1,5 @@ """The JuiceNet integration.""" -from datetime import timedelta import logging import aiohttp @@ -14,9 +13,9 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .coordinator import JuiceNetCoordinator from .device import JuiceNetApi _LOGGER = logging.getLogger(__name__) @@ -74,20 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False _LOGGER.debug("%d JuiceNet device(s) found", len(juicenet.devices)) - async def async_update_data(): - """Update all device states from the JuiceNet API.""" - for device in juicenet.devices: - await device.update_state(True) - return True - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="JuiceNet", - update_method=async_update_data, - update_interval=timedelta(seconds=30), - ) + coordinator = JuiceNetCoordinator(hass, entry, juicenet) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/juicenet/coordinator.py b/homeassistant/components/juicenet/coordinator.py new file mode 100644 index 00000000000..7a89416e400 --- /dev/null +++ b/homeassistant/components/juicenet/coordinator.py @@ -0,0 +1,33 @@ +"""The JuiceNet integration.""" + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .device import JuiceNetApi + +_LOGGER = logging.getLogger(__name__) + + +class JuiceNetCoordinator(DataUpdateCoordinator[None]): + """Coordinator for JuiceNet.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, juicenet_api: JuiceNetApi + ) -> None: + """Initialize the JuiceNet coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="JuiceNet", + update_interval=timedelta(seconds=30), + ) + self.juicenet_api = juicenet_api + + async def _async_update_data(self) -> None: + for device in self.juicenet_api.devices: + await device.update_state(True) diff --git a/homeassistant/components/juicenet/device.py b/homeassistant/components/juicenet/device.py index daec88c2a94..b38b0efd68a 100644 --- a/homeassistant/components/juicenet/device.py +++ b/homeassistant/components/juicenet/device.py @@ -1,19 +1,21 @@ """Adapter to wrap the pyjuicenet api for home assistant.""" +from pyjuicenet import Api, Charger + class JuiceNetApi: """Represent a connection to JuiceNet.""" - def __init__(self, api): + def __init__(self, api: Api) -> None: """Create an object from the provided API instance.""" self.api = api - self._devices = [] + self._devices: list[Charger] = [] - async def setup(self): + async def setup(self) -> None: """JuiceNet device setup.""" self._devices = await self.api.get_devices() @property - def devices(self) -> list: + def devices(self) -> list[Charger]: """Get a list of devices managed by this account.""" return self._devices diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index b3433948582..d54ccb5accb 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -3,21 +3,19 @@ from pyjuicenet import Charger from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import JuiceNetCoordinator -class JuiceNetDevice(CoordinatorEntity): +class JuiceNetEntity(CoordinatorEntity[JuiceNetCoordinator]): """Represent a base JuiceNet device.""" _attr_has_entity_name = True def __init__( - self, device: Charger, key: str, coordinator: DataUpdateCoordinator + self, device: Charger, key: str, coordinator: JuiceNetCoordinator ) -> None: """Initialise the sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index 69323884f61..ff8c357a115 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from pyjuicenet import Api, Charger +from pyjuicenet import Charger from homeassistant.components.number import ( DEFAULT_MAX_VALUE, @@ -14,10 +14,11 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice +from .coordinator import JuiceNetCoordinator +from .device import JuiceNetApi +from .entity import JuiceNetEntity @dataclass(frozen=True, kw_only=True) @@ -47,8 +48,8 @@ async def async_setup_entry( ) -> None: """Set up the JuiceNet Numbers.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: Api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] + api: JuiceNetApi = juicenet_data[JUICENET_API] + coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] entities = [ JuiceNetNumber(device, description, coordinator) @@ -58,7 +59,7 @@ async def async_setup_entry( async_add_entities(entities) -class JuiceNetNumber(JuiceNetDevice, NumberEntity): +class JuiceNetNumber(JuiceNetEntity, NumberEntity): """Implementation of a JuiceNet number.""" entity_description: JuiceNetNumberEntityDescription @@ -67,7 +68,7 @@ class JuiceNetNumber(JuiceNetDevice, NumberEntity): self, device: Charger, description: JuiceNetNumberEntityDescription, - coordinator: DataUpdateCoordinator, + coordinator: JuiceNetCoordinator, ) -> None: """Initialise the number.""" super().__init__(device, description.key, coordinator) diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 7bf0639f5d0..e3ae35da2ce 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pyjuicenet import Charger + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -21,7 +23,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice +from .coordinator import JuiceNetCoordinator +from .device import JuiceNetApi +from .entity import JuiceNetEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -74,8 +78,8 @@ async def async_setup_entry( ) -> None: """Set up the JuiceNet Sensors.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] + api: JuiceNetApi = juicenet_data[JUICENET_API] + coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] entities = [ JuiceNetSensorDevice(device, coordinator, description) @@ -85,11 +89,14 @@ async def async_setup_entry( async_add_entities(entities) -class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): +class JuiceNetSensorDevice(JuiceNetEntity, SensorEntity): """Implementation of a JuiceNet sensor.""" def __init__( - self, device, coordinator, description: SensorEntityDescription + self, + device: Charger, + coordinator: JuiceNetCoordinator, + description: SensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(device, description.key, coordinator) diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py index 9f34b7afdb3..e8a16e9da8f 100644 --- a/homeassistant/components/juicenet/switch.py +++ b/homeassistant/components/juicenet/switch.py @@ -2,13 +2,17 @@ from typing import Any +from pyjuicenet import Charger + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice +from .coordinator import JuiceNetCoordinator +from .device import JuiceNetApi +from .entity import JuiceNetEntity async def async_setup_entry( @@ -18,20 +22,20 @@ async def async_setup_entry( ) -> None: """Set up the JuiceNet switches.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] + api: JuiceNetApi = juicenet_data[JUICENET_API] + coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] async_add_entities( JuiceNetChargeNowSwitch(device, coordinator) for device in api.devices ) -class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity): +class JuiceNetChargeNowSwitch(JuiceNetEntity, SwitchEntity): """Implementation of a JuiceNet switch.""" _attr_translation_key = "charge_now" - def __init__(self, device, coordinator): + def __init__(self, device: Charger, coordinator: JuiceNetCoordinator) -> None: """Initialise the switch.""" super().__init__(device, "charge_now", coordinator) diff --git a/homeassistant/components/justnimbus/__init__.py b/homeassistant/components/justnimbus/__init__.py index 123807d887c..5f369027b00 100644 --- a/homeassistant/components/justnimbus/__init__.py +++ b/homeassistant/components/justnimbus/__init__.py @@ -2,15 +2,14 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN, PLATFORMS -from .coordinator import JustNimbusCoordinator +from .const import PLATFORMS +from .coordinator import JustNimbusConfigEntry, JustNimbusCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: JustNimbusConfigEntry) -> bool: """Set up JustNimbus from a config entry.""" if "zip_code" in entry.data: coordinator = JustNimbusCoordinator(hass, entry) @@ -18,13 +17,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: JustNimbusConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/justnimbus/coordinator.py b/homeassistant/components/justnimbus/coordinator.py index a6945c45417..b51058a8e54 100644 --- a/homeassistant/components/justnimbus/coordinator.py +++ b/homeassistant/components/justnimbus/coordinator.py @@ -16,13 +16,17 @@ from .const import CONF_ZIP_CODE, DOMAIN _LOGGER = logging.getLogger(__name__) +type JustNimbusConfigEntry = ConfigEntry[JustNimbusCoordinator] + class JustNimbusCoordinator(DataUpdateCoordinator[justnimbus.JustNimbusModel]): """Data update coordinator.""" - config_entry: ConfigEntry + config_entry: JustNimbusConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: JustNimbusConfigEntry + ) -> None: """Initialize the coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index 1e288e272cd..88f12cad113 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, EntityCategory, @@ -24,8 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import JustNimbusCoordinator -from .const import DOMAIN +from .coordinator import JustNimbusConfigEntry, JustNimbusCoordinator from .entity import JustNimbusEntity @@ -102,16 +100,15 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: JustNimbusConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JustNimbus sensor.""" - coordinator: JustNimbusCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( JustNimbusSensor( device_id=entry.data[CONF_CLIENT_ID], description=description, - coordinator=coordinator, + coordinator=entry.runtime_data, ) for description in SENSOR_TYPES ) diff --git a/homeassistant/components/kaleidescape/__init__.py b/homeassistant/components/kaleidescape/__init__.py index f074ac640d8..c6639e096d7 100644 --- a/homeassistant/components/kaleidescape/__init__.py +++ b/homeassistant/components/kaleidescape/__init__.py @@ -3,26 +3,22 @@ from __future__ import annotations from dataclasses import dataclass -import logging -from typing import TYPE_CHECKING from kaleidescape import Device as KaleidescapeDevice, KaleidescapeError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import Event, HomeAssistant - -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE, Platform.SENSOR] +type KaleidescapeConfigEntry = ConfigEntry[KaleidescapeDevice] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: KaleidescapeConfigEntry +) -> bool: """Set up Kaleidescape from a config entry.""" device = KaleidescapeDevice( entry.data[CONF_HOST], timeout=5, reconnect=True, reconnect_delay=5 @@ -36,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unable to connect to {entry.data[CONF_HOST]}: {err}" ) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + entry.runtime_data = device async def disconnect(event: Event) -> None: await device.disconnect() @@ -44,18 +40,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) ) + entry.async_on_unload(device.disconnect) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: KaleidescapeConfigEntry +) -> bool: """Unload config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await hass.data[DOMAIN][entry.entry_id].disconnect() - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @dataclass diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py index cd8aa9d4a8e..564b0c41c30 100644 --- a/homeassistant/components/kaleidescape/media_player.py +++ b/homeassistant/components/kaleidescape/media_player.py @@ -2,8 +2,8 @@ from __future__ import annotations +from datetime import datetime import logging -from typing import TYPE_CHECKING from kaleidescape import const as kaleidescape_const @@ -12,19 +12,13 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow -from .const import DOMAIN +from . import KaleidescapeConfigEntry from .entity import KaleidescapeEntity -if TYPE_CHECKING: - from datetime import datetime - - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - - KALEIDESCAPE_PLAYING_STATES = [ kaleidescape_const.PLAY_STATUS_PLAYING, kaleidescape_const.PLAY_STATUS_FORWARD, @@ -39,11 +33,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: KaleidescapeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - entities = [KaleidescapeMediaPlayer(hass.data[DOMAIN][entry.entry_id])] + entities = [KaleidescapeMediaPlayer(entry.runtime_data)] async_add_entities(entities) diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py index 2b341e0c429..a71fb7f917a 100644 --- a/homeassistant/components/kaleidescape/remote.py +++ b/homeassistant/components/kaleidescape/remote.py @@ -2,32 +2,27 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from collections.abc import Iterable +from typing import Any from kaleidescape import const as kaleidescape_const from homeassistant.components.remote import RemoteEntity +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import KaleidescapeConfigEntry from .entity import KaleidescapeEntity -if TYPE_CHECKING: - from collections.abc import Iterable - from typing import Any - - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: KaleidescapeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - entities = [KaleidescapeRemote(hass.data[DOMAIN][entry.entry_id])] + entities = [KaleidescapeRemote(entry.runtime_data)] async_add_entities(entities) diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index ac0f6504daa..8d7365aa20b 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -2,25 +2,20 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING + +from kaleidescape import Device as KaleidescapeDevice from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import KaleidescapeConfigEntry from .entity import KaleidescapeEntity -if TYPE_CHECKING: - from collections.abc import Callable - - from kaleidescape import Device as KaleidescapeDevice - - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - from homeassistant.helpers.typing import StateType - @dataclass(frozen=True, kw_only=True) class KaleidescapeSensorEntityDescription(SensorEntityDescription): @@ -132,11 +127,11 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: KaleidescapeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - device: KaleidescapeDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data async_add_entities( KaleidescapeSensor(device, description) for description in SENSOR_TYPES ) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index e2ca17ebce8..7986158ab50 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -19,16 +18,14 @@ from .const import ( DEFAULT_INTERFACE, DEFAULT_SCAN_INTERVAL, DOMAIN, - ROUTER, - UNDO_UPDATE_LISTENER, ) -from .router import KeeneticRouter +from .router import KeeneticConfigEntry, KeeneticRouter PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> bool: """Set up the component.""" hass.data.setdefault(DOMAIN, {}) async_add_defaults(hass, entry) @@ -36,32 +33,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: router = KeeneticRouter(hass, entry) await router.async_setup() - undo_listener = entry.add_update_listener(update_listener) + entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = { - ROUTER: router, - UNDO_UPDATE_LISTENER: undo_listener, - } + entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: KeeneticConfigEntry +) -> bool: """Unload a config entry.""" - hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() - unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] - + router = config_entry.runtime_data await router.async_teardown() - hass.data[DOMAIN].pop(config_entry.entry_id) - new_tracked_interfaces: set[str] = set(config_entry.options[CONF_INTERFACES]) if router.tracked_interfaces - new_tracked_interfaces: @@ -96,12 +87,12 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -def async_add_defaults(hass: HomeAssistant, entry: ConfigEntry): +def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): """Populate default options.""" host: str = entry.data[CONF_HOST] imported_options: dict = hass.data[DOMAIN].get(f"imported_options_{host}", {}) diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py index 4d1b5da3552..6eea55c33e7 100644 --- a/homeassistant/components/keenetic_ndms2/binary_sensor.py +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -4,24 +4,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import KeeneticRouter -from .const import DOMAIN, ROUTER +from .router import KeeneticConfigEntry, KeeneticRouter async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: KeeneticConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Keenetic NDMS2 component.""" - router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] - - async_add_entities([RouterOnlineBinarySensor(router)]) + async_add_entities([RouterOnlineBinarySensor(config_entry.runtime_data)]) class RouterOnlineBinarySensor(BinarySensorEntity): diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 3dc4c8b1b77..7219819b911 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -8,12 +8,7 @@ from urllib.parse import urlparse from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -41,9 +36,8 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_TELNET_PORT, DOMAIN, - ROUTER, ) -from .router import KeeneticRouter +from .router import KeeneticConfigEntry class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): @@ -56,7 +50,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: KeeneticConfigEntry, ) -> KeeneticOptionsFlowHandler: """Get the options flow for this handler.""" return KeeneticOptionsFlowHandler() @@ -142,6 +136,8 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): class KeeneticOptionsFlowHandler(OptionsFlow): """Handle options.""" + config_entry: KeeneticConfigEntry + def __init__(self) -> None: """Initialize options flow.""" self._interface_options: dict[str, str] = {} @@ -150,9 +146,7 @@ class KeeneticOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" - router: KeeneticRouter = self.hass.data[DOMAIN][self.config_entry.entry_id][ - ROUTER - ] + router = self.config_entry.runtime_data interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job( router.client.get_interfaces diff --git a/homeassistant/components/keenetic_ndms2/const.py b/homeassistant/components/keenetic_ndms2/const.py index 0b415a9502f..4a856647387 100644 --- a/homeassistant/components/keenetic_ndms2/const.py +++ b/homeassistant/components/keenetic_ndms2/const.py @@ -5,8 +5,6 @@ from homeassistant.components.device_tracker import ( ) DOMAIN = "keenetic_ndms2" -ROUTER = "router" -UNDO_UPDATE_LISTENER = "undo_update_listener" DEFAULT_TELNET_PORT = 23 DEFAULT_SCAN_INTERVAL = 120 DEFAULT_CONSIDER_HOME = _DEFAULT_CONSIDER_HOME.total_seconds() diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index 4143611d6af..7de7c497ef3 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -10,26 +10,24 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, ROUTER -from .router import KeeneticRouter +from .router import KeeneticConfigEntry, KeeneticRouter _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: KeeneticConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Keenetic NDMS2 component.""" - router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] + router = config_entry.runtime_data tracked: set[str] = set() diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index 8c3079b910d..364e921cd40 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -35,11 +35,13 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type KeeneticConfigEntry = ConfigEntry[KeeneticRouter] + class KeeneticRouter: """Keenetic client Object.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: KeeneticConfigEntry) -> None: """Initialize the Client.""" self.hass = hass self.config_entry = config_entry diff --git a/homeassistant/components/kegtron/__init__.py b/homeassistant/components/kegtron/__init__.py index d7485be0840..ec2ebee6995 100644 --- a/homeassistant/components/kegtron/__init__.py +++ b/homeassistant/components/kegtron/__init__.py @@ -14,27 +14,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type KegtronConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: KegtronConfigEntry) -> bool: """Set up Kegtron BLE device from a config entry.""" address = entry.unique_id assert address is not None data = KegtronBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.PASSIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( coordinator.async_start() @@ -42,9 +41,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: KegtronConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/kegtron/sensor.py b/homeassistant/components/kegtron/sensor.py index 602c61f96ff..f0023e8ef6a 100644 --- a/homeassistant/components/kegtron/sensor.py +++ b/homeassistant/components/kegtron/sensor.py @@ -8,11 +8,9 @@ from kegtron_ble import ( Units, ) -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -30,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import KegtronConfigEntry from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -109,13 +107,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: KegtronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Kegtron BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py index 7fea46d7a02..01948006852 100644 --- a/homeassistant/components/keymitt_ble/__init__.py +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -2,26 +2,20 @@ from __future__ import annotations -import logging - from microbot import MicroBotApiClient from homeassistant.components import bluetooth -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import MicroBotDataUpdateCoordinator +from .coordinator import MicroBotConfigEntry, MicroBotDataUpdateCoordinator -_LOGGER: logging.Logger = logging.getLogger(__package__) PLATFORMS: list[str] = [Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MicroBotConfigEntry) -> bool: """Set up this integration using UI.""" - hass.data.setdefault(DOMAIN, {}) token: str = entry.data[CONF_ACCESS_TOKEN] bdaddr: str = entry.data[CONF_ADDRESS] ble_device = bluetooth.async_ble_device_from_address(hass, bdaddr) @@ -35,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, client=client, ble_device=ble_device ) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(coordinator.async_start()) @@ -43,9 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MicroBotConfigEntry) -> bool: """Handle removal of an entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/keymitt_ble/coordinator.py b/homeassistant/components/keymitt_ble/coordinator.py index 3e72826ac5d..9d2b250ba82 100644 --- a/homeassistant/components/keymitt_ble/coordinator.py +++ b/homeassistant/components/keymitt_ble/coordinator.py @@ -11,14 +11,15 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothDataUpdateCoordinator, ) -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback if TYPE_CHECKING: from bleak.backends.device import BLEDevice _LOGGER: logging.Logger = logging.getLogger(__package__) -PLATFORMS: list[str] = [Platform.SWITCH] + +type MicroBotConfigEntry = ConfigEntry[MicroBotDataUpdateCoordinator] class MicroBotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): @@ -31,7 +32,7 @@ class MicroBotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): ble_device: BLEDevice, ) -> None: """Initialize.""" - self.api: MicroBotApiClient = client + self.api = client self.data: dict[str, Any] = {} self.ble_device = ble_device super().__init__( diff --git a/homeassistant/components/keymitt_ble/entity.py b/homeassistant/components/keymitt_ble/entity.py index b5229e6917e..94bb1498744 100644 --- a/homeassistant/components/keymitt_ble/entity.py +++ b/homeassistant/components/keymitt_ble/entity.py @@ -19,7 +19,7 @@ class MicroBotEntity(PassiveBluetoothCoordinatorEntity[MicroBotDataUpdateCoordin _attr_has_entity_name = True - def __init__(self, coordinator, config_entry): + def __init__(self, coordinator: MicroBotDataUpdateCoordinator) -> None: """Initialise the entity.""" super().__init__(coordinator) self._address = self.coordinator.ble_device.address diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index 57d3af98062..dab7d8c2d36 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -7,7 +7,6 @@ from typing import Any import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -16,8 +15,7 @@ from homeassistant.helpers.entity_platform import ( ) from homeassistant.helpers.typing import VolDictType -from .const import DOMAIN -from .coordinator import MicroBotDataUpdateCoordinator +from .coordinator import MicroBotConfigEntry from .entity import MicroBotEntity CALIBRATE = "calibrate" @@ -30,12 +28,11 @@ CALIBRATE_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MicroBot based on a config entry.""" - coordinator: MicroBotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([MicroBotBinarySwitch(coordinator, entry)]) + async_add_entities([MicroBotBinarySwitch(entry.runtime_data)]) platform = async_get_current_platform() platform.async_register_entity_service( CALIBRATE, diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py index edec0b32af2..84959217a5d 100644 --- a/homeassistant/components/kmtronic/__init__.py +++ b/homeassistant/components/kmtronic/__init__.py @@ -1,27 +1,18 @@ """The kmtronic integration.""" -import asyncio -from datetime import timedelta -import logging - -import aiohttp from pykmtronic.auth import Auth from pykmtronic.hub import KMTronicHubAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DATA_COORDINATOR, DATA_HUB, DOMAIN, MANUFACTURER, UPDATE_LISTENER +from .coordinator import KMTronicConfigEntry, KMtronicCoordinator PLATFORMS = [Platform.SWITCH] -_LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: KMTronicConfigEntry) -> bool: """Set up kmtronic from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) auth = Auth( @@ -31,51 +22,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) hub = KMTronicHubAPI(auth) - - async def async_update_data(): - try: - async with asyncio.timeout(10): - await hub.async_update_relays() - except aiohttp.client_exceptions.ClientResponseError as err: - raise UpdateFailed(f"Wrong credentials: {err}") from err - except aiohttp.client_exceptions.ClientConnectorError as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{MANUFACTURER} {hub.name}", - update_method=async_update_data, - update_interval=timedelta(seconds=30), - ) + coordinator = KMtronicCoordinator(hass, entry, hub) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_HUB: hub, - DATA_COORDINATOR: coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - update_listener = entry.add_update_listener(async_update_options) - hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] = update_listener + entry.async_on_unload(entry.add_update_listener(async_update_options)) return True -async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, config_entry: KMTronicConfigEntry +) -> None: """Update options.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: KMTronicConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - update_listener = hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] - update_listener() - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py index 3bdb3074851..6604b559bc2 100644 --- a/homeassistant/components/kmtronic/const.py +++ b/homeassistant/components/kmtronic/const.py @@ -4,9 +4,4 @@ DOMAIN = "kmtronic" CONF_REVERSE = "reverse" -DATA_HUB = "hub" -DATA_COORDINATOR = "coordinator" - MANUFACTURER = "KMtronic" - -UPDATE_LISTENER = "update_listener" diff --git a/homeassistant/components/kmtronic/coordinator.py b/homeassistant/components/kmtronic/coordinator.py new file mode 100644 index 00000000000..a5bebff466b --- /dev/null +++ b/homeassistant/components/kmtronic/coordinator.py @@ -0,0 +1,50 @@ +"""The kmtronic integration.""" + +import asyncio +from datetime import timedelta +import logging + +from aiohttp.client_exceptions import ClientConnectorError, ClientResponseError +from pykmtronic.hub import KMTronicHubAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MANUFACTURER + +PLATFORMS = [Platform.SWITCH] + +_LOGGER = logging.getLogger(__name__) + +type KMTronicConfigEntry = ConfigEntry[KMtronicCoordinator] + + +class KMtronicCoordinator(DataUpdateCoordinator[None]): + """Coordinator for KMTronic.""" + + entry: KMTronicConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: KMTronicConfigEntry, hub: KMTronicHubAPI + ) -> None: + """Initialize the KMTronic coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{MANUFACTURER} {hub.name}", + update_interval=timedelta(seconds=30), + ) + self.hub = hub + + async def _async_update_data(self) -> None: + """Fetch the latest data from the source.""" + try: + async with asyncio.timeout(10): + await self.hub.async_update_relays() + except ClientResponseError as err: + raise UpdateFailed(f"Wrong credentials: {err}") from err + except ClientConnectorError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index b32f78b0e98..f8d068cec87 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -4,23 +4,23 @@ from typing import Any import urllib.parse from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_REVERSE, DATA_COORDINATOR, DATA_HUB, DOMAIN, MANUFACTURER +from .const import CONF_REVERSE, DOMAIN, MANUFACTURER +from .coordinator import KMTronicConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: KMTronicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry example.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - hub = hass.data[DOMAIN][entry.entry_id][DATA_HUB] + coordinator = entry.runtime_data + hub = coordinator.hub reverse = entry.options.get(CONF_REVERSE, False) await hub.async_get_relays() diff --git a/homeassistant/components/kodi/__init__.py b/homeassistant/components/kodi/__init__.py index d3c7d4da724..5ffde76d313 100644 --- a/homeassistant/components/kodi/__init__.py +++ b/homeassistant/components/kodi/__init__.py @@ -1,8 +1,10 @@ """The kodi component.""" +from dataclasses import dataclass import logging from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connection +from pykodi.kodi import KodiHTTPConnection, KodiWSConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -17,19 +19,23 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ( - CONF_WS_PORT, - DATA_CONNECTION, - DATA_KODI, - DATA_REMOVE_LISTENER, - DOMAIN, -) +from .const import CONF_WS_PORT _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.MEDIA_PLAYER] +type KodiConfigEntry = ConfigEntry[KodiRuntimeData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class KodiRuntimeData: + """Data class to hold Kodi runtime data.""" + + connection: KodiHTTPConnection | KodiWSConnection + kodi: Kodi + + +async def async_setup_entry(hass: HomeAssistant, entry: KodiConfigEntry) -> bool: """Set up Kodi from a config entry.""" conn = get_kodi_connection( entry.data[CONF_HOST], @@ -58,26 +64,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _close(event): await conn.close() - remove_stop_listener = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close) + entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_CONNECTION: conn, - DATA_KODI: kodi, - DATA_REMOVE_LISTENER: remove_stop_listener, - } + entry.runtime_data = KodiRuntimeData(connection=conn, kodi=kodi) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: KodiConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - data = hass.data[DOMAIN].pop(entry.entry_id) - await data[DATA_CONNECTION].close() - data[DATA_REMOVE_LISTENER]() + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.connection.close() return unload_ok diff --git a/homeassistant/components/kodi/const.py b/homeassistant/components/kodi/const.py index 479b02e0fb5..1ac439b27c3 100644 --- a/homeassistant/components/kodi/const.py +++ b/homeassistant/components/kodi/const.py @@ -4,10 +4,6 @@ DOMAIN = "kodi" CONF_WS_PORT = "ws_port" -DATA_CONNECTION = "connection" -DATA_KODI = "kodi" -DATA_REMOVE_LISTENER = "remove_listener" - DEFAULT_PORT = 8080 DEFAULT_SSL = False DEFAULT_TIMEOUT = 5 diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index c4a2436548a..2e32d969fce 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -24,7 +24,7 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -55,6 +55,7 @@ from homeassistant.helpers.network import is_internal_request from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType from homeassistant.util import dt as dt_util +from . import KodiConfigEntry from .browse_media import ( build_item_response, get_media_info, @@ -63,8 +64,6 @@ from .browse_media import ( ) from .const import ( CONF_WS_PORT, - DATA_CONNECTION, - DATA_KODI, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_TIMEOUT, @@ -208,7 +207,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: KodiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Kodi media player platform.""" @@ -220,14 +219,12 @@ async def async_setup_entry( SERVICE_CALL_METHOD, KODI_CALL_METHOD_SCHEMA, "async_call_method" ) - data = hass.data[DOMAIN][config_entry.entry_id] - connection = data[DATA_CONNECTION] - kodi = data[DATA_KODI] + data = config_entry.runtime_data name = config_entry.data[CONF_NAME] if (uid := config_entry.unique_id) is None: uid = config_entry.entry_id - entity = KodiEntity(connection, kodi, name, uid) + entity = KodiEntity(data.connection, data.kodi, name, uid) async_add_entities([entity]) diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 25c731ac7f4..dd4dbc7dbe5 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -58,7 +58,6 @@ from .const import ( PIN_TO_ZONE, STATE_HIGH, STATE_LOW, - UNDO_UPDATE_LISTENER, UPDATE_ENDPOINT, ZONE_TO_PIN, ZONES, @@ -261,10 +260,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # config entry specific data to enable unload - hass.data[DOMAIN][entry.entry_id] = { - UNDO_UPDATE_LISTENER: entry.add_update_listener(async_entry_updated) - } + entry.async_on_unload(entry.add_update_listener(async_entry_updated)) return True @@ -272,11 +268,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - if unload_ok: hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID]) - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py index c4dd67e7d39..ffaa548003b 100644 --- a/homeassistant/components/konnected/const.py +++ b/homeassistant/components/konnected/const.py @@ -44,5 +44,3 @@ ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} ENDPOINT_ROOT = "/api/konnected" UPDATE_ENDPOINT = ENDPOINT_ROOT + r"/device/{device_id:[a-zA-Z0-9]+}" SIGNAL_DS18B20_NEW = "konnected.ds18b20.new" - -UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/kostal_plenticore/__init__.py b/homeassistant/components/kostal_plenticore/__init__.py index 3675b4342b4..c549a8d338f 100644 --- a/homeassistant/components/kostal_plenticore/__init__.py +++ b/homeassistant/components/kostal_plenticore/__init__.py @@ -4,42 +4,35 @@ import logging from pykoplenti import ApiException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import Plenticore +from .coordinator import Plenticore, PlenticoreConfigEntry _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PlenticoreConfigEntry) -> bool: """Set up Kostal Plenticore Solar Inverter from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - plenticore = Plenticore(hass, entry) if not await plenticore.async_setup(): return False - hass.data[DOMAIN][entry.entry_id] = plenticore + entry.runtime_data = plenticore await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PlenticoreConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - # remove API object - plenticore = hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): try: - await plenticore.async_unload() + await entry.runtime_data.async_unload() except ApiException as err: _LOGGER.error("Error logging out from inverter: %s", err) diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index f87f8ca630a..d312130bb54 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -30,6 +30,8 @@ from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) +type PlenticoreConfigEntry = ConfigEntry[Plenticore] + class Plenticore: """Manages the Plenticore API.""" @@ -166,12 +168,12 @@ class DataUpdateCoordinatorMixin: class PlenticoreUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Base implementation of DataUpdateCoordinator for Plenticore data.""" - config_entry: ConfigEntry + config_entry: PlenticoreConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PlenticoreConfigEntry, logger: logging.Logger, name: str, update_inverval: timedelta, @@ -248,12 +250,12 @@ class SettingDataUpdateCoordinator( class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Base implementation of DataUpdateCoordinator for Plenticore data.""" - config_entry: ConfigEntry + config_entry: PlenticoreConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PlenticoreConfigEntry, logger: logging.Logger, name: str, update_inverval: timedelta, diff --git a/homeassistant/components/kostal_plenticore/diagnostics.py b/homeassistant/components/kostal_plenticore/diagnostics.py index 3978869c524..4d4d61f56a7 100644 --- a/homeassistant/components/kostal_plenticore/diagnostics.py +++ b/homeassistant/components/kostal_plenticore/diagnostics.py @@ -5,23 +5,21 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_IDENTIFIERS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import Plenticore +from .coordinator import PlenticoreConfigEntry TO_REDACT = {CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: PlenticoreConfigEntry ) -> dict[str, dict[str, Any]]: """Return diagnostics for a config entry.""" data = {"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT)} - plenticore: Plenticore = hass.data[DOMAIN][config_entry.entry_id] + plenticore = config_entry.runtime_data # Get information from Kostal Plenticore library available_process_data = await plenticore.client.get_process_data() diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 7efb00cf8f4..ddb0a84a6cc 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -14,15 +14,13 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import SettingDataUpdateCoordinator +from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator from .helper import PlenticoreDataFormatter _LOGGER = logging.getLogger(__name__) @@ -74,11 +72,11 @@ NUMBER_SETTINGS_DATA = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlenticoreConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Kostal Plenticore Number entities.""" - plenticore = hass.data[DOMAIN][entry.entry_id] + plenticore = entry.runtime_data entities = [] diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 61929b9fadc..86ffb63966d 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -7,15 +7,13 @@ from datetime import timedelta import logging from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import Plenticore, SelectDataUpdateCoordinator +from .coordinator import PlenticoreConfigEntry, SelectDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,11 +41,11 @@ SELECT_SETTINGS_DATA = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlenticoreConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Select widget.""" - plenticore: Plenticore = hass.data[DOMAIN][entry.entry_id] + plenticore = entry.runtime_data available_settings_data = await plenticore.client.get_settings() select_data_update_coordinator = SelectDataUpdateCoordinator( diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 1be7fb06e7b..aafd6bb1ff6 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -29,8 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ProcessDataUpdateCoordinator +from .coordinator import PlenticoreConfigEntry, ProcessDataUpdateCoordinator from .helper import PlenticoreDataFormatter _LOGGER = logging.getLogger(__name__) @@ -808,11 +806,11 @@ SENSOR_PROCESS_DATA = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlenticoreConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Sensors.""" - plenticore = hass.data[DOMAIN][entry.entry_id] + plenticore = entry.runtime_data entities = [] diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index e3d5f830c78..44eced7ca4a 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -8,15 +8,13 @@ import logging from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import SettingDataUpdateCoordinator +from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,11 +47,11 @@ SWITCH_SETTINGS_DATA = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlenticoreConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Switch.""" - plenticore = hass.data[DOMAIN][entry.entry_id] + plenticore = entry.runtime_data entities = [] diff --git a/homeassistant/components/lacrosse_view/__init__.py b/homeassistant/components/lacrosse_view/__init__.py index e98d1d421be..6cb5e93acfe 100644 --- a/homeassistant/components/lacrosse_view/__init__.py +++ b/homeassistant/components/lacrosse_view/__init__.py @@ -6,20 +6,18 @@ import logging from lacrosse_view import LaCrosse, LoginError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import LaCrosseUpdateCoordinator +from .coordinator import LaCrosseConfigEntry, LaCrosseUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LaCrosseConfigEntry) -> bool: """Set up LaCrosse View from a config entry.""" api = LaCrosse(async_get_clientsession(hass)) @@ -35,9 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("First refresh") await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "coordinator": coordinator, - } + entry.runtime_data = coordinator _LOGGER.debug("Setting up platforms") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -45,9 +41,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LaCrosseConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 16d7e8b2bb8..1499dd02900 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -17,6 +17,8 @@ from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type LaCrosseConfigEntry = ConfigEntry[LaCrosseUpdateCoordinator] + class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): """DataUpdateCoordinator for LaCrosse View.""" @@ -27,12 +29,12 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): id: str hass: HomeAssistant devices: list[Sensor] | None = None - config_entry: ConfigEntry + config_entry: LaCrosseConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: LaCrosseConfigEntry, api: LaCrosse, ) -> None: """Initialize DataUpdateCoordinator for LaCrosse View.""" diff --git a/homeassistant/components/lacrosse_view/diagnostics.py b/homeassistant/components/lacrosse_view/diagnostics.py index eaf3ded6a4a..479533007c8 100644 --- a/homeassistant/components/lacrosse_view/diagnostics.py +++ b/homeassistant/components/lacrosse_view/diagnostics.py @@ -5,25 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LaCrosseUpdateCoordinator +from .coordinator import LaCrosseConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LaCrosseConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LaCrosseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - "coordinator" - ] return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "coordinator_data": coordinator.data, + "coordinator_data": entry.runtime_data.data, } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index dde8dfd54a2..d0221e22667 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -32,6 +31,7 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import DOMAIN +from .coordinator import LaCrosseConfigEntry _LOGGER = logging.getLogger(__name__) @@ -159,17 +159,14 @@ UNIT_OF_MEASUREMENT_MAP = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaCrosseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaCrosse View from a config entry.""" - coordinator: DataUpdateCoordinator[list[Sensor]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinator"] - sensors: list[Sensor] = coordinator.data + coordinator = entry.runtime_data sensor_list = [] - for i, sensor in enumerate(sensors): + for i, sensor in enumerate(coordinator.data): for field in sensor.sensor_field_names: description = SENSOR_DESCRIPTIONS.get(field) if description is None: diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 46a29427264..7fdafc4dda1 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.8"] + "requirements": ["pylamarzocco==2.0.9"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 980a08c09ae..f8cb8b1d6fe 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,6 +58,10 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), + available_fn=( + lambda coordinator: WidgetType.CM_COFFEE_BOILER + in coordinator.device.dashboard.config + ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", @@ -221,7 +225,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): entity_description: LaMarzoccoNumberEntityDescription @property - def native_value(self) -> float: + def native_value(self) -> float | int: """Return the current value.""" return self.entity_description.native_value_fn(self.coordinator.device) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 29f1c6209ec..c76f51c3488 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -57,6 +57,10 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ).ready_start_time ), entity_category=EntityCategory.DIAGNOSTIC, + available_fn=( + lambda coordinator: WidgetType.CM_COFFEE_BOILER + in coordinator.device.dashboard.config + ), ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 0656454bb01..dbf25f6680b 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -9,7 +9,13 @@ } }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "manual_entry": { "data": { diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index be5d6299f09..9e300716d3e 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.6", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.9", "lcn-frontend==0.2.5"] } diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index ef6343bdfef..33550d9785d 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -438,6 +439,7 @@ SERVICES = ( ) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for LCN.""" for service_name, service in SERVICES: diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index bd787157482..39e49348663 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import timedelta import logging from letpot.deviceclient import LetPotDeviceClient @@ -42,6 +43,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): _LOGGER, config_entry=config_entry, name=f"LetPot {device.serial_number}", + update_interval=timedelta(minutes=10), ) self._info = info self.device = device diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 9804a5ec3a4..f5e88bfc369 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -5,9 +5,9 @@ rules: comment: | This integration does not provide additional actions. appropriate-polling: - status: exempt + status: done comment: | - This integration only receives push-based updates. + Primarily uses push, but polls with a long interval for availability and missed updates. brands: done common-modules: done config-flow-test-coverage: done @@ -39,7 +39,7 @@ rules: comment: | The integration does not have configuration options. docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: todo parallel-updates: done diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py index 6cf2a9086b1..6b98b6d8f11 100644 --- a/homeassistant/components/lg_thinq/vacuum.py +++ b/homeassistant/components/lg_thinq/vacuum.py @@ -4,6 +4,7 @@ from __future__ import annotations from enum import StrEnum import logging +from typing import Any from thinqconnect import DeviceType from thinqconnect.integration import ExtendedProperty @@ -154,7 +155,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): ) ) - async def async_return_to_base(self, **kwargs) -> None: + async def async_return_to_base(self, **kwargs: Any) -> None: """Return device to dock.""" _LOGGER.debug( "[%s:%s] async_return_to_base", diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 887bc3c3527..9fae2628f1d 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any import aiolifx_effects from aiolifx_themes.painter import ThemePainter @@ -31,9 +31,12 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN -from .coordinator import LIFXUpdateCoordinator, Light +from .coordinator import LIFXUpdateCoordinator from .util import convert_8_to_16, find_hsbk +if TYPE_CHECKING: + from aiolifx.aiolifx import Light + SCAN_INTERVAL = timedelta(seconds=10) SERVICE_EFFECT_COLORLOOP = "effect_colorloop" @@ -426,8 +429,8 @@ class LIFXManager: ) -> None: """Start the firmware-based Sky effect.""" palette = kwargs.get(ATTR_PALETTE) + theme = Theme() if palette is not None: - theme = Theme() for hsbk in palette: theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index d6319c7a506..335f1acf396 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.11"], + "requirements": ["python-linkplay==0.2.12"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index f7563296711..81f987f8c1f 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "bronze", - "requirements": ["pylitterbot==2024.0.0"] + "requirements": ["pylitterbot==2024.2.0"] } diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 8534cc1bfbf..639cf5234d1 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -36,11 +36,6 @@ _LOGGER = logging.getLogger(__name__) PRODID = "-//homeassistant.io//local_calendar 1.0//EN" -# The calendar on disk is only changed when this entity is updated, so there -# is no need to poll for changes. The calendar enttiy base class will handle -# refreshing the entity state based on the start or end time of the event. -SCAN_INTERVAL = timedelta(days=1) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index e0b08313d63..3bf00f30624 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index c8e80e4f91b..134cea5293b 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index a494a37cb52..97823d404fc 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -29,6 +29,8 @@ ATTR_ACTION = "action" ATTR_FULL_ID = "full_id" ATTR_UUID = "uuid" +type LutronConfigEntry = ConfigEntry[LutronData] + @dataclass(slots=True, kw_only=True) class LutronData: @@ -44,7 +46,9 @@ class LutronData: switches: list[tuple[str, Output]] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: LutronConfigEntry +) -> bool: """Set up the Lutron integration.""" host = config_entry.data[CONF_HOST] @@ -113,6 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b "Toggle", "SingleSceneRaiseLower", "MasterRaiseLower", + "AdvancedToggle", ): # Associate an LED with a button if there is one led = next( @@ -168,7 +173,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b name="Main repeater", ) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = entry_data + config_entry.runtime_data = entry_data await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -221,6 +226,6 @@ def _async_check_device_identifiers( ) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LutronConfigEntry) -> bool: """Clean up resources and entities associated with the integration.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 5bed760e1ac..fddfdac7c8d 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any from pylutron import OccupancyGroup @@ -12,19 +11,16 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronDevice -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron binary_sensor platform. @@ -32,7 +28,7 @@ async def async_setup_entry( Adds occupancy groups from the Main Repeater associated with the config_entry as binary_sensor entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( [ LutronOccupancySensor(area_name, device, entry_data.client) diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py index 3f55a2b131b..bd1cd107e8c 100644 --- a/homeassistant/components/lutron/config_flow.py +++ b/homeassistant/components/lutron/config_flow.py @@ -9,12 +9,7 @@ from urllib.error import HTTPError from pylutron import Lutron import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -23,6 +18,7 @@ from homeassistant.helpers.selector import ( NumberSelectorMode, ) +from . import LutronConfigEntry from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -83,7 +79,7 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index e8f3ad09879..8909e49f7aa 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -13,11 +13,10 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronDevice _LOGGER = logging.getLogger(__name__) @@ -25,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron cover platform. @@ -33,7 +32,7 @@ async def async_setup_entry( Adds shades from the Main Repeater associated with the config_entry as cover entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( [ LutronCover(area_name, device, entry_data.client) diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py index 942e165b97f..d7ec85835b7 100644 --- a/homeassistant/components/lutron/event.py +++ b/homeassistant/components/lutron/event.py @@ -5,13 +5,12 @@ from enum import StrEnum from pylutron import Button, Keypad, Lutron, LutronEvent from homeassistant.components.event import EventEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify -from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, DOMAIN, LutronData +from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, LutronConfigEntry from .entity import LutronKeypad @@ -32,11 +31,11 @@ LEGACY_EVENT_TYPES: dict[LutronEventType, str] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron event platform.""" - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( LutronEventEntity(area_name, keypad, button, entry_data.client) diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py index 5928c3c2da3..cc63994cdbe 100644 --- a/homeassistant/components/lutron/fan.py +++ b/homeassistant/components/lutron/fan.py @@ -2,25 +2,21 @@ from __future__ import annotations -import logging from typing import Any from pylutron import Output from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronDevice -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron fan platform. @@ -28,7 +24,7 @@ async def async_setup_entry( Adds fan controls from the Main Repeater associated with the config_entry as fan entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( [ LutronFan(area_name, device, entry_data.client) diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index a7489e13b7b..955c4a2af90 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -19,14 +19,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL from .entity import LutronDevice async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron light platform. @@ -34,7 +34,7 @@ async def async_setup_entry( Adds dimmers from the Main Repeater associated with the config_entry as light entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index 4889f9056ac..5f3736f0882 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -7,17 +7,16 @@ from typing import Any from pylutron import Button, Keypad, Lutron from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronKeypad async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron scene platform. @@ -25,7 +24,7 @@ async def async_setup_entry( Adds scenes from the Main Repeater associated with the config_entry as scene entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( LutronScene(area_name, keypad, device, entry_data.client) diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index e1e97d1774a..addde6f95aa 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -8,17 +8,16 @@ from typing import Any from pylutron import Button, Keypad, Led, Lutron, Output from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronDevice, LutronKeypad async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron switch platform. @@ -26,7 +25,7 @@ async def async_setup_entry( Adds switches from the Main Repeater associated with the config_entry as switch entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data entities: list[SwitchEntity] = [] # Add Lutron Switches diff --git a/homeassistant/components/lyric/application_credentials.py b/homeassistant/components/lyric/application_credentials.py index 2ccdca72bb6..9c53395bb6d 100644 --- a/homeassistant/components/lyric/application_credentials.py +++ b/homeassistant/components/lyric/application_credentials.py @@ -24,3 +24,11 @@ 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", + } diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 41598dfbdd0..a934d8eda2e 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -1,15 +1,24 @@ { + "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": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Lyric integration needs to re-authenticate your account." }, "oauth_discovery": { - "description": "Home Assistant has found a Honeywell Lyric device on your network. Press **Submit** to continue setting up Honeywell Lyric." + "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." } }, "abort": { diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index ac3e70dcfc8..c71a5d07e24 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -57,6 +57,9 @@ "bat_replacement_description": { "default": "mdi:battery-sync" }, + "battery_voltage": { + "default": "mdi:current-dc" + }, "flow": { "default": "mdi:pipe" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 70e4cb238f5..9cab1a2c02f 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -345,6 +345,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="PowerSourceBatVoltage", + translation_key="battery_voltage", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 7cae16c5e9b..72e4d8c50b7 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -324,6 +324,9 @@ "battery_replacement_description": { "name": "Battery type" }, + "battery_voltage": { + "name": "Battery voltage" + }, "current_phase": { "name": "Current phase" }, diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 69a0eb8a553..65b1795023f 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -133,8 +133,6 @@ class MaxCubeClimate(ClimateEntity): self._set_target(MAX_DEVICE_MODE_MANUAL, temp) elif hvac_mode == HVACMode.AUTO: self._set_target(MAX_DEVICE_MODE_AUTOMATIC, None) - else: - raise ValueError(f"unsupported HVAC mode {hvac_mode}") def _set_target(self, mode: int | None, temp: float | None) -> None: """Set the mode and/or temperature of the thermostat. diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 2b59d4ffa51..780b4818666 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -12,10 +12,10 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", "data": { - "implementation": "Credentials" + "implementation": "[%key:common::config_flow::data::implementation%]" }, "data_description": { - "implementation": "The credentials to use for the OAuth2 flow" + "implementation": "[%key:common::config_flow::description::implementation%]" } } }, diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index bc8fdbd56c8..07284b29434 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -88,7 +88,6 @@ class ModelContextProtocolSSEView(HomeAssistantView): context = llm.LLMContext( platform=DOMAIN, context=self.context(request), - user_prompt=None, language="*", assistant=conversation.DOMAIN, device_id=None, diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index e019dae2c33..0221fd45051 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -24,7 +24,7 @@ from .coordinator import ( MealieShoppingListCoordinator, MealieStatisticsCoordinator, ) -from .services import setup_services +from .services import async_setup_services from .utils import create_version PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.TODO] @@ -34,7 +34,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Mealie component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 15e3348adbe..0d9a29392a4 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -19,6 +19,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -98,9 +99,10 @@ SERVICE_SET_MEALPLAN_SCHEMA = vol.Any( ) -def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MealieConfigEntry: +def _async_get_entry(call: ServiceCall) -> MealieConfigEntry: """Get the Mealie config entry.""" - if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + config_entry_id: str = call.data[ATTR_CONFIG_ENTRY_ID] + if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="integration_not_found", @@ -115,143 +117,149 @@ def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MealieConfigEn return cast(MealieConfigEntry, entry) -def setup_services(hass: HomeAssistant) -> None: - """Set up the services for the Mealie integration.""" +async def _async_get_mealplan(call: ServiceCall) -> ServiceResponse: + """Get the mealplan for a specific range.""" + entry = _async_get_entry(call) + start_date = call.data.get(ATTR_START_DATE, date.today()) + end_date = call.data.get(ATTR_END_DATE, date.today()) + if end_date < start_date: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="end_date_before_start_date", + ) + client = entry.runtime_data.client + try: + mealplans = await client.get_mealplans(start_date, end_date) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + return {"mealplan": [asdict(x) for x in mealplans.items]} - async def async_get_mealplan(call: ServiceCall) -> ServiceResponse: - """Get the mealplan for a specific range.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - start_date = call.data.get(ATTR_START_DATE, date.today()) - end_date = call.data.get(ATTR_END_DATE, date.today()) - if end_date < start_date: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="end_date_before_start_date", - ) - client = entry.runtime_data.client - try: - mealplans = await client.get_mealplans(start_date, end_date) - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - return {"mealplan": [asdict(x) for x in mealplans.items]} - async def async_get_recipe(call: ServiceCall) -> ServiceResponse: - """Get a recipe.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - recipe_id = call.data[ATTR_RECIPE_ID] - client = entry.runtime_data.client - try: - recipe = await client.get_recipe(recipe_id) - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - except MealieNotFoundError as err: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="recipe_not_found", - translation_placeholders={"recipe_id": recipe_id}, - ) from err +async def _async_get_recipe(call: ServiceCall) -> ServiceResponse: + """Get a recipe.""" + entry = _async_get_entry(call) + recipe_id = call.data[ATTR_RECIPE_ID] + client = entry.runtime_data.client + try: + recipe = await client.get_recipe(recipe_id) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + except MealieNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="recipe_not_found", + translation_placeholders={"recipe_id": recipe_id}, + ) from err + return {"recipe": asdict(recipe)} + + +async def _async_import_recipe(call: ServiceCall) -> ServiceResponse: + """Import a recipe.""" + entry = _async_get_entry(call) + url = call.data[ATTR_URL] + include_tags = call.data.get(ATTR_INCLUDE_TAGS, False) + client = entry.runtime_data.client + try: + recipe = await client.import_recipe(url, include_tags) + except MealieValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="could_not_import_recipe", + ) from err + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + if call.return_response: return {"recipe": asdict(recipe)} + return None - async def async_import_recipe(call: ServiceCall) -> ServiceResponse: - """Import a recipe.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - url = call.data[ATTR_URL] - include_tags = call.data.get(ATTR_INCLUDE_TAGS, False) - client = entry.runtime_data.client - try: - recipe = await client.import_recipe(url, include_tags) - except MealieValidationError as err: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="could_not_import_recipe", - ) from err - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - if call.return_response: - return {"recipe": asdict(recipe)} - return None - async def async_set_random_mealplan(call: ServiceCall) -> ServiceResponse: - """Set a random mealplan.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - mealplan_date = call.data[ATTR_DATE] - entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) - client = entry.runtime_data.client - try: - mealplan = await client.random_mealplan(mealplan_date, entry_type) - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - if call.return_response: - return {"mealplan": asdict(mealplan)} - return None +async def _async_set_random_mealplan(call: ServiceCall) -> ServiceResponse: + """Set a random mealplan.""" + entry = _async_get_entry(call) + mealplan_date = call.data[ATTR_DATE] + entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) + client = entry.runtime_data.client + try: + mealplan = await client.random_mealplan(mealplan_date, entry_type) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + if call.return_response: + return {"mealplan": asdict(mealplan)} + return None - async def async_set_mealplan(call: ServiceCall) -> ServiceResponse: - """Set a mealplan.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - mealplan_date = call.data[ATTR_DATE] - entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) - client = entry.runtime_data.client - try: - mealplan = await client.set_mealplan( - mealplan_date, - entry_type, - recipe_id=call.data.get(ATTR_RECIPE_ID), - note_title=call.data.get(ATTR_NOTE_TITLE), - note_text=call.data.get(ATTR_NOTE_TEXT), - ) - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - if call.return_response: - return {"mealplan": asdict(mealplan)} - return None + +async def _async_set_mealplan(call: ServiceCall) -> ServiceResponse: + """Set a mealplan.""" + entry = _async_get_entry(call) + mealplan_date = call.data[ATTR_DATE] + entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) + client = entry.runtime_data.client + try: + mealplan = await client.set_mealplan( + mealplan_date, + entry_type, + recipe_id=call.data.get(ATTR_RECIPE_ID), + note_title=call.data.get(ATTR_NOTE_TITLE), + note_text=call.data.get(ATTR_NOTE_TEXT), + ) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + if call.return_response: + return {"mealplan": asdict(mealplan)} + return None + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Mealie integration.""" hass.services.async_register( DOMAIN, SERVICE_GET_MEALPLAN, - async_get_mealplan, + _async_get_mealplan, schema=SERVICE_GET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_GET_RECIPE, - async_get_recipe, + _async_get_recipe, schema=SERVICE_GET_RECIPE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_IMPORT_RECIPE, - async_import_recipe, + _async_import_recipe, schema=SERVICE_IMPORT_RECIPE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_SET_RANDOM_MEALPLAN, - async_set_random_mealplan, + _async_set_random_mealplan, schema=SERVICE_SET_RANDOM_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_SET_MEALPLAN, - async_set_mealplan, + _async_set_mealplan, schema=SERVICE_SET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 50eff40c0e8..0a9fa77f902 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -1,93 +1,28 @@ """The Meater Temperature Probe integration.""" -import asyncio -from datetime import timedelta -import logging - -from meater import ( - AuthenticationError, - MeaterApi, - ServiceUnavailableError, - TooManyRequestsError, -) -from meater.MeaterApi import MeaterProbe - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +from .coordinator import MeaterConfigEntry, MeaterCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Set up Meater Temperature Probe from a config entry.""" - # Store an API object to access - session = async_get_clientsession(hass) - meater_api = MeaterApi(session) - # Add the credentials - try: - _LOGGER.debug("Authenticating with the Meater API") - await meater_api.authenticate( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] - ) - except (ServiceUnavailableError, TooManyRequestsError) as err: - raise ConfigEntryNotReady from err - except AuthenticationError as err: - raise ConfigEntryAuthFailed( - f"Unable to authenticate with the Meater API: {err}" - ) from err - - async def async_update_data() -> dict[str, MeaterProbe]: - """Fetch data from API endpoint.""" - try: - # Note: TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(10): - devices: list[MeaterProbe] = await meater_api.get_all_devices() - except AuthenticationError as err: - raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err - except TooManyRequestsError as err: - raise UpdateFailed( - "Too many requests have been made to the API, rate limiting is in place" - ) from err - - return {device.id: device for device in devices} - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - # Name of the data. For logging purposes. - name="meater_api", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=30), - ) + coordinator = MeaterCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault("known_probes", set()) + hass.data.setdefault(DOMAIN, {}).setdefault("known_probes", set()) - hass.data[DOMAIN][entry.entry_id] = { - "api": meater_api, - "coordinator": coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/meater/coordinator.py b/homeassistant/components/meater/coordinator.py new file mode 100644 index 00000000000..042a3c87b0c --- /dev/null +++ b/homeassistant/components/meater/coordinator.py @@ -0,0 +1,77 @@ +"""Meater Coordinator.""" + +import asyncio +from datetime import timedelta +import logging + +from meater.MeaterApi import ( + AuthenticationError, + MeaterApi, + MeaterProbe, + ServiceUnavailableError, + TooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +type MeaterConfigEntry = ConfigEntry[MeaterCoordinator] + + +class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): + """Meater Coordinator.""" + + config_entry: MeaterConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: MeaterConfigEntry, + ) -> None: + """Initialize the Meater Coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"Meater {entry.title}", + update_interval=timedelta(seconds=30), + ) + session = async_get_clientsession(hass) + self.client = MeaterApi(session) + + async def _async_setup(self) -> None: + """Set up the Meater Coordinator.""" + try: + _LOGGER.debug("Authenticating with the Meater API") + await self.client.authenticate( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + except (ServiceUnavailableError, TooManyRequestsError) as err: + raise UpdateFailed from err + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + f"Unable to authenticate with the Meater API: {err}" + ) from err + + async def _async_update_data(self) -> dict[str, MeaterProbe]: + """Fetch data from API endpoint.""" + try: + # Note: TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with asyncio.timeout(10): + devices: list[MeaterProbe] = await self.client.get_all_devices() + except AuthenticationError as err: + raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err + except TooManyRequestsError as err: + raise UpdateFailed( + "Too many requests have been made to the API, rate limiting is in place" + ) from err + + return {device.id: device for device in devices} diff --git a/homeassistant/components/meater/diagnostics.py b/homeassistant/components/meater/diagnostics.py new file mode 100644 index 00000000000..247457d0bc8 --- /dev/null +++ b/homeassistant/components/meater/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics support for the Meater integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import MeaterConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: MeaterConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = config_entry.runtime_data + + return { + identifier: { + "id": probe.id, + "internal_temperature": probe.internal_temperature, + "ambient_temperature": probe.ambient_temperature, + "time_updated": probe.time_updated.isoformat(), + "cook": ( + { + "id": probe.cook.id, + "name": probe.cook.name, + "state": probe.cook.state, + "target_temperature": ( + probe.cook.target_temperature + if hasattr(probe.cook, "target_temperature") + else None + ), + "peak_temperature": ( + probe.cook.peak_temperature + if hasattr(probe.cook, "peak_temperature") + else None + ), + "time_remaining": ( + probe.cook.time_remaining + if hasattr(probe.cook, "time_remaining") + else None + ), + "time_elapsed": ( + probe.cook.time_elapsed + if hasattr(probe.cook, "time_elapsed") + else None + ), + } + if probe.cook + else None + ), + } + for identifier, probe in coordinator.data.items() + } diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 00fc28b8718..61833babd47 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -14,18 +14,28 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util +from . import MeaterCoordinator from .const import DOMAIN +from .coordinator import MeaterConfigEntry + +COOK_STATES = { + "Not Started": "not_started", + "Configured": "configured", + "Started": "started", + "Ready For Resting": "ready_for_resting", + "Resting": "resting", + "Slightly Underdone": "slightly_underdone", + "Finished": "finished", + "Slightly Overdone": "slightly_overdone", + "OVERCOOK!": "overcooked", +} @dataclass(frozen=True, kw_only=True) @@ -82,13 +92,13 @@ SENSOR_TYPES = ( available=lambda probe: probe is not None and probe.cook is not None, value=lambda probe: probe.cook.name if probe.cook else None, ), - # One of Not Started, Configured, Started, Ready For Resting, Resting, - # Slightly Underdone, Finished, Slightly Overdone, OVERCOOK!. Not translated. MeaterSensorEntityDescription( key="cook_state", translation_key="cook_state", available=lambda probe: probe is not None and probe.cook is not None, - value=lambda probe: probe.cook.state if probe.cook else None, + device_class=SensorDeviceClass.ENUM, + options=list(COOK_STATES.values()), + value=lambda probe: COOK_STATES.get(probe.cook.state) if probe.cook else None, ), # Target temperature MeaterSensorEntityDescription( @@ -137,13 +147,11 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeaterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" - coordinator: DataUpdateCoordinator[dict[str, MeaterProbe]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinator"] + coordinator = entry.runtime_data @callback def async_update_data(): @@ -174,11 +182,10 @@ async def async_setup_entry( # Add a subscriber to the coordinator to discover new temperature probes coordinator.async_add_listener(async_update_data) + async_update_data() -class MeaterProbeTemperature( - SensorEntity, CoordinatorEntity[DataUpdateCoordinator[dict[str, MeaterProbe]]] -): +class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]): """Meater Temperature Sensor Entity.""" entity_description: MeaterSensorEntityDescription diff --git a/homeassistant/components/meater/strings.json b/homeassistant/components/meater/strings.json index 20dd2919026..a578f895a8c 100644 --- a/homeassistant/components/meater/strings.json +++ b/homeassistant/components/meater/strings.json @@ -40,7 +40,18 @@ "name": "Cooking" }, "cook_state": { - "name": "Cook state" + "name": "Cook state", + "state": { + "not_started": "Not started", + "configured": "Configured", + "started": "Started", + "ready_for_resting": "Ready for resting", + "resting": "Resting", + "slightly_underdone": "Slightly underdone", + "finished": "Finished", + "slightly_overdone": "Slightly overdone", + "overcooked": "Overcooked" + } }, "cook_target_temp": { "name": "Target temperature" diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 3ce80f497ef..20068efccef 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.05.22"], + "requirements": ["yt-dlp[default]==2025.06.09"], "single_config_entry": true } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 0979852ecce..d0c6bcabfcf 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -814,19 +814,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag media player features that are supported.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> MediaPlayerEntityFeature: - """Return the supported features as MediaPlayerEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: - new_features = MediaPlayerEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError @@ -966,87 +953,85 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features_compat + return MediaPlayerEntityFeature.PLAY in self.supported_features @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat + return MediaPlayerEntityFeature.PAUSE in self.supported_features @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features_compat + return MediaPlayerEntityFeature.STOP in self.supported_features @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features_compat + return MediaPlayerEntityFeature.SEEK in self.supported_features @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat - ) + return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat + return MediaPlayerEntityFeature.GROUPING in self.supported_features async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1074,7 +1059,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1092,7 +1077,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1135,7 +1120,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features if ( source_list := self.source_list @@ -1364,7 +1349,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" @@ -1447,7 +1432,7 @@ async def websocket_search_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features_compat: + if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media" diff --git a/homeassistant/components/microbees/strings.json b/homeassistant/components/microbees/strings.json index 8635753a564..5337bf149b7 100644 --- a/homeassistant/components/microbees/strings.json +++ b/homeassistant/components/microbees/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "error": { diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index cf01d01e476..94aef8d6d3f 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -8,7 +8,13 @@ "description": "[%key:common::config_flow::description::confirm_setup%]" }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index d8f60380a6c..e74b78446e5 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging from typing import Any +import dns.asyncresolver import dns.rdata import dns.rdataclass import dns.rdatatype @@ -22,20 +23,23 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -def load_dnspython_rdata_classes() -> None: - """Load dnspython rdata classes used by mcstatus.""" +def prevent_dnspython_blocking_operations() -> None: + """Prevent dnspython blocking operations by pre-loading required data.""" + + # Blocking import: https://github.com/rthalley/dnspython/issues/1083 for rdtype in dns.rdatatype.RdataType: if not dns.rdatatype.is_metatype(rdtype) or rdtype == dns.rdatatype.OPT: dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call] + # Blocking open: https://github.com/rthalley/dnspython/issues/1200 + dns.asyncresolver.get_default_resolver() + async def async_setup_entry( hass: HomeAssistant, entry: MinecraftServerConfigEntry ) -> bool: """Set up Minecraft Server from a config entry.""" - - # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) - await hass.async_add_executor_job(load_dnspython_rdata_classes) + await hass.async_add_executor_job(prevent_dnspython_blocking_operations) # Create coordinator instance and store it. coordinator = MinecraftServerCoordinator(hass, entry) diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json index e4ec34a8459..fa916021138 100644 --- a/homeassistant/components/monzo/strings.json +++ b/homeassistant/components/monzo/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 165c4c19675..9cff2956a5f 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -62,6 +62,7 @@ TILT_DEVICE_MAP = { BlindType.VerticalBlind: CoverDeviceClass.BLIND, BlindType.VerticalBlindLeft: CoverDeviceClass.BLIND, BlindType.VerticalBlindRight: CoverDeviceClass.BLIND, + BlindType.RollerTiltMotor: CoverDeviceClass.BLIND, } TILT_ONLY_DEVICE_MAP = { diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 1a6c9c5f82f..a82da20396f 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.27"] + "requirements": ["motionblinds==0.6.28"] } diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ae010bf18c9..9e3dc59f852 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -354,8 +354,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def write_dump() -> None: with open(hass.config.path("mqtt_dump.txt"), "w", encoding="utf8") as fp: - for msg in messages: - fp.write(",".join(msg) + "\n") + fp.writelines([",".join(msg) + "\n" for msg in messages]) async def finish_dump(_: datetime) -> None: """Write dump to file.""" @@ -608,8 +607,7 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove MQTT config entry from a device.""" - # pylint: disable-next=import-outside-toplevel - from . import device_automation + from . import device_automation # noqa: PLC0415 await device_automation.async_removed_from_device(hass, device_entry.id) return True diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index c2bcb306d0b..5d2b422a909 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -293,10 +293,9 @@ class MqttClientSetup: """ # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel + from paho.mqtt import client as mqtt # noqa: PLC0415 - # pylint: disable-next=import-outside-toplevel - from .async_client import AsyncMQTTClient + from .async_client import AsyncMQTTClient # noqa: PLC0415 config = self._config clean_session: bool | None = None @@ -524,8 +523,7 @@ class MQTT: """Start the misc periodic.""" assert self._misc_timer is None, "Misc periodic already started" _LOGGER.debug("%s: Starting client misc loop", self.config_entry.title) - # pylint: disable=import-outside-toplevel - import paho.mqtt.client as mqtt + import paho.mqtt.client as mqtt # noqa: PLC0415 # Inner function to avoid having to check late import # each time the function is called. @@ -665,8 +663,7 @@ class MQTT: async def async_connect(self, client_available: asyncio.Future[bool]) -> None: """Connect to the host. Does not process messages yet.""" - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt + import paho.mqtt.client as mqtt # noqa: PLC0415 result: int | None = None self._available_future = client_available @@ -724,8 +721,7 @@ class MQTT: async def _reconnect_loop(self) -> None: """Reconnect to the MQTT server.""" - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt + import paho.mqtt.client as mqtt # noqa: PLC0415 while True: if not self.connected: @@ -1228,7 +1224,7 @@ class MQTT: """Handle a callback exception.""" # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt # noqa: PLC0415 _LOGGER.warning( "Error returned from MQTT server: %s", @@ -1273,8 +1269,7 @@ class MQTT: ) -> None: """Wait for ACK from broker or raise on error.""" if result_code != 0: - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt + import paho.mqtt.client as mqtt # noqa: PLC0415 raise HomeAssistantError( translation_domain=DOMAIN, @@ -1322,8 +1317,7 @@ class MQTT: def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: - # pylint: disable-next=import-outside-toplevel - from paho.mqtt.matcher import MQTTMatcher + from paho.mqtt.matcher import MQTTMatcher # noqa: PLC0415 matcher = MQTTMatcher() # type: ignore[no-untyped-call] matcher[subscription] = True diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index b41e549093d..ca15a899c01 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -3493,7 +3493,7 @@ def try_connection( """Test if we can connect to an MQTT broker.""" # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt # noqa: PLC0415 mqtt_client_setup = MqttClientSetup(user_input) mqtt_client_setup.setup() diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 1202f04ed42..b62d42a80d0 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -640,8 +640,7 @@ async def cleanup_device_registry( entities, triggers or tags. """ # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import device_trigger, tag + from . import device_trigger, tag # noqa: PLC0415 device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 46d475fcee8..783a0b30b14 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -35,7 +35,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util import dt as dt_util @@ -48,7 +47,6 @@ from .const import ( CONF_OPTIONS, CONF_STATE_TOPIC, CONF_SUGGESTED_DISPLAY_PRECISION, - DOMAIN, PAYLOAD_NONE, ) from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper @@ -138,12 +136,9 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT device_class in DEVICE_CLASS_UNITS and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] ): - _LOGGER.warning( - "The unit of measurement `%s` is not valid " - "together with device class `%s`. " - "this will stop working in HA Core 2025.7.0", - unit_of_measurement, - device_class, + raise vol.Invalid( + f"The unit of measurement `{unit_of_measurement}` is not valid " + f"together with device class `{device_class}`", ) return config @@ -194,40 +189,8 @@ class MqttSensor(MqttEntity, RestoreSensor): None ) - @callback - def async_check_uom(self) -> None: - """Check if the unit of measurement is valid with the device class.""" - if ( - self._discovery_data is not None - or self.device_class is None - or self.native_unit_of_measurement is None - ): - return - if ( - self.device_class in DEVICE_CLASS_UNITS - and self.native_unit_of_measurement - not in DEVICE_CLASS_UNITS[self.device_class] - ): - async_create_issue( - self.hass, - DOMAIN, - self.entity_id, - issue_domain=sensor.DOMAIN, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM, - translation_placeholders={ - "uom": self.native_unit_of_measurement, - "device_class": self.device_class.value, - "entity_id": self.entity_id, - }, - translation_key="invalid_unit_of_measurement", - breaks_in_ha_version="2025.7.0", - ) - async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" - self.async_check_uom() last_state: State | None last_sensor_data: SensorExtraStoredData | None if ( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9bc6df1b633..16652c498f3 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,10 +3,6 @@ "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." - }, - "invalid_unit_of_measurement": { - "title": "Sensor with invalid unit of measurement", - "description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index e3996c80a8a..1bf743d3da7 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -163,16 +163,14 @@ async def async_forward_entry_setup_and_setup_discovery( tasks: list[asyncio.Task] = [] if "device_automation" in new_platforms: # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import device_automation + from . import device_automation # noqa: PLC0415 tasks.append( create_eager_task(device_automation.async_setup_entry(hass, config_entry)) ) if "tag" in new_platforms: # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import tag + from . import tag # noqa: PLC0415 tasks.append(create_eager_task(tag.async_setup_entry(hass, config_entry))) if new_entity_platforms := (new_platforms - {"tag", "device_automation"}): diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index b272a610516..a4b802f001c 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mysensors", "iot_class": "local_push", "loggers": ["mysensors"], - "requirements": ["pymysensors==0.24.0"] + "requirements": ["pymysensors==0.25.0"] } diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 939aa2f17c8..d599836b8ef 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index 0324fdb8fad..c16b7bc1903 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::description::confirm_setup%]" diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index 3d97e3290e0..79227e8564b 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["nessclient"], "quality_scale": "legacy", - "requirements": ["nessclient==1.1.2"] + "requirements": ["nessclient==1.2.0"] } diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index f5eff664f83..25f39704393 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -267,8 +267,6 @@ class ThermostatEntity(ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - if hvac_mode not in self.hvac_modes: - raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] trait = self._device.traits[ThermostatModeTrait.NAME] try: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 5146d04af0b..1fc3de9be6b 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -23,7 +23,13 @@ } }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "pubsub_topic": { "title": "Configure Cloud Pub/Sub topic", diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 580b49ea646..f47b9e993aa 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 47a39a39be0..a6df67a7c83 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -96,7 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - await async_setup_services(hass, modem) + async_setup_services(hass) await discovery.async_load_platform( hass, diff --git a/homeassistant/components/netgear_lte/services.py b/homeassistant/components/netgear_lte/services.py index 77ed1b91f31..5cac48c2634 100644 --- a/homeassistant/components/netgear_lte/services.py +++ b/homeassistant/components/netgear_lte/services.py @@ -1,9 +1,9 @@ """Services for the Netgear LTE integration.""" -from eternalegypt.eternalegypt import Modem import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( @@ -16,6 +16,7 @@ from .const import ( FAILOVER_MODES, LOGGER, ) +from .coordinator import NetgearLTEConfigEntry SERVICE_DELETE_SMS = "delete_sms" SERVICE_SET_OPTION = "set_option" @@ -45,30 +46,37 @@ CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) -async def async_setup_services(hass: HomeAssistant, modem: Modem) -> None: +async def _service_handler(call: ServiceCall) -> None: + """Apply a service.""" + host = call.data.get(ATTR_HOST) + + entry: NetgearLTEConfigEntry | None = None + for entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + if entry.data.get(CONF_HOST) == host: + break + + if not entry or not (modem := entry.runtime_data.modem).token: + LOGGER.error("%s: host %s unavailable", call.service, host) + return + + if call.service == SERVICE_DELETE_SMS: + for sms_id in call.data[ATTR_SMS_ID]: + await modem.delete_sms(sms_id) + elif call.service == SERVICE_SET_OPTION: + if failover := call.data.get(ATTR_FAILOVER): + await modem.set_failover_mode(failover) + if autoconnect := call.data.get(ATTR_AUTOCONNECT): + await modem.set_autoconnect_mode(autoconnect) + elif call.service == SERVICE_CONNECT_LTE: + await modem.connect_lte() + elif call.service == SERVICE_DISCONNECT_LTE: + await modem.disconnect_lte() + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Netgear LTE integration.""" - async def service_handler(call: ServiceCall) -> None: - """Apply a service.""" - host = call.data.get(ATTR_HOST) - - if not modem.token: - LOGGER.error("%s: host %s unavailable", call.service, host) - return - - if call.service == SERVICE_DELETE_SMS: - for sms_id in call.data[ATTR_SMS_ID]: - await modem.delete_sms(sms_id) - elif call.service == SERVICE_SET_OPTION: - if failover := call.data.get(ATTR_FAILOVER): - await modem.set_failover_mode(failover) - if autoconnect := call.data.get(ATTR_AUTOCONNECT): - await modem.set_autoconnect_mode(autoconnect) - elif call.service == SERVICE_CONNECT_LTE: - await modem.connect_lte() - elif call.service == SERVICE_DISCONNECT_LTE: - await modem.disconnect_lte() - service_schemas = { SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, SERVICE_SET_OPTION: SET_OPTION_SCHEMA, @@ -77,4 +85,4 @@ async def async_setup_services(hass: HomeAssistant, modem: Modem) -> None: } for service, schema in service_schemas.items(): - hass.services.async_register(DOMAIN, service, service_handler, schema=schema) + hass.services.async_register(DOMAIN, service, _service_handler, schema=schema) diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 14c7dc55cf0..dd5344faa56 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -175,9 +175,7 @@ async def async_get_announce_addresses(hass: HomeAssistant) -> list[str]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" # Avoid circular issue: http->network->websocket_api->http - from .websocket import ( # pylint: disable=import-outside-toplevel - async_register_websocket_commands, - ) + from .websocket import async_register_websocket_commands # noqa: PLC0415 await async_get_network(hass) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 4b7057f7142..c1da33f2555 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==2.2.0"] + "requirements": ["py-nextbusnext==2.3.0"] } diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index ed244146efc..5107fcd00d6 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -13,14 +13,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry -from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -61,30 +60,14 @@ async def async_setup_entry( ) -class NextDnsBinarySensor( - CoordinatorEntity[NextDnsUpdateCoordinator[ConnectionStatus]], BinarySensorEntity -): +class NextDnsBinarySensor(NextDnsEntity, BinarySensorEntity): """Define an NextDNS binary sensor.""" - _attr_has_entity_name = True entity_description: NextDnsBinarySensorEntityDescription - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[ConnectionStatus], - description: NextDnsBinarySensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self._attr_is_on = description.state(coordinator.data, coordinator.profile_id) - self.entity_description = description - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._attr_is_on = self.entity_description.state( + @property + def is_on(self) -> bool: + """Return True if the binary sensor is on.""" + return self.entity_description.state( self.coordinator.data, self.coordinator.profile_id ) - self.async_write_ha_state() diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index 2adccaa304f..5c78d794120 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -4,21 +4,21 @@ from __future__ import annotations from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError +from nextdns import ApiError, InvalidApiKeyError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import DOMAIN -from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity PARALLEL_UPDATES = 1 + CLEAR_LOGS_BUTTON = ButtonEntityDescription( key="clear_logs", translation_key="clear_logs", @@ -37,24 +37,9 @@ async def async_setup_entry( async_add_entities([NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)]) -class NextDnsButton( - CoordinatorEntity[NextDnsUpdateCoordinator[AnalyticsStatus]], ButtonEntity -): +class NextDnsButton(NextDnsEntity, ButtonEntity): """Define an NextDNS button.""" - _attr_has_entity_name = True - - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[AnalyticsStatus], - description: ButtonEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self.entity_description = description - async def async_press(self) -> None: """Trigger cleaning logs.""" try: diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 41f6ff43a2a..9b82e82ffe0 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -24,7 +24,6 @@ from tenacity import RetryError from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: @@ -34,10 +33,10 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -CoordinatorDataT = TypeVar("CoordinatorDataT", bound=NextDnsData) - -class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): +class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( + DataUpdateCoordinator[CoordinatorDataT] +): """Class to manage fetching NextDNS data API.""" config_entry: NextDnsConfigEntry @@ -53,14 +52,6 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): """Initialize.""" self.nextdns = nextdns self.profile_id = profile_id - self.profile_name = nextdns.get_profile_name(profile_id) - self.device_info = DeviceInfo( - configuration_url=f"https://my.nextdns.io/{profile_id}/setup", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(profile_id))}, - manufacturer="NextDNS Inc.", - name=self.profile_name, - ) super().__init__( hass, diff --git a/homeassistant/components/nextdns/entity.py b/homeassistant/components/nextdns/entity.py new file mode 100644 index 00000000000..7e86d1d246c --- /dev/null +++ b/homeassistant/components/nextdns/entity.py @@ -0,0 +1,35 @@ +"""Define NextDNS entities.""" + +from nextdns.model import NextDnsData + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NextDnsUpdateCoordinator + + +class NextDnsEntity[CoordinatorDataT: NextDnsData]( + CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]] +): + """Define NextDNS entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NextDnsUpdateCoordinator[CoordinatorDataT], + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + configuration_url=f"https://my.nextdns.io/{coordinator.profile_id}/setup", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(coordinator.profile_id))}, + manufacturer="NextDNS Inc.", + name=coordinator.nextdns.get_profile_name(coordinator.profile_id), + ) + self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" + self.entity_description = description diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 0a4a8eaad8f..1b43f7c9c25 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic from nextdns import ( AnalyticsDnssec, @@ -13,6 +12,7 @@ from nextdns import ( AnalyticsProtocols, AnalyticsStatus, ) +from nextdns.model import NextDnsData from homeassistant.components.sensor import ( SensorEntity, @@ -20,10 +20,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import ( @@ -33,14 +32,14 @@ from .const import ( ATTR_PROTOCOLS, ATTR_STATUS, ) -from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator +from .entity import NextDnsEntity -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class NextDnsSensorEntityDescription( - SensorEntityDescription, Generic[CoordinatorDataT] +class NextDnsSensorEntityDescription[CoordinatorDataT: NextDnsData]( + SensorEntityDescription ): """NextDNS sensor entity description.""" @@ -297,27 +296,14 @@ async def async_setup_entry( ) -class NextDnsSensor( - CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]], SensorEntity +class NextDnsSensor[CoordinatorDataT: NextDnsData]( + NextDnsEntity[CoordinatorDataT], SensorEntity ): """Define an NextDNS sensor.""" - _attr_has_entity_name = True + entity_description: NextDnsSensorEntityDescription[CoordinatorDataT] - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[CoordinatorDataT], - description: NextDnsSensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self._attr_native_value = description.value(coordinator.data) - self.entity_description: NextDnsSensorEntityDescription = description - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._attr_native_value = self.entity_description.value(self.coordinator.data) - self.async_write_ha_state() + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 38944a0711e..8d7bd6a215f 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -4,16 +4,25 @@ "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The API key for your NextDNS account" } }, "profiles": { "data": { - "profile": "Profile" + "profile_name": "Profile" + }, + "data_description": { + "profile_name": "The NextDNS configuration profile you want to integrate" } }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]" } } }, diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 8bdca76b955..872f7430b3d 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -15,11 +15,11 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity PARALLEL_UPDATES = 1 @@ -536,12 +536,9 @@ async def async_setup_entry( ) -class NextDnsSwitch( - CoordinatorEntity[NextDnsUpdateCoordinator[Settings]], SwitchEntity -): +class NextDnsSwitch(NextDnsEntity, SwitchEntity): """Define an NextDNS switch.""" - _attr_has_entity_name = True entity_description: NextDnsSwitchEntityDescription def __init__( @@ -550,11 +547,8 @@ class NextDnsSwitch( description: NextDnsSwitchEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" + super().__init__(coordinator, description) self._attr_is_on = description.state(coordinator.data) - self.entity_description = description @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index b02d6711e74..e074f7ad000 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -11,15 +10,14 @@ from .const import ( CONF_AREA_FILTER, CONF_FILTER_CORONA, CONF_HEADLINE_FILTER, - DOMAIN, NO_MATCH_REGEX, ) -from .coordinator import NINADataUpdateCoordinator +from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator PLATFORMS: list[str] = [Platform.BINARY_SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" if CONF_HEADLINE_FILTER not in entry.data: filter_regex = NO_MATCH_REGEX @@ -41,18 +39,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: NinaConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 3f7d496aca9..be7e5995fbc 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -30,17 +30,17 @@ from .const import ( CONF_REGIONS, DOMAIN, ) -from .coordinator import NINADataUpdateCoordinator +from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NinaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entries.""" - coordinator: NINADataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data regions: dict[str, str] = config_entry.data[CONF_REGIONS] message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS] diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index 3c27729ef09..eb1ad3d6293 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -23,6 +23,8 @@ from .const import ( SCAN_INTERVAL, ) +type NinaConfigEntry = ConfigEntry[NINADataUpdateCoordinator] + @dataclass class NinaWarningData: diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index 628962811e3..9bb97d0737b 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -22,6 +22,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -66,6 +67,7 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry: return entry +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Nord Pool integration.""" diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 46538aad921..f5703022e12 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -282,8 +282,7 @@ class BaseNotificationService: for name, target in self.targets.items(): target_name = slugify(f"{self._target_service_name_prefix}_{name}") - if target_name in stale_targets: - stale_targets.remove(target_name) + stale_targets.discard(target_name) if ( target_name in self.registered_targets and target == self.registered_targets[target_name] diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py index 04a6730aa73..ed8d56820c2 100644 --- a/homeassistant/components/ntfy/config_flow.py +++ b/homeassistant/components/ntfy/config_flow.py @@ -90,6 +90,24 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema( } ) +STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema( + { + vol.Exclusive(CONF_USERNAME, ATTR_CREDENTIALS): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD, default=""): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + vol.Exclusive(CONF_TOKEN, ATTR_CREDENTIALS): str, + } +) + STEP_USER_TOPIC_SCHEMA = vol.Schema( { vol.Required(CONF_TOPIC): str, @@ -244,6 +262,103 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for ntfy.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + if user_input is not None: + session = async_get_clientsession(self.hass) + if token := user_input.get(CONF_TOKEN): + ntfy = Ntfy( + entry.data[CONF_URL], + session, + token=user_input[CONF_TOKEN], + ) + else: + ntfy = Ntfy( + entry.data[CONF_URL], + session, + username=user_input.get(CONF_USERNAME, entry.data[CONF_USERNAME]), + password=user_input[CONF_PASSWORD], + ) + + try: + account = await ntfy.account() + if not token: + token = (await ntfy.generate_token("Home Assistant")).token + except NtfyUnauthorizedAuthenticationError: + errors["base"] = "invalid_auth" + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + errors["base"] = "cannot_connect" + except NtfyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if entry.data[CONF_USERNAME]: + if entry.data[CONF_USERNAME] != account.username: + return self.async_abort( + reason="account_mismatch", + description_placeholders={ + CONF_USERNAME: entry.data[CONF_USERNAME], + "wrong_username": account.username, + }, + ) + + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_TOKEN: token}, + ) + self._async_abort_entries_match( + { + CONF_URL: entry.data[CONF_URL], + CONF_USERNAME: account.username, + } + ) + return self.async_update_reload_and_abort( + entry, + data_updates={ + CONF_USERNAME: account.username, + CONF_TOKEN: token, + }, + ) + if entry.data[CONF_USERNAME]: + return self.async_show_form( + step_id="reconfigure_user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, + suggested_values=user_input, + ), + errors=errors, + description_placeholders={ + CONF_NAME: entry.title, + CONF_USERNAME: entry.data[CONF_USERNAME], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_RECONFIGURE_DATA_SCHEMA, + suggested_values=user_input, + ), + errors=errors, + description_placeholders={CONF_NAME: entry.title}, + ) + + async def async_step_reconfigure_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for authenticated ntfy entry.""" + + return await self.async_step_reconfigure(user_input) + class TopicSubentryFlowHandler(ConfigSubentryFlow): """Handle subentry flow for adding and modifying a topic.""" diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml index 0d075f0014b..43a96135baf 100644 --- a/homeassistant/components/ntfy/quality_scale.yaml +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -72,7 +72,7 @@ rules: comment: the notify entity uses the device name as entity name, no translation required exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: the integration has no repairs diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 13704d960be..cef662d6f2f 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -39,7 +39,33 @@ }, "data_description": { "password": "Enter the password corresponding to the aforementioned username to automatically create an access token", - "token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and click create access token" + "token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'" + } + }, + "reconfigure": { + "title": "Configuration for {name}", + "description": "You can either log in with your **ntfy** username and password, and Home Assistant will automatically create an access token to authenticate with **ntfy**, or you can provide an access token directly", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "[%key:component::ntfy::config::step::user::sections::auth::data_description::username%]", + "password": "[%key:component::ntfy::config::step::user::sections::auth::data_description::password%]", + "token": "Enter a new or existing access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'" + } + }, + "reconfigure_user": { + "title": "[%key:component::ntfy::config::step::reconfigure::title%]", + "description": "Enter the password for **{username}** below. Home Assistant will automatically create a new access token to authenticate with **ntfy**. You can also directly provide a valid access token", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "password": "[%key:component::ntfy::config::step::reauth_confirm::data_description::password%]", + "token": "[%key:component::ntfy::config::step::reconfigure::data_description::token%]" } } }, @@ -51,7 +77,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**" + "account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "config_subentries": { diff --git a/homeassistant/components/nzbget/services.py b/homeassistant/components/nzbget/services.py index 1072000cfea..ebcdd362b0c 100644 --- a/homeassistant/components/nzbget/services.py +++ b/homeassistant/components/nzbget/services.py @@ -2,7 +2,7 @@ import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -48,6 +48,7 @@ def set_speed(call: ServiceCall) -> None: _get_coordinator(call).nzbget.rate(call.data[ATTR_SPEED]) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register integration-level services.""" diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index 8ed29aa373d..bebfe718095 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -11,6 +11,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import selector @@ -70,6 +71,7 @@ def __get_client(call: ServiceCall) -> OhmeApiClient: return entry.runtime_data.charge_session_coordinator.client +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services.""" diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 928d5565081..1717d0b24b2 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -218,19 +218,41 @@ class OllamaConversationEntity( """Call the API.""" settings = {**self.entry.data, **self.entry.options} - client = self.hass.data[DOMAIN][self.entry.entry_id] - model = settings[CONF_MODEL] - try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), settings.get(CONF_LLM_HASS_API), settings.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() + await self._async_handle_chat_log(chat_log) + + # Create intent response + intent_response = intent.IntentResponse(language=user_input.language) + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise TypeError( + f"Unexpected last message type: {type(chat_log.content[-1])}" + ) + intent_response.async_set_speech(chat_log.content[-1].content or "") + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + settings = {**self.entry.data, **self.entry.options} + + client = self.hass.data[DOMAIN][self.entry.entry_id] + model = settings[CONF_MODEL] + tools: list[dict[str, Any]] | None = None if chat_log.llm_api: tools = [ @@ -269,7 +291,7 @@ class OllamaConversationEntity( [ _convert_content(content) async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(response_generator) + self.entity_id, _transform_stream(response_generator) ) ] ) @@ -277,19 +299,6 @@ class OllamaConversationEntity( if not chat_log.unresponded_tool_results: break - # Create intent response - intent_response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - raise TypeError( - f"Unexpected last message type: {type(chat_log.content[-1])}" - ) - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: """Trims excess messages from a single history. @@ -313,8 +322,9 @@ class OllamaConversationEntity( num_keep = 2 * max_messages + 1 drop_index = len(message_history.messages) - num_keep message_history.messages = [ - message_history.messages[0] - ] + message_history.messages[drop_index:] + message_history.messages[0], + *message_history.messages[drop_index:], + ] async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a42577b9f34..a897d04562f 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -218,8 +218,7 @@ class UserOnboardingView(_BaseOnboardingStepView): # Return authorization code for fetching tokens and connect # during onboarding. - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.auth import create_auth_code + from homeassistant.components.auth import create_auth_code # noqa: PLC0415 auth_code = create_auth_code(hass, data["client_id"], credentials) return self.json({"auth_code": auth_code}) @@ -309,8 +308,7 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): ) # Return authorization code so we can redirect user and log them in - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.auth import create_auth_code + from homeassistant.components.auth import create_auth_code # noqa: PLC0415 auth_code = create_auth_code( hass, data["client_id"], refresh_token.credential diff --git a/homeassistant/components/ondilo_ico/strings.json b/homeassistant/components/ondilo_ico/strings.json index 360c0b124a7..3a5e7445a0c 100644 --- a/homeassistant/components/ondilo_ico/strings.json +++ b/homeassistant/components/ondilo_ico/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 3eb7d762712..07a8dbd203b 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -66,6 +66,7 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): translation_domain=DOMAIN, translation_key="authentication_failed" ) from err except OneDriveException as err: + _LOGGER.debug("Failed to fetch drive data: %s", err, exc_info=True) raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py index f29133a4ca4..971a4da1f6b 100644 --- a/homeassistant/components/onedrive/services.py +++ b/homeassistant/components/onedrive/services.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -70,6 +71,7 @@ def _read_file_contents( return results +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register OneDrive services.""" diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index b8fa7f8189d..8c01ad85d4a 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/onkyo/services.py b/homeassistant/components/onkyo/services.py index e602c5a24e0..26a22523a0e 100644 --- a/homeassistant/components/onkyo/services.py +++ b/homeassistant/components/onkyo/services.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey @@ -40,6 +40,7 @@ ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register Onkyo services.""" diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 6d3f461981c..60d81bf6745 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -2,10 +2,8 @@ from __future__ import annotations -from collections.abc import Mapping import json import logging -from types import MappingProxyType from typing import Any import openai @@ -77,7 +75,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( RECOMMENDED_OPTIONS = { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, } @@ -142,55 +140,193 @@ class OpenAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.last_rendered_recommended = config_entry.options.get( - CONF_RECOMMENDED, False - ) + self.options = config_entry.options.copy() async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Manage the options.""" - options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options - errors: dict[str, str] = {} + """Manage initial options.""" + options = self.options + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( + suggested_llm_apis, str + ): + options[CONF_LLM_HASS_API] = [suggested_llm_apis] + + step_schema: VolDictType = { + vol.Optional( + CONF_PROMPT, + description={"suggested_value": llm.DEFAULT_INSTRUCTIONS_PROMPT}, + ): TemplateSelector(), + vol.Optional(CONF_LLM_HASS_API): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } if user_input is not None: - if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if not user_input.get(CONF_LLM_HASS_API): - user_input.pop(CONF_LLM_HASS_API, None) - if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: - errors[CONF_CHAT_MODEL] = "model_not_supported" + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) - if user_input.get(CONF_WEB_SEARCH): - if ( - user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - not in WEB_SEARCH_MODELS - ): - errors[CONF_WEB_SEARCH] = "web_search_not_supported" - elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION): - user_input.update(await self.get_location_data()) + if user_input[CONF_RECOMMENDED]: + return self.async_create_entry(title="", data=user_input) - if not errors: - return self.async_create_entry(title="", data=user_input) - else: - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + options.update(user_input) + if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input: + options.pop(CONF_LLM_HASS_API) + return await self.async_step_advanced() - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ), - CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), - } - - schema = openai_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", - data_schema=vol.Schema(schema), + data_schema=self.add_suggested_values_to_schema( + vol.Schema(step_schema), options + ), + ) + + async def async_step_advanced( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage advanced options.""" + options = self.options + errors: dict[str, str] = {} + + step_schema: VolDictType = { + vol.Optional( + CONF_CHAT_MODEL, + default=RECOMMENDED_CHAT_MODEL, + ): str, + vol.Optional( + CONF_MAX_TOKENS, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + } + + if user_input is not None: + options.update(user_input) + if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: + errors[CONF_CHAT_MODEL] = "model_not_supported" + + if not errors: + return await self.async_step_model() + + return self.async_show_form( + step_id="advanced", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(step_schema), options + ), errors=errors, ) - async def get_location_data(self) -> dict[str, str]: + async def async_step_model( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage model-specific options.""" + options = self.options + errors: dict[str, str] = {} + + step_schema: VolDictType = {} + + model = options[CONF_CHAT_MODEL] + + if model.startswith("o"): + step_schema.update( + { + vol.Optional( + CONF_REASONING_EFFORT, + default=RECOMMENDED_REASONING_EFFORT, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_REASONING_EFFORT, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + elif CONF_REASONING_EFFORT in options: + options.pop(CONF_REASONING_EFFORT) + + if model.startswith(tuple(WEB_SEARCH_MODELS)): + step_schema.update( + { + vol.Optional( + CONF_WEB_SEARCH, + default=RECOMMENDED_WEB_SEARCH, + ): bool, + vol.Optional( + CONF_WEB_SEARCH_CONTEXT_SIZE, + default=RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_WEB_SEARCH_CONTEXT_SIZE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional( + CONF_WEB_SEARCH_USER_LOCATION, + default=RECOMMENDED_WEB_SEARCH_USER_LOCATION, + ): bool, + } + ) + elif CONF_WEB_SEARCH in options: + options = { + k: v + for k, v in options.items() + if k + not in ( + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_USER_LOCATION, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_TIMEZONE, + ) + } + + if not step_schema: + return self.async_create_entry(title="", data=options) + + if user_input is not None: + if user_input.get(CONF_WEB_SEARCH): + if user_input.get(CONF_WEB_SEARCH_USER_LOCATION): + user_input.update(await self._get_location_data()) + else: + options.pop(CONF_WEB_SEARCH_CITY, None) + options.pop(CONF_WEB_SEARCH_REGION, None) + options.pop(CONF_WEB_SEARCH_COUNTRY, None) + options.pop(CONF_WEB_SEARCH_TIMEZONE, None) + + options.update(user_input) + return self.async_create_entry(title="", data=options) + + return self.async_show_form( + step_id="model", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(step_schema), options + ), + errors=errors, + ) + + async def _get_location_data(self) -> dict[str, str]: """Get approximate location data of the user.""" location_data: dict[str, str] = {} zone_home = self.hass.states.get(ENTITY_ID_HOME) @@ -242,103 +378,3 @@ class OpenAIOptionsFlow(OptionsFlow): _LOGGER.debug("Location data: %s", location_data) return location_data - - -def openai_config_option_schema( - hass: HomeAssistant, - options: Mapping[str, Any], -) -> VolDictType: - """Return a schema for OpenAI completion options.""" - hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label=api.name, - value=api.id, - ) - for api in llm.async_get_apis(hass) - ] - if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( - suggested_llm_apis, str - ): - suggested_llm_apis = [suggested_llm_apis] - schema: VolDictType = { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": suggested_llm_apis}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } - - if options.get(CONF_RECOMMENDED): - return schema - - schema.update( - { - vol.Optional( - CONF_CHAT_MODEL, - description={"suggested_value": options.get(CONF_CHAT_MODEL)}, - default=RECOMMENDED_CHAT_MODEL, - ): str, - vol.Optional( - CONF_MAX_TOKENS, - description={"suggested_value": options.get(CONF_MAX_TOKENS)}, - default=RECOMMENDED_MAX_TOKENS, - ): int, - vol.Optional( - CONF_TOP_P, - description={"suggested_value": options.get(CONF_TOP_P)}, - default=RECOMMENDED_TOP_P, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), - vol.Optional( - CONF_TEMPERATURE, - description={"suggested_value": options.get(CONF_TEMPERATURE)}, - default=RECOMMENDED_TEMPERATURE, - ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), - vol.Optional( - CONF_REASONING_EFFORT, - description={"suggested_value": options.get(CONF_REASONING_EFFORT)}, - default=RECOMMENDED_REASONING_EFFORT, - ): SelectSelector( - SelectSelectorConfig( - options=["low", "medium", "high"], - translation_key=CONF_REASONING_EFFORT, - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional( - CONF_WEB_SEARCH, - description={"suggested_value": options.get(CONF_WEB_SEARCH)}, - default=RECOMMENDED_WEB_SEARCH, - ): bool, - vol.Optional( - CONF_WEB_SEARCH_CONTEXT_SIZE, - description={ - "suggested_value": options.get(CONF_WEB_SEARCH_CONTEXT_SIZE) - }, - default=RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, - ): SelectSelector( - SelectSelectorConfig( - options=["low", "medium", "high"], - translation_key=CONF_WEB_SEARCH_CONTEXT_SIZE, - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional( - CONF_WEB_SEARCH_USER_LOCATION, - description={ - "suggested_value": options.get(CONF_WEB_SEARCH_USER_LOCATION) - }, - default=RECOMMENDED_WEB_SEARCH_USER_LOCATION, - ): bool, - } - ) - return schema diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index a129400194b..8fea4613ce0 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -279,11 +279,11 @@ class OpenAIConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 0a07fa354b2..351e82ec11f 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -18,20 +18,32 @@ "init": { "data": { "prompt": "Instructions", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template." + } + }, + "advanced": { + "title": "Advanced settings", + "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", - "top_p": "Top P", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings", + "top_p": "Top P" + } + }, + "model": { + "title": "Model-specific options", + "data": { "reasoning_effort": "Reasoning effort", "web_search": "Enable web search", "search_context_size": "Search context size", "user_location": "Include home location" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)", + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "web_search": "Allow the model to search the web for the latest information before generating a response", "search_context_size": "High level guidance for the amount of context window space to use for the search", "user_location": "Refine search results based on geography" @@ -39,8 +51,7 @@ } }, "error": { - "model_not_supported": "This model is not supported, please select a different model", - "web_search_not_supported": "Web search is not supported by this model" + "model_not_supported": "This model is not supported, please select a different model" } }, "selector": { diff --git a/homeassistant/components/opentherm_gw/services.py b/homeassistant/components/opentherm_gw/services.py index c8f5c748875..5031393e867 100644 --- a/homeassistant/components/opentherm_gw/services.py +++ b/homeassistant/components/opentherm_gw/services.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_TIME, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -61,6 +61,7 @@ def _get_gateway(call: ServiceCall) -> OpenThermGatewayHub: return gw_hub +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema({vol.Required(ATTR_GW_ID): vol.All(cv.string)}) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 6396ba24a15..4753a77894e 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -10,6 +10,7 @@ from opower import ( CannotConnect, InvalidAuth, Opower, + create_cookie_jar, get_supported_utility_names, select_utility, ) @@ -39,7 +40,7 @@ async def _validate_login( ) -> dict[str, str]: """Validate login data and return any errors.""" api = Opower( - async_create_clientsession(hass), + async_create_clientsession(hass, cookie_jar=create_cookie_jar()), login_data[CONF_UTILITY], login_data[CONF_USERNAME], login_data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index d03c30b7db0..189fa185cd1 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -12,6 +12,7 @@ from opower import ( MeterType, Opower, ReadResolution, + create_cookie_jar, ) from opower.exceptions import ApiException, CannotConnect, InvalidAuth @@ -30,7 +31,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client, issue_registry as ir +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -62,7 +64,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): update_interval=timedelta(hours=12), ) self.api = Opower( - aiohttp_client.async_get_clientsession(hass), + async_create_clientsession(hass, cookie_jar=create_cookie_jar()), config_entry.data[CONF_UTILITY], config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 0aa26dbb4b1..4e88c5a68cc 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.12.3"] + "requirements": ["opower==0.12.4"] } diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index c7b81177a2b..6129aa379f7 100644 --- a/homeassistant/components/osoenergy/manifest.json +++ b/homeassistant/components/osoenergy/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/osoenergy", "iot_class": "cloud_polling", - "requirements": ["pyosoenergyapi==1.1.4"] + "requirements": ["pyosoenergyapi==1.1.5"] } diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index 597d44f66cf..3e7b5f32272 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS from .coordinator import OverseerrConfigEntry, OverseerrCoordinator -from .services import setup_services +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] CONF_CLOUDHOOK_URL = "cloudhook_url" @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Overseerr component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 4631e578af8..4e72f555603 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -12,6 +12,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util.json import JsonValueType @@ -39,7 +40,7 @@ SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( ) -def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> OverseerrConfigEntry: +def _async_get_entry(hass: HomeAssistant, config_entry_id: str) -> OverseerrConfigEntry: """Get the Overseerr config entry.""" if not (entry := hass.config_entries.async_get_entry(config_entry_id)): raise ServiceValidationError( @@ -56,7 +57,7 @@ def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> OverseerrConfi return cast(OverseerrConfigEntry, entry) -async def get_media( +async def _get_media( client: OverseerrClient, media_type: str, identifier: int ) -> dict[str, Any]: """Get media details.""" @@ -73,43 +74,45 @@ async def get_media( return media -def setup_services(hass: HomeAssistant) -> None: +async def _async_get_requests(call: ServiceCall) -> ServiceResponse: + """Get requests made to Overseerr.""" + entry = _async_get_entry(call.hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + kwargs: dict[str, Any] = {} + if status := call.data.get(ATTR_STATUS): + kwargs["status"] = status + if sort_order := call.data.get(ATTR_SORT_ORDER): + kwargs["sort"] = sort_order + if requested_by := call.data.get(ATTR_REQUESTED_BY): + kwargs["requested_by"] = requested_by + try: + requests = await client.get_requests(**kwargs) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + result: list[dict[str, Any]] = [] + for request in requests: + req = asdict(request) + assert request.media.tmdb_id + req["media"] = await _get_media( + client, request.media.media_type, request.media.tmdb_id + ) + result.append(req) + + return {"requests": cast(list[JsonValueType], result)} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Overseerr integration.""" - async def async_get_requests(call: ServiceCall) -> ServiceResponse: - """Get requests made to Overseerr.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - client = entry.runtime_data.client - kwargs: dict[str, Any] = {} - if status := call.data.get(ATTR_STATUS): - kwargs["status"] = status - if sort_order := call.data.get(ATTR_SORT_ORDER): - kwargs["sort"] = sort_order - if requested_by := call.data.get(ATTR_REQUESTED_BY): - kwargs["requested_by"] = requested_by - try: - requests = await client.get_requests(**kwargs) - except OverseerrConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - translation_placeholders={"error": str(err)}, - ) from err - result: list[dict[str, Any]] = [] - for request in requests: - req = asdict(request) - assert request.media.tmdb_id - req["media"] = await get_media( - client, request.media.media_type, request.media.tmdb_id - ) - result.append(req) - - return {"requests": cast(list[JsonValueType], result)} - hass.services.async_register( DOMAIN, SERVICE_GET_REQUESTS, - async_get_requests, + _async_get_requests, schema=SERVICE_GET_REQUESTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/ovo_energy/manifest.json b/homeassistant/components/ovo_energy/manifest.json index af4a313206e..0fc90808bc9 100644 --- a/homeassistant/components/ovo_energy/manifest.json +++ b/homeassistant/components/ovo_energy/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["ovoenergy"], - "requirements": ["ovoenergy==2.0.0"] + "requirements": ["ovoenergy==2.0.1"] } diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py index 4d60f47e1e8..0fea90b7ea3 100644 --- a/homeassistant/components/paperless_ngx/__init__.py +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -9,7 +9,7 @@ from pypaperless.exceptions import ( PaperlessInvalidTokenError, ) -from homeassistant.const import CONF_API_KEY, CONF_URL, Platform +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -69,7 +69,7 @@ async def _get_paperless_api( api = Paperless( entry.data[CONF_URL], entry.data[CONF_API_KEY], - session=async_get_clientsession(hass), + session=async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True)), ) try: diff --git a/homeassistant/components/paperless_ngx/config_flow.py b/homeassistant/components/paperless_ngx/config_flow.py index c0c1dc4ce19..9a8ea05d168 100644 --- a/homeassistant/components/paperless_ngx/config_flow.py +++ b/homeassistant/components/paperless_ngx/config_flow.py @@ -16,7 +16,7 @@ from pypaperless.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -25,6 +25,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str, + vol.Required(CONF_VERIFY_SSL, default=True): bool, } ) @@ -78,15 +79,19 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: return self.async_update_reload_and_abort(entry, data=user_input) + if user_input is not None: + suggested_values = user_input + else: + suggested_values = { + CONF_URL: entry.data[CONF_URL], + CONF_VERIFY_SSL: entry.data.get(CONF_VERIFY_SSL, True), + } + return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( data_schema=STEP_USER_DATA_SCHEMA, - suggested_values={ - CONF_URL: user_input[CONF_URL] - if user_input is not None - else entry.data[CONF_URL], - }, + suggested_values=suggested_values, ), errors=errors, ) @@ -122,13 +127,15 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _validate_input(self, user_input: dict[str, str]) -> dict[str, str]: + async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: errors: dict[str, str] = {} client = Paperless( user_input[CONF_URL], user_input[CONF_API_KEY], - session=async_get_clientsession(self.hass), + session=async_get_clientsession( + self.hass, user_input.get(CONF_VERIFY_SSL, True) + ), ) try: diff --git a/homeassistant/components/paperless_ngx/coordinator.py b/homeassistant/components/paperless_ngx/coordinator.py index d5960bed49b..270fd8063dc 100644 --- a/homeassistant/components/paperless_ngx/coordinator.py +++ b/homeassistant/components/paperless_ngx/coordinator.py @@ -5,7 +5,6 @@ from __future__ import annotations from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta -from typing import TypeVar from pypaperless import Paperless from pypaperless.exceptions import ( @@ -25,8 +24,6 @@ from .const import DOMAIN, LOGGER type PaperlessConfigEntry = ConfigEntry[PaperlessData] -TData = TypeVar("TData") - UPDATE_INTERVAL_STATISTICS = timedelta(seconds=120) UPDATE_INTERVAL_STATUS = timedelta(seconds=300) @@ -39,7 +36,7 @@ class PaperlessData: status: PaperlessStatusCoordinator -class PaperlessCoordinator(DataUpdateCoordinator[TData]): +class PaperlessCoordinator[DataT](DataUpdateCoordinator[DataT]): """Coordinator to manage fetching Paperless-ngx API.""" config_entry: PaperlessConfigEntry @@ -63,7 +60,7 @@ class PaperlessCoordinator(DataUpdateCoordinator[TData]): update_interval=update_interval, ) - async def _async_update_data(self) -> TData: + async def _async_update_data(self) -> DataT: """Update data via internal method.""" try: return await self._async_update_data_internal() @@ -89,7 +86,7 @@ class PaperlessCoordinator(DataUpdateCoordinator[TData]): ) from err @abstractmethod - async def _async_update_data_internal(self) -> TData: + async def _async_update_data_internal(self) -> DataT: """Update data via paperless-ngx API.""" diff --git a/homeassistant/components/paperless_ngx/entity.py b/homeassistant/components/paperless_ngx/entity.py index e7eb0f0edcf..59cd13c5209 100644 --- a/homeassistant/components/paperless_ngx/entity.py +++ b/homeassistant/components/paperless_ngx/entity.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Generic, TypeVar - from homeassistant.components.sensor import EntityDescription from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -11,17 +9,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import PaperlessCoordinator -TCoordinator = TypeVar("TCoordinator", bound=PaperlessCoordinator) - -class PaperlessEntity(CoordinatorEntity[TCoordinator], Generic[TCoordinator]): +class PaperlessEntity[CoordinatorT: PaperlessCoordinator]( + CoordinatorEntity[CoordinatorT] +): """Defines a base Paperless-ngx entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: TCoordinator, + coordinator: CoordinatorT, description: EntityDescription, ) -> None: """Initialize the Paperless-ngx entity.""" diff --git a/homeassistant/components/paperless_ngx/sensor.py b/homeassistant/components/paperless_ngx/sensor.py index e3f601b68e6..5d6bfe1347e 100644 --- a/homeassistant/components/paperless_ngx/sensor.py +++ b/homeassistant/components/paperless_ngx/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic from pypaperless.models import Statistic, Status from pypaperless.models.common import StatusType @@ -23,23 +22,23 @@ from homeassistant.util.unit_conversion import InformationConverter from .coordinator import ( PaperlessConfigEntry, + PaperlessCoordinator, PaperlessStatisticCoordinator, PaperlessStatusCoordinator, - TData, ) -from .entity import PaperlessEntity, TCoordinator +from .entity import PaperlessEntity PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class PaperlessEntityDescription(SensorEntityDescription, Generic[TData]): +class PaperlessEntityDescription[DataT](SensorEntityDescription): """Describes Paperless-ngx sensor entity.""" - value_fn: Callable[[TData], StateType] + value_fn: Callable[[DataT], StateType] -SENSOR_STATISTICS: tuple[PaperlessEntityDescription, ...] = ( +SENSOR_STATISTICS: tuple[PaperlessEntityDescription[Statistic], ...] = ( PaperlessEntityDescription[Statistic]( key="documents_total", translation_key="documents_total", @@ -78,7 +77,7 @@ SENSOR_STATISTICS: tuple[PaperlessEntityDescription, ...] = ( ), ) -SENSOR_STATUS: tuple[PaperlessEntityDescription, ...] = ( +SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( PaperlessEntityDescription[Status]( key="storage_total", translation_key="storage_total", @@ -258,7 +257,9 @@ async def async_setup_entry( async_add_entities(entities) -class PaperlessSensor(PaperlessEntity[TCoordinator], SensorEntity): +class PaperlessSensor[CoordinatorT: PaperlessCoordinator]( + PaperlessEntity[CoordinatorT], SensorEntity +): """Defines a Paperless-ngx sensor entity.""" entity_description: PaperlessEntityDescription diff --git a/homeassistant/components/paperless_ngx/strings.json b/homeassistant/components/paperless_ngx/strings.json index 1347dc83e98..aa3f7ada943 100644 --- a/homeassistant/components/paperless_ngx/strings.json +++ b/homeassistant/components/paperless_ngx/strings.json @@ -4,11 +4,13 @@ "user": { "data": { "url": "[%key:common::config_flow::data::url%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "url": "URL to connect to the Paperless-ngx instance", - "api_key": "API key to connect to the Paperless-ngx API" + "api_key": "API key to connect to the Paperless-ngx API", + "verify_ssl": "Verify the SSL certificate of the Paperless-ngx instance. Disable this option if you’re using a self-signed certificate." }, "title": "Add Paperless-ngx instance" }, @@ -24,11 +26,13 @@ "reconfigure": { "data": { "url": "[%key:common::config_flow::data::url%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "url": "[%key:component::paperless_ngx::config::step::user::data_description::url%]", - "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]" + "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]", + "verify_ssl": "[%key:component::paperless_ngx::config::step::user::data_description::verify_ssl%]" }, "title": "Reconfigure Paperless-ngx instance" } diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 0717b669da3..8ecae8dc301 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -7,7 +7,7 @@ from typing import cast from python_picnic_api2 import PicnicAPI import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( @@ -26,6 +26,7 @@ class PicnicServiceException(Exception): """Exception for Picnic services.""" +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for the Picnic integration, if not registered yet.""" diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index eb57dc46727..bc117e4c7f4 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -105,7 +105,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.data.setdefault(DOMAIN, hass_data) - await async_setup_services(hass) + async_setup_services(hass) hass.http.register_view(PlexImageView()) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index d5d70219471..b43a1eca135 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -56,7 +56,6 @@ AUTOMATIC_SETUP_STRING = "Obtain a new token from plex.tv" MANUAL_SETUP_STRING = "Configure Plex server manually" SERVICE_REFRESH_LIBRARY = "refresh_library" -SERVICE_SCAN_CLIENTS = "scan_for_clients" PLEX_URI_SCHEME = "plex://" diff --git a/homeassistant/components/plex/icons.json b/homeassistant/components/plex/icons.json index 2d3a7342ad2..21a48fd274e 100644 --- a/homeassistant/components/plex/icons.json +++ b/homeassistant/components/plex/icons.json @@ -9,9 +9,6 @@ "services": { "refresh_library": { "service": "mdi:refresh" - }, - "scan_for_clients": { - "service": "mdi:database-refresh" } } } diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index c70ddb6ed53..1ff7820a2c0 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -7,18 +7,10 @@ from plexapi.exceptions import NotFound import voluptuous as vol from yarl import URL -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ( - DOMAIN, - PLEX_UPDATE_PLATFORMS_SIGNAL, - PLEX_URI_SCHEME, - SERVERS, - SERVICE_REFRESH_LIBRARY, - SERVICE_SCAN_CLIENTS, -) +from .const import DOMAIN, PLEX_URI_SCHEME, SERVERS, SERVICE_REFRESH_LIBRARY from .errors import MediaNotFound from .helpers import get_plex_data from .models import PlexMediaSearchResult @@ -31,30 +23,19 @@ REFRESH_LIBRARY_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__package__) -async def async_setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up services for the Plex component.""" async def async_refresh_library_service(service_call: ServiceCall) -> None: await hass.async_add_executor_job(refresh_library, hass, service_call) - async def async_scan_clients_service(_: ServiceCall) -> None: - _LOGGER.warning( - "This service is deprecated in favor of the scan_clients button entity." - " Service calls will still work for now but the service will be removed in" - " a future release" - ) - for server_id in get_plex_data(hass)[SERVERS]: - async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) - hass.services.async_register( DOMAIN, SERVICE_REFRESH_LIBRARY, async_refresh_library_service, schema=REFRESH_LIBRARY_SCHEMA, ) - hass.services.async_register( - DOMAIN, SERVICE_SCAN_CLIENTS, async_scan_clients_service - ) def refresh_library(hass: HomeAssistant, service_call: ServiceCall) -> None: diff --git a/homeassistant/components/plex/services.yaml b/homeassistant/components/plex/services.yaml index 5ed655b7d78..ee4a2a234ea 100644 --- a/homeassistant/components/plex/services.yaml +++ b/homeassistant/components/plex/services.yaml @@ -9,5 +9,3 @@ refresh_library: example: "TV Shows" selector: text: - -scan_for_clients: diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 0c8eae86f73..0eb83a64a5d 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -83,10 +83,6 @@ "description": "Name of the Plex library to refresh." } } - }, - "scan_for_clients": { - "name": "Scan for clients", - "description": "Scans for available clients from the Plex server(s), local network, and plex.tv." } } } diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index c7fac07f1cb..834ff8bce76 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -15,7 +15,6 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MASTER_THERMOSTATS @@ -216,17 +215,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the hvac mode.""" - if hvac_mode not in self.hvac_modes: - hvac_modes = ", ".join(self.hvac_modes) - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unsupported_hvac_mode_requested", - translation_placeholders={ - "hvac_mode": hvac_mode, - "hvac_modes": hvac_modes, - }, - ) - if hvac_mode == self.hvac_mode: return diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index fdbe8c39015..9c005c4c0df 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -316,9 +316,6 @@ }, "unsupported_firmware": { "message": "[%key:component::plugwise::config::error::unsupported%]" - }, - "unsupported_hvac_mode_requested": { - "message": "Unsupported mode {hvac_mode} requested, valid modes are: {hvac_modes}." } } } diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index b2e8d9309d9..2ef55d6204a 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -20,7 +20,13 @@ }, "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index de14dc30d54..749b73e5aee 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -166,7 +166,7 @@ async def async_setup_entry( # noqa: C901 # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import objgraph # pylint: disable=import-outside-toplevel + import objgraph # noqa: PLC0415 obj_type = call.data[CONF_TYPE] @@ -192,7 +192,7 @@ async def async_setup_entry( # noqa: C901 # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import objgraph # pylint: disable=import-outside-toplevel + import objgraph # noqa: PLC0415 for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): lru = cast(_lru_cache_wrapper, lru) @@ -399,7 +399,7 @@ async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall): # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import cProfile # pylint: disable=import-outside-toplevel + import cProfile # noqa: PLC0415 start_time = int(time.time() * 1000000) persistent_notification.async_create( @@ -436,7 +436,7 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - from guppy import hpy # pylint: disable=import-outside-toplevel + from guppy import hpy # noqa: PLC0415 start_time = int(time.time() * 1000000) persistent_notification.async_create( @@ -467,7 +467,7 @@ def _write_profile(profiler, cprofile_path, callgrind_path): # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - from pyprof2calltree import convert # pylint: disable=import-outside-toplevel + from pyprof2calltree import convert # noqa: PLC0415 profiler.create_stats() profiler.dump_stats(cprofile_path) @@ -482,14 +482,14 @@ def _log_objects(*_): # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import objgraph # pylint: disable=import-outside-toplevel + import objgraph # noqa: PLC0415 _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) def _get_function_absfile(func: Any) -> str | None: """Get the absolute file path of a function.""" - import inspect # pylint: disable=import-outside-toplevel + import inspect # noqa: PLC0415 abs_file: str | None = None with suppress(Exception): @@ -510,7 +510,7 @@ def _safe_repr(obj: Any) -> str: def _find_backrefs_not_to_self(_object: Any) -> list[str]: - import objgraph # pylint: disable=import-outside-toplevel + import objgraph # noqa: PLC0415 return [ _safe_repr(backref) @@ -526,7 +526,7 @@ def _log_object_sources( # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import gc # pylint: disable=import-outside-toplevel + import gc # noqa: PLC0415 gc.collect() diff --git a/homeassistant/components/ps4/services.py b/homeassistant/components/ps4/services.py index 88751660f75..583366602ed 100644 --- a/homeassistant/components/ps4/services.py +++ b/homeassistant/components/ps4/services.py @@ -5,7 +5,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import COMMANDS, DOMAIN, PS4_DATA @@ -29,6 +29,7 @@ async def async_service_command(call: ServiceCall) -> None: await device.async_send_command(command) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Handle for services.""" diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index c0bffbe9615..a350feac519 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -45,7 +45,7 @@ from .const import ( # noqa: F401 SupportedDialect, ) from .core import Recorder -from .services import async_register_services +from .services import async_setup_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_register_services(hass, instance) + async_setup_services(hass) websocket_api.async_setup(hass) await _async_setup_integration_platform(hass, instance) diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index cf3addd4f20..e14a165f81f 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -242,7 +242,7 @@ def correct_db_schema_utf8( f"{table_name}.4-byte UTF-8" in schema_errors or f"{table_name}.utf8mb4_unicode_ci" in schema_errors ): - from ..migration import ( # pylint: disable=import-outside-toplevel + from ..migration import ( # noqa: PLC0415 _correct_table_character_set_and_collation, ) @@ -258,9 +258,7 @@ def correct_db_schema_precision( table_name = table_object.__tablename__ if f"{table_name}.double precision" in schema_errors: - from ..migration import ( # pylint: disable=import-outside-toplevel - _modify_columns, - ) + from ..migration import _modify_columns # noqa: PLC0415 precision_columns = _get_precision_column_types(table_object) # Attempt to convert timestamp columns to µs precision diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py index a28027adb1a..469d6694640 100644 --- a/homeassistant/components/recorder/history/__init__.py +++ b/homeassistant/components/recorder/history/__init__.py @@ -45,7 +45,7 @@ def get_full_significant_states_with_session( ) -> dict[str, list[State]]: """Return a dict of significant states during a time period.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 get_full_significant_states_with_session as _legacy_get_full_significant_states_with_session, ) @@ -70,7 +70,7 @@ def get_last_state_changes( ) -> dict[str, list[State]]: """Return the last number_of_states.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 get_last_state_changes as _legacy_get_last_state_changes, ) @@ -94,7 +94,7 @@ def get_significant_states( ) -> dict[str, list[State | dict[str, Any]]]: """Return a dict of significant states during a time period.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 get_significant_states as _legacy_get_significant_states, ) @@ -130,7 +130,7 @@ def get_significant_states_with_session( ) -> dict[str, list[State | dict[str, Any]]]: """Return a dict of significant states during a time period.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 get_significant_states_with_session as _legacy_get_significant_states_with_session, ) @@ -164,7 +164,7 @@ def state_changes_during_period( ) -> dict[str, list[State]]: """Return a list of states that changed during a time period.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 state_changes_during_period as _legacy_state_changes_during_period, ) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 30e277d7c0a..d8d7ddb832a 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -90,7 +90,7 @@ class RecorderPool(SingletonThreadPool, NullPool): if threading.get_ident() in self.recorder_and_worker_thread_ids: super().dispose() - def _do_get(self) -> ConnectionPoolEntry: # type: ignore[return] + def _do_get(self) -> ConnectionPoolEntry: # type: ignore[return] # noqa: RET503 if threading.get_ident() in self.recorder_and_worker_thread_ids: return super()._do_get() try: @@ -100,7 +100,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # which is allowed but discouraged since its much slower return self._do_get_db_connection_protected() # In the event loop, raise an exception - raise_for_blocking_call( # noqa: RET503 + raise_for_blocking_call( self._do_get_db_connection_protected, strict=True, advise_msg=ADVISE_MSG, diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index ba454c59bf3..ca92a2131d8 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -17,6 +17,7 @@ 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, @@ -25,7 +26,6 @@ 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,155 +87,137 @@ SERVICE_GET_STATISTICS_SCHEMA = vol.Schema( ) -@callback -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 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.""" 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) diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index 65aa797d91b..3ecd2be8af6 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -69,7 +69,7 @@ get_statistics: - sensor.energy_consumption - sensor.temperature selector: - entity: + statistic: multiple: true period: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7f41358dddf..7326519b14e 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2855,7 +2855,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: # to indicate we need to run again return False - from .migration import _drop_index # pylint: disable=import-outside-toplevel + from .migration import _drop_index # noqa: PLC0415 for table in STATISTICS_TABLES: _drop_index(instance.get_session, table, f"ix_{table}_start") diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index b7b1a8e17a3..cff3e868def 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -258,7 +258,7 @@ def basic_sanity_check(cursor: SQLiteCursor) -> bool: def validate_sqlite_database(dbpath: str) -> bool: """Run a quick check on an sqlite database to see if it is corrupt.""" - import sqlite3 # pylint: disable=import-outside-toplevel + import sqlite3 # noqa: PLC0415 try: conn = sqlite3.connect(dbpath) @@ -402,9 +402,8 @@ def _datetime_or_none(value: str) -> datetime | None: def build_mysqldb_conv() -> dict: """Build a MySQLDB conv dict that uses cisco8601 to parse datetimes.""" # Late imports since we only call this if they are using mysqldb - # pylint: disable=import-outside-toplevel - from MySQLdb.constants import FIELD_TYPE - from MySQLdb.converters import conversions + from MySQLdb.constants import FIELD_TYPE # noqa: PLC0415 + from MySQLdb.converters import conversions # noqa: PLC0415 return {**conversions, FIELD_TYPE.DATETIME: _datetime_or_none} diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 2f60918f010..f6918ea9706 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -4,6 +4,7 @@ from datetime import datetime import logging from ical.event import Event +from ical.timeline import Timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -48,12 +49,18 @@ class RemoteCalendarEntity( super().__init__(coordinator) self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id - self._event: CalendarEvent | None = None + self._timeline: Timeline | None = None @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + if self._timeline is None: + return None + now = dt_util.now() + events = self._timeline.active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -79,15 +86,12 @@ class RemoteCalendarEntity( """ await super().async_update() - def next_timeline_event() -> CalendarEvent | None: + def _get_timeline() -> Timeline | None: """Return the next active event.""" now = dt_util.now() - events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + return self.coordinator.data.timeline_tz(now.tzinfo) - self._event = await self.hass.async_add_executor_job(next_timeline_event) + self._timeline = await self.hass.async_add_executor_job(_get_timeline) def _get_calendar_event(event: Event) -> CalendarEvent: diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 7bdc5362ae7..6ba1dea55ed 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -1,12 +1,12 @@ { "domain": "remote_calendar", "name": "Remote Calendar", - "codeowners": ["@Thomas55555"], + "codeowners": ["@Thomas55555", "@allenporter"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/remote_calendar", "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==10.0.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 2d08e42a6c8..5664bba25a3 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -63,6 +63,7 @@ BINARY_PUSH_SENSORS = ( cmd_id=33, device_class=BinarySensorDeviceClass.MOTION, value=lambda api, ch: api.motion_detected(ch), + supported=lambda api, ch: api.supported(ch, "motion_detection"), ), ReolinkBinarySensorEntityDescription( key=FACE_DETECTION_TYPE, diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 329ef9028de..119fb625349 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -37,23 +37,27 @@ CAMERA_ENTITIES = ( key="sub", stream="sub", translation_key="sub", + supported=lambda api, ch: api.supported(ch, "stream"), ), ReolinkCameraEntityDescription( key="main", stream="main", translation_key="main", + supported=lambda api, ch: api.supported(ch, "stream"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="snapshots_sub", stream="snapshots_sub", translation_key="snapshots_sub", + supported=lambda api, ch: api.supported(ch, "snapshot"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="snapshots", stream="snapshots_main", translation_key="snapshots_main", + supported=lambda api, ch: api.supported(ch, "snapshot"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 1d0e5d919e7..c5085c9ca18 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -39,6 +39,8 @@ async def async_get_config_entry_diagnostics( "firmware version": api.sw_version, "HTTPS": api.use_https, "HTTP(S) port": api.port, + "Baichuan port": api.baichuan.port, + "Baichuan only": api.baichuan_only, "WiFi connection": api.wifi_connection, "WiFi signal": api.wifi_signal, "RTMP enabled": api.rtmp_enabled, diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index d7e8817b1b7..467472fef9c 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -75,14 +75,17 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] ) http_s = "https" if self._host.api.use_https else "http" - self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" + if self._host.api.baichuan_only: + self._conf_url = None + else: + self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" self._dev_id = self._host.unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, name=self._host.api.nvr_name, model=self._host.api.model, - model_id=self._host.api.item_number, + model_id=self._host.api.item_number(), manufacturer=self._host.api.manufacturer, hw_version=self._host.api.hardware_version, sw_version=self._host.api.sw_version, @@ -184,17 +187,23 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): if mac := self._host.api.baichuan.mac_address(dev_ch): connections.add((CONNECTION_NETWORK_MAC, mac)) + if self._conf_url is None: + conf_url = None + else: + conf_url = f"{self._conf_url}/?ch={dev_ch}" + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, connections=connections, via_device=(DOMAIN, self._host.unique_id), name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), + model_id=self._host.api.item_number(dev_ch), manufacturer=self._host.api.manufacturer, hw_version=self._host.api.camera_hardware_version(dev_ch), sw_version=self._host.api.camera_sw_version(dev_ch), serial_number=self._host.api.camera_uid(dev_ch), - configuration_url=f"{self._conf_url}/?ch={dev_ch}", + configuration_url=conf_url, ) @property diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index fef175457f7..cf3079e51e8 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -172,6 +172,9 @@ "floodlight_brightness": { "default": "mdi:spotlight-beam" }, + "ir_brightness": { + "default": "mdi:led-off" + }, "volume": { "default": "mdi:volume-high", "state": { @@ -217,6 +220,9 @@ "ai_animal_sensitivity": { "default": "mdi:paw" }, + "cry_sensitivity": { + "default": "mdi:emoticon-cry-outline" + }, "crossline_sensitivity": { "default": "mdi:fence" }, @@ -485,6 +491,12 @@ "state": { "on": "mdi:eye-off" } + }, + "privacy_mask": { + "default": "mdi:eye", + "state": { + "on": "mdi:eye-off" + } } } }, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5ae8b0305e4..04996689bf7 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.13.5"] + "requirements": ["reolink-aio==0.14.1"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 2a6fb740ee0..6de702a0395 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -122,6 +122,20 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.whiteled_brightness(ch), method=lambda api, ch, value: api.set_whiteled(ch, brightness=int(value)), ), + ReolinkNumberEntityDescription( + key="ir_brightness", + cmd_key="208", + translation_key="ir_brightness", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ir_brightness"), + value=lambda api, ch: api.baichuan.ir_brightness(ch), + method=lambda api, ch, value: ( + api.baichuan.set_status_led(ch, ir_brightness=int(value)) + ), + ), ReolinkNumberEntityDescription( key="volume", cmd_key="GetAudioCfg", @@ -258,6 +272,18 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), ), + ReolinkNumberEntityDescription( + key="cry_sensitivity", + cmd_key="299", + translation_key="cry_sensitivity", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=5, + supported=lambda api, ch: api.supported(ch, "ai_cry"), + value=lambda api, ch: api.baichuan.cry_sensitivity(ch), + method=lambda api, ch, value: api.baichuan.set_cry_detection(ch, int(value)), + ), ReolinkNumberEntityDescription( key="ai_face_delay", cmd_key="GetAiAlarm", diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index d170aa32379..352ebb4ef19 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -19,51 +19,54 @@ from .util import get_device_uid_and_ch, raise_translated_error ATTR_RINGTONE = "ringtone" +@raise_translated_error +async def _async_play_chime(service_call: ServiceCall) -> None: + """Play a ringtone.""" + service_data = service_call.data + device_registry = dr.async_get(service_call.hass) + + for device_id in service_data[ATTR_DEVICE_ID]: + config_entry = None + device = device_registry.async_get(device_id) + if device is not None: + for entry_id in device.config_entries: + config_entry = service_call.hass.config_entries.async_get_entry( + entry_id + ) + if config_entry is not None and config_entry.domain == DOMAIN: + break + if ( + config_entry is None + or device is None + or config_entry.state != ConfigEntryState.LOADED + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_entry_ex", + translation_placeholders={"service_name": "play_chime"}, + ) + host: ReolinkHost = config_entry.runtime_data.host + (device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) + chime: Chime | None = host.api.chime(chime_id) + if not is_chime or chime is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_not_chime", + translation_placeholders={"device_name": str(device.name)}, + ) + + ringtone = service_data[ATTR_RINGTONE] + await chime.play(ChimeToneEnum[ringtone].value) + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up Reolink services.""" - @raise_translated_error - async def async_play_chime(service_call: ServiceCall) -> None: - """Play a ringtone.""" - service_data = service_call.data - device_registry = dr.async_get(hass) - - for device_id in service_data[ATTR_DEVICE_ID]: - config_entry = None - device = device_registry.async_get(device_id) - if device is not None: - for entry_id in device.config_entries: - config_entry = hass.config_entries.async_get_entry(entry_id) - if config_entry is not None and config_entry.domain == DOMAIN: - break - if ( - config_entry is None - or device is None - or config_entry.state != ConfigEntryState.LOADED - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="service_entry_ex", - translation_placeholders={"service_name": "play_chime"}, - ) - host: ReolinkHost = config_entry.runtime_data.host - (device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) - chime: Chime | None = host.api.chime(chime_id) - if not is_chime or chime is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="service_not_chime", - translation_placeholders={"device_name": str(device.name)}, - ) - - ringtone = service_data[ATTR_RINGTONE] - await chime.play(ChimeToneEnum[ringtone].value) - hass.services.async_register( DOMAIN, "play_chime", - async_play_chime, + _async_play_chime, schema=vol.Schema( { vol.Required(ATTR_DEVICE_ID): list[str], diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index d1d51d9229a..e7a970ec1c8 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -532,6 +532,9 @@ "floodlight_brightness": { "name": "Floodlight turn on brightness" }, + "ir_brightness": { + "name": "Infrared light brightness" + }, "volume": { "name": "Volume" }, @@ -568,6 +571,9 @@ "ai_animal_sensitivity": { "name": "AI animal sensitivity" }, + "cry_sensitivity": { + "name": "Baby cry sensitivity" + }, "crossline_sensitivity": { "name": "AI crossline {zone_name} sensitivity" }, @@ -954,6 +960,9 @@ }, "privacy_mode": { "name": "Privacy mode" + }, + "privacy_mask": { + "name": "Privacy mask" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index d9f192a3faa..47b14f7f4ad 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -216,6 +216,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.baichuan.privacy_mode(ch), method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value), ), + ReolinkSwitchEntityDescription( + key="privacy_mask", + cmd_key="GetMask", + translation_key="privacy_mask", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "privacy_mask"), + value=lambda api, ch: api.privacy_mask_enabled(ch), + method=lambda api, ch, value: api.set_privacy_mask(ch, enable=value), + ), ReolinkSwitchEntityDescription( key="hardwired_chime_enabled", cmd_key="483", diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index f5f372d2d33..206b31ab86f 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -6,5 +6,5 @@ "iot_class": "assumed_state", "loggers": ["rflink"], "quality_scale": "legacy", - "requirements": ["rflink==0.0.66"] + "requirements": ["rflink==0.0.67"] } diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 92f4f5a0434..52437cc00be 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -264,8 +264,7 @@ class RMVDepartureData: for dest in self._destinations: if dest in journey["stops"]: dest_found = True - if dest in _deps_not_found: - _deps_not_found.remove(dest) + _deps_not_found.discard(dest) _nextdep["destination"] = dest if not dest_found: diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 7fe2fb3b686..d5e2e2e5224 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["rokuecp"], - "requirements": ["rokuecp==0.19.3"], + "requirements": ["rokuecp==0.19.5"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 65fbd89e203..f35a476bbb3 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -9,6 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS @@ -52,6 +54,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> ) from err entry.runtime_data = client + device_registry = dr.async_get(hass) + + for controller_id, controller in client.controllers.items(): + _device_identifier = ( + controller.mac_address + or f"{client.controllers[1].mac_address}-{controller_id}" + ) + connections = None + via_device = None + configuration_url = None + if controller_id != 1: + assert client.controllers[1].mac_address + via_device = ( + DOMAIN, + client.controllers[1].mac_address, + ) + else: + assert controller.mac_address + connections = {(CONNECTION_NETWORK_MAC, controller.mac_address)} + if isinstance(client.connection_handler, RussoundTcpConnectionHandler): + configuration_url = f"http://{client.connection_handler.host}" + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, _device_identifier)}, + manufacturer="Russound", + name=controller.controller_type, + model=controller.controller_type, + sw_version=controller.firmware_version, + connections=connections, + via_device=via_device, + configuration_url=configuration_url, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 9790ff43e68..d7b4e412831 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -4,11 +4,11 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate -from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler +from aiorussound import Controller, RussoundClient from aiorussound.models import CallbackType from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS @@ -46,6 +46,7 @@ class RussoundBaseEntity(Entity): def __init__( self, controller: Controller, + zone_id: int | None = None, ) -> None: """Initialize the entity.""" self._client = controller.client @@ -57,29 +58,21 @@ class RussoundBaseEntity(Entity): self._controller.mac_address or f"{self._primary_mac_address}-{self._controller.controller_id}" ) + if not zone_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_identifier)}, + ) + return + zone = controller.zones[zone_id] self._attr_device_info = DeviceInfo( - # Use MAC address of Russound device as identifier - identifiers={(DOMAIN, self._device_identifier)}, + identifiers={(DOMAIN, f"{self._device_identifier}-{zone_id}")}, + name=zone.name, manufacturer="Russound", - name=controller.controller_type, model=controller.controller_type, sw_version=controller.firmware_version, + suggested_area=zone.name, + via_device=(DOMAIN, self._device_identifier), ) - if isinstance(self._client.connection_handler, RussoundTcpConnectionHandler): - self._attr_device_info["configuration_url"] = ( - f"http://{self._client.connection_handler.host}" - ) - if controller.controller_id != 1: - assert self._client.controllers[1].mac_address - self._attr_device_info["via_device"] = ( - DOMAIN, - self._client.controllers[1].mac_address, - ) - else: - assert controller.mac_address - self._attr_device_info["connections"] = { - (CONNECTION_NETWORK_MAC, controller.mac_address) - } async def _state_update_callback( self, _client: RussoundClient, _callback_type: CallbackType diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index e16e589e648..a74a1887836 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.5.2"], + "requirements": ["aiorussound==4.6.1"], "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index b40b82862f9..7dbc3ae34be 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -60,16 +60,16 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK ) + _attr_name = None def __init__( self, controller: Controller, zone_id: int, sources: dict[int, Source] ) -> None: """Initialize the zone device.""" - super().__init__(controller) + super().__init__(controller, zone_id) self._zone_id = zone_id _zone = self._zone self._sources = sources - self._attr_name = _zone.name self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}" @property diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 1918f6ef28c..2927dcf2683 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -76,10 +76,10 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) def _wake_on_lan(self) -> None: """Wake the device via wake on lan.""" - send_magic_packet(self._mac, ip_address=self._host) + send_magic_packet(self._mac, ip_address=self._host) # type: ignore[arg-type] # If the ip address changed since we last saw the device # broadcast a packet as well - send_magic_packet(self._mac) + send_magic_packet(self._mac) # type: ignore[arg-type] async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 5bb69e7f121..dc8133a1b1f 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -38,7 +38,7 @@ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", - "wakeonlan==2.1.0", + "wakeonlan==3.1.0", "async-upnp-client==0.44.0" ], "ssdp": [ diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 972837f7d75..c6e4f0c279c 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -18,7 +18,7 @@ from homeassistant.util import slugify from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info from .data import ENTITY_MIGRATIONS -from .services import async_load_screenlogic_services +from .services import async_setup_services from .util import generate_unique_id type ScreenLogicConfigEntry = ConfigEntry[ScreenlogicDataUpdateCoordinator] @@ -48,7 +48,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Screenlogic.""" - async_load_screenlogic_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index 44d8ad3ed81..3901f1cfd37 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -54,105 +54,110 @@ TURN_ON_SUPER_CHLOR_SCHEMA = BASE_SERVICE_SCHEMA.extend( ) +async def _get_coordinators( + service_call: ServiceCall, +) -> list[ScreenlogicDataUpdateCoordinator]: + entry_ids = {service_call.data[ATTR_CONFIG_ENTRY]} + coordinators: list[ScreenlogicDataUpdateCoordinator] = [] + for entry_id in entry_ids: + config_entry = cast( + ScreenLogicConfigEntry | None, + service_call.hass.config_entries.async_get_entry(entry_id), + ) + if not config_entry: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not found" + ) + if not config_entry.domain == DOMAIN: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' is not a {DOMAIN} config" + ) + if not config_entry.state == ConfigEntryState.LOADED: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not loaded" + ) + coordinators.append(config_entry.runtime_data) + + return coordinators + + +async def _async_set_color_mode(service_call: ServiceCall) -> None: + color_num = SUPPORTED_COLOR_MODES[service_call.data[ATTR_COLOR_MODE]] + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await _get_coordinators(service_call): + _LOGGER.debug( + "Service %s called on %s with mode %s", + SERVICE_SET_COLOR_MODE, + coordinator.gateway.name, + color_num, + ) + try: + await coordinator.gateway.async_set_color_lights(color_num) + # Debounced refresh to catch any secondary changes in the device + await coordinator.async_request_refresh() + except ScreenLogicError as error: + raise HomeAssistantError(error) from error + + +async def _async_set_super_chlor( + service_call: ServiceCall, + is_on: bool, + runtime: int | None = None, +) -> None: + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await _get_coordinators(service_call): + if EQUIPMENT_FLAG.CHLORINATOR not in coordinator.gateway.equipment_flags: + raise ServiceValidationError( + f"Equipment configuration for {coordinator.gateway.name} does not" + f" support {service_call.service}" + ) + rt_log = f" with runtime {runtime}" if runtime else "" + _LOGGER.debug( + "Service %s called on %s%s", + service_call.service, + coordinator.gateway.name, + rt_log, + ) + try: + await coordinator.gateway.async_set_scg_config( + super_chlor_timer=runtime, super_chlorinate=is_on + ) + # Debounced refresh to catch any secondary changes in the device + await coordinator.async_request_refresh() + except ScreenLogicError as error: + raise HomeAssistantError(error) from error + + +async def _async_start_super_chlor(service_call: ServiceCall) -> None: + runtime = service_call.data[ATTR_RUNTIME] + await _async_set_super_chlor(service_call, True, runtime) + + +async def _async_stop_super_chlor(service_call: ServiceCall) -> None: + await _async_set_super_chlor(service_call, False) + + @callback -def async_load_screenlogic_services(hass: HomeAssistant): +def async_setup_services(hass: HomeAssistant): """Set up services for the ScreenLogic integration.""" - async def get_coordinators( - service_call: ServiceCall, - ) -> list[ScreenlogicDataUpdateCoordinator]: - entry_ids = {service_call.data[ATTR_CONFIG_ENTRY]} - coordinators: list[ScreenlogicDataUpdateCoordinator] = [] - for entry_id in entry_ids: - config_entry = cast( - ScreenLogicConfigEntry | None, - hass.config_entries.async_get_entry(entry_id), - ) - if not config_entry: - raise ServiceValidationError( - f"Failed to call service '{service_call.service}'. Config entry " - f"'{entry_id}' not found" - ) - if not config_entry.domain == DOMAIN: - raise ServiceValidationError( - f"Failed to call service '{service_call.service}'. Config entry " - f"'{entry_id}' is not a {DOMAIN} config" - ) - if not config_entry.state == ConfigEntryState.LOADED: - raise ServiceValidationError( - f"Failed to call service '{service_call.service}'. Config entry " - f"'{entry_id}' not loaded" - ) - coordinators.append(config_entry.runtime_data) - - return coordinators - - async def async_set_color_mode(service_call: ServiceCall) -> None: - color_num = SUPPORTED_COLOR_MODES[service_call.data[ATTR_COLOR_MODE]] - coordinator: ScreenlogicDataUpdateCoordinator - for coordinator in await get_coordinators(service_call): - _LOGGER.debug( - "Service %s called on %s with mode %s", - SERVICE_SET_COLOR_MODE, - coordinator.gateway.name, - color_num, - ) - try: - await coordinator.gateway.async_set_color_lights(color_num) - # Debounced refresh to catch any secondary changes in the device - await coordinator.async_request_refresh() - except ScreenLogicError as error: - raise HomeAssistantError(error) from error - - async def async_set_super_chlor( - service_call: ServiceCall, - is_on: bool, - runtime: int | None = None, - ) -> None: - coordinator: ScreenlogicDataUpdateCoordinator - for coordinator in await get_coordinators(service_call): - if EQUIPMENT_FLAG.CHLORINATOR not in coordinator.gateway.equipment_flags: - raise ServiceValidationError( - f"Equipment configuration for {coordinator.gateway.name} does not" - f" support {service_call.service}" - ) - rt_log = f" with runtime {runtime}" if runtime else "" - _LOGGER.debug( - "Service %s called on %s%s", - service_call.service, - coordinator.gateway.name, - rt_log, - ) - try: - await coordinator.gateway.async_set_scg_config( - super_chlor_timer=runtime, super_chlorinate=is_on - ) - # Debounced refresh to catch any secondary changes in the device - await coordinator.async_request_refresh() - except ScreenLogicError as error: - raise HomeAssistantError(error) from error - - async def async_start_super_chlor(service_call: ServiceCall) -> None: - runtime = service_call.data[ATTR_RUNTIME] - await async_set_super_chlor(service_call, True, runtime) - - async def async_stop_super_chlor(service_call: ServiceCall) -> None: - await async_set_super_chlor(service_call, False) - hass.services.async_register( - DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA + DOMAIN, SERVICE_SET_COLOR_MODE, _async_set_color_mode, SET_COLOR_MODE_SCHEMA ) hass.services.async_register( DOMAIN, SERVICE_START_SUPER_CHLORINATION, - async_start_super_chlor, + _async_start_super_chlor, TURN_ON_SUPER_CHLOR_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_STOP_SUPER_CHLORINATION, - async_stop_super_chlor, + _async_stop_super_chlor, BASE_SERVICE_SCHEMA, ) diff --git a/homeassistant/components/script/helpers.py b/homeassistant/components/script/helpers.py index 31aac506b35..53228517b18 100644 --- a/homeassistant/components/script/helpers.py +++ b/homeassistant/components/script/helpers.py @@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "script_blueprints" def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool: """Return True if any script references the blueprint.""" - from . import scripts_with_blueprint # pylint: disable=import-outside-toplevel + from . import scripts_with_blueprint # noqa: PLC0415 return len(scripts_with_blueprint(hass, blueprint_path)) > 0 diff --git a/homeassistant/components/senz/strings.json b/homeassistant/components/senz/strings.json index cb1f056d72d..32398c64c52 100644 --- a/homeassistant/components/senz/strings.json +++ b/homeassistant/components/senz/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 235a5338cb6..90fe9f325fa 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import SeventeenTrackCoordinator -from .services import setup_services +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -23,7 +23,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the 17Track component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index 34019208a14..19daedb1b5e 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.0.2"] + "requirements": ["pyseventeentrack==1.1.1"] } diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 5ba0b569b19..531ff2aea43 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -12,6 +12,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, selector @@ -70,100 +71,106 @@ SERVICE_ARCHIVE_PACKAGE_SCHEMA: Final = vol.Schema( ) -def setup_services(hass: HomeAssistant) -> None: +async def _get_packages(call: ServiceCall) -> ServiceResponse: + """Get packages from 17Track.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + package_states = call.data.get(ATTR_PACKAGE_STATE, []) + + await _validate_service(call.hass, config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ + config_entry_id + ] + live_packages = sorted( + await seventeen_coordinator.client.profile.packages( + show_archived=seventeen_coordinator.show_archived + ) + ) + + return { + "packages": [ + _package_to_dict(package) + for package in live_packages + if slugify(package.status) in package_states or package_states == [] + ] + } + + +async def _add_package(call: ServiceCall) -> None: + """Add a new package to 17Track.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] + friendly_name = call.data[ATTR_PACKAGE_FRIENDLY_NAME] + + await _validate_service(call.hass, config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ + config_entry_id + ] + + await seventeen_coordinator.client.profile.add_package( + tracking_number, friendly_name + ) + + +async def _archive_package(call: ServiceCall) -> None: + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] + + await _validate_service(call.hass, config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ + config_entry_id + ] + + await seventeen_coordinator.client.profile.archive_package(tracking_number) + + +def _package_to_dict(package: Package) -> dict[str, Any]: + result = { + ATTR_DESTINATION_COUNTRY: package.destination_country, + ATTR_ORIGIN_COUNTRY: package.origin_country, + ATTR_PACKAGE_TYPE: package.package_type, + ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + ATTR_TRACKING_NUMBER: package.tracking_number, + ATTR_LOCATION: package.location, + ATTR_STATUS: package.status, + ATTR_INFO_TEXT: package.info_text, + ATTR_FRIENDLY_NAME: package.friendly_name, + } + if timestamp := package.timestamp: + result[ATTR_TIMESTAMP] = timestamp.isoformat() + return result + + +async def _validate_service(hass: HomeAssistant, config_entry_id: str) -> None: + entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) + if not entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry_id": config_entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry_id": entry.title, + }, + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the seventeentrack integration.""" - async def get_packages(call: ServiceCall) -> ServiceResponse: - """Get packages from 17Track.""" - config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - package_states = call.data.get(ATTR_PACKAGE_STATE, []) - - await _validate_service(config_entry_id) - - seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ - config_entry_id - ] - live_packages = sorted( - await seventeen_coordinator.client.profile.packages( - show_archived=seventeen_coordinator.show_archived - ) - ) - - return { - "packages": [ - package_to_dict(package) - for package in live_packages - if slugify(package.status) in package_states or package_states == [] - ] - } - - async def add_package(call: ServiceCall) -> None: - """Add a new package to 17Track.""" - config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] - friendly_name = call.data[ATTR_PACKAGE_FRIENDLY_NAME] - - await _validate_service(config_entry_id) - - seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ - config_entry_id - ] - - await seventeen_coordinator.client.profile.add_package( - tracking_number, friendly_name - ) - - async def archive_package(call: ServiceCall) -> None: - config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] - - await _validate_service(config_entry_id) - - seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ - config_entry_id - ] - - await seventeen_coordinator.client.profile.archive_package(tracking_number) - - def package_to_dict(package: Package) -> dict[str, Any]: - result = { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } - if timestamp := package.timestamp: - result[ATTR_TIMESTAMP] = timestamp.isoformat() - return result - - async def _validate_service(config_entry_id): - entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) - if not entry: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_config_entry", - translation_placeholders={ - "config_entry_id": config_entry_id, - }, - ) - if entry.state != ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unloaded_config_entry", - translation_placeholders={ - "config_entry_id": entry.title, - }, - ) - hass.services.async_register( DOMAIN, SERVICE_GET_PACKAGES, - get_packages, + _get_packages, schema=SERVICE_GET_PACKAGES_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -171,13 +178,13 @@ def setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_ADD_PACKAGE, - add_package, + _add_package, schema=SERVICE_ADD_PACKAGE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_ARCHIVE_PACKAGE, - archive_package, + _archive_package, schema=SERVICE_ARCHIVE_PACKAGE_SCHEMA, ) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 3130acff538..75fedf9b16d 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -64,6 +64,7 @@ from .utils import ( get_http_port, get_rpc_scripts_event_types, get_ws_context, + remove_stale_blu_trv_devices, ) PLATFORMS: Final = [ @@ -300,6 +301,7 @@ 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( diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index eab7514514d..ad03a373dba 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -235,11 +235,15 @@ class ShellyButton(ShellyBaseButton): self._attr_unique_id = f"{coordinator.mac}_{description.key}" if isinstance(coordinator, ShellyBlockCoordinator): self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) else: self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 26fabe7e8b5..abc387f3efd 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -211,7 +211,10 @@ class BlockSleepingClimate( elif entry is not None: self._unique_id = entry.unique_id self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac, sensor_block + coordinator.device, + coordinator.mac, + sensor_block, + suggested_area=coordinator.suggested_area, ) self._attr_name = get_block_entity_name( self.coordinator.device, sensor_block, None diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f980ba8f914..cba559a9773 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -31,7 +31,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -114,6 +118,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( self.device = device self.device_id: str | None = None self._pending_platforms: list[Platform] | None = None + self.suggested_area: str | None = None device_name = device.name if device.initialized else entry.title interval_td = timedelta(seconds=update_interval) # The device has come online at least once. In the case of a sleeping RPC @@ -176,6 +181,11 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( hw_version=f"gen{get_device_entry_gen(self.config_entry)}", configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", ) + # We want to use the main device area as the suggested area for sub-devices. + if (area_id := device_entry.area_id) is not None: + area_registry = ar.async_get(self.hass) + if (area := area_registry.async_get_area(area_id)) is not None: + self.suggested_area = area.name self.device_id = device_entry.id async def shutdown(self) -> None: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 1b0078890af..5a420a4543b 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -362,7 +362,10 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): self.block = block self._attr_name = get_block_entity_name(coordinator.device, block) self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac, block + coordinator.device, + coordinator.mac, + block, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" @@ -405,7 +408,10 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): super().__init__(coordinator) self.key = key self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -521,7 +527,9 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): ) self._attr_unique_id = f"{coordinator.mac}-{attribute}" self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) self._last_value = None @@ -630,7 +638,10 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.entity_description = description self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac, block + coordinator.device, + coordinator.mac, + block, + suggested_area=coordinator.suggested_area, ) if block is not None: @@ -642,7 +653,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): ) elif entry is not None: self._attr_unique_id = entry.unique_id - self._attr_name = cast(str, entry.original_name) @callback def _update_callback(self) -> None: @@ -698,7 +708,10 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.entity_description = description self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = self._attr_unique_id = ( f"{coordinator.mac}-{key}-{attribute}" diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 677ea1f6138..2eb9ff00964 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -207,7 +207,10 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): super().__init__(coordinator) self.event_id = int(key.split(":")[-1]) self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml index 753b2ee4a93..39667b556dd 100644 --- a/homeassistant/components/shelly/quality_scale.yaml +++ b/homeassistant/components/shelly/quality_scale.yaml @@ -61,8 +61,8 @@ rules: reconfiguration-flow: done repair-issues: done stale-devices: - status: todo - comment: BLU TRV needs to be removed when un-paired + status: done + comment: BLU TRV is removed when un-paired # Platinum async-dependency: done diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 0ea246c7734..3a6f5f221c5 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -139,7 +139,11 @@ class RpcEmeterPhaseSensor(RpcSensor): super().__init__(coordinator, key, attribute, description) self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key, description.emeter_phase + coordinator.device, + coordinator.mac, + key, + emeter_phase=description.emeter_phase, + suggested_area=coordinator.suggested_area, ) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index eff5c95125c..953fcbace06 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -16,6 +16,7 @@ from aioshelly.const import ( DEFAULT_COAP_PORT, DEFAULT_HTTP_PORT, MODEL_1L, + MODEL_BLU_GATEWAY_G3, MODEL_DIMMER, MODEL_DIMMER_2, MODEL_EM3, @@ -750,6 +751,7 @@ def get_rpc_device_info( mac: str, key: str | None = None, emeter_phase: str | None = None, + suggested_area: str | None = None, ) -> DeviceInfo: """Return device info for RPC device.""" if key is None: @@ -769,6 +771,7 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}-{emeter_phase.lower()}")}, name=get_rpc_sub_device_name(device, key, emeter_phase), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) @@ -783,6 +786,7 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}")}, name=get_rpc_sub_device_name(device, key), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) @@ -804,7 +808,10 @@ def get_blu_trv_device_info( def get_block_device_info( - device: BlockDevice, mac: str, block: Block | None = None + device: BlockDevice, + mac: str, + block: Block | None = None, + suggested_area: str | None = None, ) -> DeviceInfo: """Return device info for Block device.""" if ( @@ -819,5 +826,35 @@ def get_block_device_info( identifiers={(DOMAIN, f"{mac}-{block.description}")}, name=get_block_sub_device_name(device, block), manufacturer="Shelly", + suggested_area=suggested_area, 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) diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 3037fbc98f6..ddb5c96db0a 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -19,7 +19,13 @@ "title": "Discovered Smappee device" }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py index c55b1067735..2de3fcfa242 100644 --- a/homeassistant/components/smarla/__init__.py +++ b/homeassistant/components/smarla/__init__.py @@ -22,12 +22,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) - federwiege = Federwiege(hass.loop, connection) federwiege.register() - federwiege.connect() entry.runtime_data = federwiege await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + federwiege.connect() + return True diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index 5e572c78536..8f7786bdf72 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], "quality_scale": "bronze", - "requirements": ["pysmarlaapi==0.8.2"] + "requirements": ["pysmarlaapi==0.9.0"] } diff --git a/homeassistant/components/smarla/number.py b/homeassistant/components/smarla/number.py index d2421962b07..c1a236e4557 100644 --- a/homeassistant/components/smarla/number.py +++ b/homeassistant/components/smarla/number.py @@ -53,9 +53,10 @@ class SmarlaNumber(SmarlaBaseEntity, NumberEntity): _property: Property[int] @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" - return self._property.get() + v = self._property.get() + return float(v) if v is not None else None def set_native_value(self, value: float) -> None: """Update to the smarla device.""" diff --git a/homeassistant/components/smarla/switch.py b/homeassistant/components/smarla/switch.py index d68f3428a77..f9b56fdea7e 100644 --- a/homeassistant/components/smarla/switch.py +++ b/homeassistant/components/smarla/switch.py @@ -52,7 +52,7 @@ class SmarlaSwitch(SmarlaBaseEntity, SwitchEntity): _property: Property[bool] @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the entity value to represent the entity state.""" return self._property.get() diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 481048c3bdb..7c3fc47e512 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.4"] + "requirements": ["pysmartthings==3.2.5"] } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 8e972ac8aea..5a1d111b617 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", @@ -599,7 +605,10 @@ "name": "Wrinkle prevent" }, "ice_maker": { - "name": "Ice maker" + "name": "Cubed ice" + }, + "ice_maker_2": { + "name": "Ice Bites" }, "sabbath_mode": { "name": "Sabbath mode" diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 56096dc6ab5..1f75e1976f6 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -95,6 +95,7 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio status_attribute=Attribute.SWITCH, component_translation_key={ "icemaker": "ice_maker", + "icemaker-02": "ice_maker_2", }, ), Capability.SAMSUNG_CE_SABBATH_MODE: SmartThingsSwitchEntityDescription( diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 24580971ae2..cbce25197b0 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -3,8 +3,6 @@ from __future__ import annotations import asyncio -from collections import OrderedDict -from dataclasses import dataclass, field import datetime from functools import partial from ipaddress import AddressValueError, IPv4Address @@ -25,9 +23,8 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -46,7 +43,6 @@ from homeassistant.util.async_ import create_eager_task from .alarms import SonosAlarms from .const import ( AVAILABILITY_CHECK_INTERVAL, - DATA_SONOS, DATA_SONOS_DISCOVERY_MANAGER, DISCOVERY_INTERVAL, DOMAIN, @@ -62,7 +58,7 @@ from .const import ( ) from .exception import SonosUpdateError from .favorites import SonosFavorites -from .helpers import sync_get_visible_zones +from .helpers import SonosConfigEntry, SonosData, sync_get_visible_zones from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -95,32 +91,6 @@ CONFIG_SCHEMA = vol.Schema( ) -@dataclass -class UnjoinData: - """Class to track data necessary for unjoin coalescing.""" - - speakers: list[SonosSpeaker] - event: asyncio.Event = field(default_factory=asyncio.Event) - - -class SonosData: - """Storage class for platform global data.""" - - def __init__(self) -> None: - """Initialize the data.""" - # OrderedDict behavior used by SonosAlarms and SonosFavorites - self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict() - self.favorites: dict[str, SonosFavorites] = {} - self.alarms: dict[str, SonosAlarms] = {} - self.topology_condition = asyncio.Condition() - self.hosts_heartbeat: CALLBACK_TYPE | None = None - self.discovery_known: set[str] = set() - self.boot_counts: dict[str, int] = {} - self.mdns_names: dict[str, str] = {} - self.entity_id_mappings: dict[str, SonosSpeaker] = {} - self.unjoin_data: dict[str, UnjoinData] = {} - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Sonos component.""" conf = config.get(DOMAIN) @@ -137,17 +107,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SonosConfigEntry) -> bool: """Set up Sonos from a config entry.""" soco_config.EVENTS_MODULE = events_asyncio soco_config.REQUEST_TIMEOUT = 9.5 soco_config.ZGT_EVENT_FALLBACK = False zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT - if DATA_SONOS not in hass.data: - hass.data[DATA_SONOS] = SonosData() + data = entry.runtime_data = SonosData() - data = hass.data[DATA_SONOS] config = hass.data[DOMAIN].get("media_player", {}) hosts = config.get(CONF_HOSTS, []) _LOGGER.debug("Reached async_setup_entry, config=%s", config) @@ -172,12 +140,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: SonosConfigEntry +) -> bool: """Unload a Sonos config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown() - hass.data.pop(DATA_SONOS) - hass.data.pop(DATA_SONOS_DISCOVERY_MANAGER) return unload_ok @@ -185,7 +155,11 @@ class SonosDiscoveryManager: """Manage sonos discovery.""" def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, data: SonosData, hosts: list[str] + self, + hass: HomeAssistant, + entry: SonosConfigEntry, + data: SonosData, + hosts: list[str], ) -> None: """Init discovery manager.""" self.hass = hass @@ -380,7 +354,9 @@ class SonosDiscoveryManager: if soco.uid not in self.data.boot_counts: self.data.boot_counts[soco.uid] = soco.boot_seqnum _LOGGER.debug("Adding new speaker: %s", speaker_info) - speaker = SonosSpeaker(self.hass, soco, speaker_info, zone_group_state_sub) + speaker = SonosSpeaker( + self.hass, self.entry, soco, speaker_info, zone_group_state_sub + ) self.data.discovered[soco.uid] = speaker for coordinator, coord_dict in ( (SonosAlarms, self.data.alarms), @@ -388,7 +364,9 @@ class SonosDiscoveryManager: ): c_dict: dict[str, Any] = coord_dict if soco.household_id not in c_dict: - new_coordinator = coordinator(self.hass, soco.household_id) + new_coordinator = coordinator( + self.hass, soco.household_id, self.entry + ) new_coordinator.setup(soco) c_dict[soco.household_id] = new_coordinator speaker.setup(self.entry) @@ -622,10 +600,10 @@ class SonosDiscoveryManager: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: SonosConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove Sonos config entry from a device.""" - known_devices = hass.data[DATA_SONOS].discovered.keys() + known_devices = config_entry.runtime_data.discovered.keys() for identifier in device_entry.identifiers: if identifier[0] != DOMAIN: continue diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index afbff8baa6d..c3c3b14545f 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -12,7 +12,7 @@ from soco.events_base import Event as SonosEvent from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM +from .const import SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM from .helpers import soco_error from .household_coordinator import SonosHouseholdCoordinator @@ -52,7 +52,7 @@ class SonosAlarms(SonosHouseholdCoordinator): for alarm_id, alarm in self.alarms.alarms.items(): if alarm_id in self.created_alarm_ids: continue - speaker = self.hass.data[DATA_SONOS].discovered.get(alarm.zone.uid) + speaker = self.config_entry.runtime_data.discovered.get(alarm.zone.uid) if speaker: async_dispatcher_send( self.hass, SONOS_CREATE_ALARM, speaker, [alarm_id] diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index e2e981b293c..8a4c3abe248 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -17,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SONOS_CREATE_BATTERY, SONOS_CREATE_MIC_SENSOR from .entity import SonosEntity -from .helpers import soco_error +from .helpers import SonosConfigEntry, soco_error from .speaker import SonosSpeaker ATTR_BATTERY_POWER_SOURCE = "power_source" @@ -27,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" @@ -35,13 +34,13 @@ async def async_setup_entry( @callback def _async_create_battery_entity(speaker: SonosSpeaker) -> None: _LOGGER.debug("Creating battery binary_sensor on %s", speaker.zone_name) - entity = SonosPowerEntity(speaker) + entity = SonosPowerEntity(speaker, config_entry) async_add_entities([entity]) @callback def _async_create_mic_entity(speaker: SonosSpeaker) -> None: _LOGGER.debug("Creating microphone binary_sensor on %s", speaker.zone_name) - async_add_entities([SonosMicrophoneSensorEntity(speaker)]) + async_add_entities([SonosMicrophoneSensorEntity(speaker, config_entry)]) config_entry.async_on_unload( async_dispatcher_connect( @@ -62,9 +61,9 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize the power entity binary sensor.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-power" async def _async_fallback_poll(self) -> None: @@ -95,9 +94,9 @@ class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_translation_key = "microphone" - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize the microphone binary sensor entity.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-microphone" async def _async_fallback_poll(self) -> None: diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 614be2b1817..76e0a915060 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -10,7 +10,6 @@ from homeassistant.const import Platform UPNP_ST = "urn:schemas-upnp-org:device:ZonePlayer:1" DOMAIN = "sonos" -DATA_SONOS = "sonos_media_player" DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" PLATFORMS = [ Platform.BINARY_SENSOR, diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index a0207af77ab..35d81edbea0 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -5,11 +5,11 @@ from __future__ import annotations import time from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import DATA_SONOS, DOMAIN +from .const import DOMAIN +from .helpers import SonosConfigEntry from .speaker import SonosSpeaker MEDIA_DIAGNOSTIC_ATTRIBUTES = ( @@ -45,27 +45,29 @@ SPEAKER_DIAGNOSTIC_ATTRIBUTES = ( async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: SonosConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" payload: dict[str, Any] = {"current_timestamp": time.monotonic()} for section in ("discovered", "discovery_known"): payload[section] = {} - data: set[Any] | dict[str, Any] = getattr(hass.data[DATA_SONOS], section) + data: set[Any] | dict[str, Any] = getattr(config_entry.runtime_data, section) if isinstance(data, set): payload[section] = data continue for key, value in data.items(): if isinstance(value, SonosSpeaker): - payload[section][key] = await async_generate_speaker_info(hass, value) + payload[section][key] = await async_generate_speaker_info( + hass, config_entry, value + ) else: payload[section][key] = value return payload async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: SonosConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" uid = next( @@ -75,10 +77,10 @@ async def async_get_device_diagnostics( if uid is None: return {} - if (speaker := hass.data[DATA_SONOS].discovered.get(uid)) is None: + if (speaker := config_entry.runtime_data.discovered.get(uid)) is None: return {} - return await async_generate_speaker_info(hass, speaker) + return await async_generate_speaker_info(hass, config_entry, speaker) async def async_generate_media_info( @@ -107,7 +109,7 @@ async def async_generate_media_info( async def async_generate_speaker_info( - hass: HomeAssistant, speaker: SonosSpeaker + hass: HomeAssistant, config_entry: SonosConfigEntry, speaker: SonosSpeaker ) -> dict[str, Any]: """Generate the diagnostic payload for a specific speaker.""" payload: dict[str, Any] = {} @@ -132,7 +134,7 @@ async def async_generate_speaker_info( payload["enabled_entities"] = sorted( entity_id - for entity_id, s in hass.data[DATA_SONOS].entity_id_mappings.items() + for entity_id, s in config_entry.runtime_data.entity_id_mappings.items() if s is speaker ) payload["media"] = await async_generate_media_info(hass, speaker) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index a9a76b3b4d0..58108f9974c 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -13,8 +13,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DATA_SONOS, DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED +from .const import DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED from .exception import SonosUpdateError +from .helpers import SonosConfigEntry from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -26,13 +27,14 @@ class SonosEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize a SonosEntity.""" self.speaker = speaker + self.config_entry = config_entry async def async_added_to_hass(self) -> None: """Handle common setup when added to hass.""" - self.hass.data[DATA_SONOS].entity_id_mappings[self.entity_id] = self.speaker + self.config_entry.runtime_data.entity_id_mappings[self.entity_id] = self.speaker self.async_on_remove( async_dispatcher_connect( self.hass, @@ -50,7 +52,7 @@ class SonosEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" - del self.hass.data[DATA_SONOS].entity_id_mappings[self.entity_id] + del self.config_entry.runtime_data.entity_id_mappings[self.entity_id] async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 8ced5a87b28..3350df430f8 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio +from collections import OrderedDict from collections.abc import Callable +from dataclasses import dataclass, field import logging from typing import TYPE_CHECKING, Any, Concatenate, overload @@ -10,13 +13,17 @@ from requests.exceptions import Timeout from soco import SoCo from soco.exceptions import SoCoException, SoCoUPnPException +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers.dispatcher import dispatcher_send from .const import SONOS_SPEAKER_ACTIVITY from .exception import SonosUpdateError if TYPE_CHECKING: + from .alarms import SonosAlarms from .entity import SonosEntity + from .favorites import SonosFavorites from .household_coordinator import SonosHouseholdCoordinator from .media import SonosMedia from .speaker import SonosSpeaker @@ -120,3 +127,30 @@ def sync_get_visible_zones(soco: SoCo) -> set[SoCo]: _ = soco.household_id _ = soco.uid return soco.visible_zones + + +@dataclass +class UnjoinData: + """Class to track data necessary for unjoin coalescing.""" + + speakers: list[SonosSpeaker] = field(default_factory=list) + event: asyncio.Event = field(default_factory=asyncio.Event) + + +@dataclass +class SonosData: + """Storage class for platform global data.""" + + discovered: OrderedDict[str, SonosSpeaker] = field(default_factory=OrderedDict) + favorites: dict[str, SonosFavorites] = field(default_factory=dict) + alarms: dict[str, SonosAlarms] = field(default_factory=dict) + topology_condition: asyncio.Condition = field(default_factory=asyncio.Condition) + hosts_heartbeat: CALLBACK_TYPE | None = None + discovery_known: set[str] = field(default_factory=set) + boot_counts: dict[str, int] = field(default_factory=dict) + mdns_names: dict[str, str] = field(default_factory=dict) + entity_id_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) + unjoin_data: dict[str, UnjoinData] = field(default_factory=dict) + + +type SonosConfigEntry = ConfigEntry[SonosData] diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index 8fcecdf4d5e..a2c128dce94 100644 --- a/homeassistant/components/sonos/household_coordinator.py +++ b/homeassistant/components/sonos/household_coordinator.py @@ -5,16 +5,18 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine import logging -from typing import Any +from typing import TYPE_CHECKING, Any from soco import SoCo from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from .const import DATA_SONOS from .exception import SonosUpdateError +if TYPE_CHECKING: + from .helpers import SonosConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -23,12 +25,15 @@ class SonosHouseholdCoordinator: cache_update_lock: asyncio.Lock - def __init__(self, hass: HomeAssistant, household_id: str) -> None: + def __init__( + self, hass: HomeAssistant, household_id: str, config_entry: SonosConfigEntry + ) -> None: """Initialize the data.""" self.hass = hass self.household_id = household_id self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None self.last_processed_event_id: int | None = None + self.config_entry = config_entry def setup(self, soco: SoCo) -> None: """Set up the SonosAlarm instance.""" @@ -54,7 +59,7 @@ class SonosHouseholdCoordinator: async def _async_poll(self) -> None: """Poll any known speaker.""" - discovered = self.hass.data[DATA_SONOS].discovered + discovered = self.config_entry.runtime_data.discovered for uid, speaker in discovered.items(): _LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index f1f95659469..96e4d34ddc4 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations import datetime from functools import partial import logging -from typing import Any +from typing import TYPE_CHECKING, Any from soco import SoCo, alarms from soco.core import ( @@ -40,7 +40,6 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.plex import PLEX_URI_SCHEME from homeassistant.components.plex.services import process_plex_payload -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -49,9 +48,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from . import UnjoinData, media_browser +from . import media_browser from .const import ( - DATA_SONOS, DOMAIN, MEDIA_TYPE_DIRECTORY, MEDIA_TYPES_TO_SONOS, @@ -67,9 +65,12 @@ from .const import ( SOURCE_TV, ) from .entity import SonosEntity -from .helpers import soco_error +from .helpers import UnjoinData, soco_error from .speaker import SonosMedia, SonosSpeaker +if TYPE_CHECKING: + from .helpers import SonosConfigEntry + _LOGGER = logging.getLogger(__name__) LONG_SERVICE_TIMEOUT = 30.0 @@ -108,7 +109,7 @@ ATTR_QUEUE_POSITION = "queue_position" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" @@ -118,7 +119,7 @@ async def async_setup_entry( def async_create_entities(speaker: SonosSpeaker) -> None: """Handle device discovery and create entities.""" _LOGGER.debug("Creating media_player on %s", speaker.zone_name) - async_add_entities([SonosMediaPlayerEntity(speaker)]) + async_add_entities([SonosMediaPlayerEntity(speaker, config_entry)]) @service.verify_domain_control(hass, DOMAIN) async def async_service_handle(service_call: ServiceCall) -> None: @@ -136,11 +137,11 @@ async def async_setup_entry( if service_call.service == SERVICE_SNAPSHOT: await SonosSpeaker.snapshot_multi( - hass, speakers, service_call.data[ATTR_WITH_GROUP] + hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] ) elif service_call.service == SERVICE_RESTORE: await SonosSpeaker.restore_multi( - hass, speakers, service_call.data[ATTR_WITH_GROUP] + hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] ) config_entry.async_on_unload( @@ -231,9 +232,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC _attr_device_class = MediaPlayerDeviceClass.SPEAKER - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize the media player entity.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = self.soco.uid async def async_added_to_hass(self) -> None: @@ -298,9 +299,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def _async_fallback_poll(self) -> None: """Retrieve latest state by polling.""" - await ( - self.hass.data[DATA_SONOS].favorites[self.speaker.household_id].async_poll() - ) + favorites = self.config_entry.runtime_data.favorites[self.speaker.household_id] + assert favorites.async_poll + await favorites.async_poll() await self.hass.async_add_executor_job(self._update) def _update(self) -> None: @@ -880,12 +881,16 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Join `group_members` as a player group with the current player.""" speakers = [] for entity_id in group_members: - if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get(entity_id): + if speaker := self.config_entry.runtime_data.entity_id_mappings.get( + entity_id + ): speakers.append(speaker) else: raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") - await SonosSpeaker.join_multi(self.hass, self.speaker, speakers) + await SonosSpeaker.join_multi( + self.hass, self.config_entry, self.speaker, speakers + ) async def async_unjoin_player(self) -> None: """Remove this player from any group. @@ -894,7 +899,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): which optimizes the order in which speakers are removed from their groups. Removing coordinators last better preserves playqueues on the speakers. """ - sonos_data = self.hass.data[DATA_SONOS] + sonos_data = self.config_entry.runtime_data household_id = self.speaker.household_id async def async_process_unjoin(now: datetime.datetime) -> None: @@ -903,7 +908,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): _LOGGER.debug( "Processing unjoins for %s", [x.zone_name for x in unjoin_data.speakers] ) - await SonosSpeaker.unjoin_multi(self.hass, unjoin_data.speakers) + await SonosSpeaker.unjoin_multi( + self.hass, self.config_entry, unjoin_data.speakers + ) unjoin_data.event.set() if unjoin_data := sonos_data.unjoin_data.get(household_id): diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index c23ba51a877..8e4b4fb5b42 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -6,7 +6,6 @@ import logging from typing import cast from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -14,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SONOS_CREATE_LEVELS from .entity import SonosEntity -from .helpers import soco_error +from .helpers import SonosConfigEntry, soco_error from .speaker import SonosSpeaker LEVEL_TYPES = { @@ -69,7 +68,7 @@ LEVEL_FROM_NUMBER = {"balance": _balance_from_number} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sonos number platform from a config entry.""" @@ -93,7 +92,9 @@ async def async_setup_entry( _LOGGER.debug( "Creating %s number control on %s", level_type, speaker.zone_name ) - entities.append(SonosLevelEntity(speaker, level_type, valid_range)) + entities.append( + SonosLevelEntity(speaker, config_entry, level_type, valid_range) + ) async_add_entities(entities) config_entry.async_on_unload( @@ -107,10 +108,14 @@ class SonosLevelEntity(SonosEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, speaker: SonosSpeaker, level_type: str, valid_range: tuple[int, int] + self, + speaker: SonosSpeaker, + config_entry: SonosConfigEntry, + level_type: str, + valid_range: tuple[int, int], ) -> None: """Initialize the level entity.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-{level_type}" self._attr_translation_key = level_type self.level_type = level_type diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index d888ee669bb..6b507ec910a 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,7 +19,7 @@ from .const import ( ) from .entity import SonosEntity, SonosPollingEntity from .favorites import SonosFavorites -from .helpers import soco_error +from .helpers import SonosConfigEntry, soco_error from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -28,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" @@ -38,13 +37,13 @@ async def async_setup_entry( speaker: SonosSpeaker, audio_format: str ) -> None: _LOGGER.debug("Creating audio input format sensor on %s", speaker.zone_name) - entity = SonosAudioInputFormatSensorEntity(speaker, audio_format) + entity = SonosAudioInputFormatSensorEntity(speaker, config_entry, audio_format) async_add_entities([entity]) @callback def _async_create_battery_sensor(speaker: SonosSpeaker) -> None: _LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name) - entity = SonosBatteryEntity(speaker) + entity = SonosBatteryEntity(speaker, config_entry) async_add_entities([entity]) @callback @@ -82,9 +81,9 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize the battery sensor.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-battery" async def _async_fallback_poll(self) -> None: @@ -109,9 +108,11 @@ class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity): _attr_translation_key = "audio_input_format" _attr_should_poll = True - def __init__(self, speaker: SonosSpeaker, audio_format: str) -> None: + def __init__( + self, speaker: SonosSpeaker, config_entry: SonosConfigEntry, audio_format: str + ) -> None: """Initialize the audio input format sensor.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-audio-format" self._attr_native_value = audio_format diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index d339e861a13..aee0a40c184 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -21,7 +21,6 @@ from soco.snapshot import Snapshot from sonos_websocket import SonosWebsocket from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -38,7 +37,6 @@ from .alarms import SonosAlarms from .const import ( AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, - DATA_SONOS, DOMAIN, SCAN_INTERVAL, SONOS_CHECK_ACTIVITY, @@ -66,7 +64,8 @@ from .media import SonosMedia from .statistics import ActivityStatistics, EventStatistics if TYPE_CHECKING: - from . import SonosData + from .helpers import SonosConfigEntry + NEVER_TIME = -1200.0 RESUB_COOLDOWN_SECONDS = 10.0 @@ -95,13 +94,15 @@ class SonosSpeaker: def __init__( self, hass: HomeAssistant, + config_entry: SonosConfigEntry, soco: SoCo, speaker_info: dict[str, Any], zone_group_state_sub: SubscriptionBase | None, ) -> None: """Initialize a SonosSpeaker.""" self.hass = hass - self.data: SonosData = hass.data[DATA_SONOS] + self.config_entry = config_entry + self.data = config_entry.runtime_data self.soco = soco self.websocket: SonosWebsocket | None = None self.household_id: str = soco.household_id @@ -179,7 +180,10 @@ class SonosSpeaker: self._group_members_missing: set[str] = set() async def async_setup( - self, entry: ConfigEntry, has_battery: bool, dispatches: list[tuple[Any, ...]] + self, + entry: SonosConfigEntry, + has_battery: bool, + dispatches: list[tuple[Any, ...]], ) -> None: """Complete setup in async context.""" # Battery events can be infrequent, polling is still necessary @@ -216,7 +220,7 @@ class SonosSpeaker: await self.async_subscribe() - def setup(self, entry: ConfigEntry) -> None: + def setup(self, entry: SonosConfigEntry) -> None: """Run initial setup of the speaker.""" self.media.play_mode = self.soco.play_mode self.update_volume() @@ -961,15 +965,16 @@ class SonosSpeaker: @staticmethod async def join_multi( hass: HomeAssistant, + config_entry: SonosConfigEntry, master: SonosSpeaker, speakers: list[SonosSpeaker], ) -> None: """Form a group with other players.""" - async with hass.data[DATA_SONOS].topology_condition: + async with config_entry.runtime_data.topology_condition: group: list[SonosSpeaker] = await hass.async_add_executor_job( master.join, speakers ) - await SonosSpeaker.wait_for_groups(hass, [group]) + await SonosSpeaker.wait_for_groups(hass, config_entry, [group]) @soco_error() def unjoin(self) -> None: @@ -980,7 +985,11 @@ class SonosSpeaker: self.coordinator = None @staticmethod - async def unjoin_multi(hass: HomeAssistant, speakers: list[SonosSpeaker]) -> None: + async def unjoin_multi( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + speakers: list[SonosSpeaker], + ) -> None: """Unjoin several players from their group.""" def _unjoin_all(speakers: list[SonosSpeaker]) -> None: @@ -992,9 +1001,11 @@ class SonosSpeaker: for speaker in joined_speakers + coordinators: speaker.unjoin() - async with hass.data[DATA_SONOS].topology_condition: + async with config_entry.runtime_data.topology_condition: await hass.async_add_executor_job(_unjoin_all, speakers) - await SonosSpeaker.wait_for_groups(hass, [[s] for s in speakers]) + await SonosSpeaker.wait_for_groups( + hass, config_entry, [[s] for s in speakers] + ) @soco_error() def snapshot(self, with_group: bool) -> None: @@ -1008,7 +1019,10 @@ class SonosSpeaker: @staticmethod async def snapshot_multi( - hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool + hass: HomeAssistant, + config_entry: SonosConfigEntry, + speakers: list[SonosSpeaker], + with_group: bool, ) -> None: """Snapshot all the speakers and optionally their groups.""" @@ -1023,7 +1037,7 @@ class SonosSpeaker: for speaker in list(speakers_set): speakers_set.update(speaker.sonos_group) - async with hass.data[DATA_SONOS].topology_condition: + async with config_entry.runtime_data.topology_condition: await hass.async_add_executor_job(_snapshot_all, speakers_set) @soco_error() @@ -1041,7 +1055,10 @@ class SonosSpeaker: @staticmethod async def restore_multi( - hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool + hass: HomeAssistant, + config_entry: SonosConfigEntry, + speakers: list[SonosSpeaker], + with_group: bool, ) -> None: """Restore snapshots for all the speakers.""" @@ -1119,16 +1136,18 @@ class SonosSpeaker: assert len(speaker.snapshot_group) speakers_set.update(speaker.snapshot_group) - async with hass.data[DATA_SONOS].topology_condition: + async with config_entry.runtime_data.topology_condition: groups = await hass.async_add_executor_job( _restore_groups, speakers_set, with_group ) - await SonosSpeaker.wait_for_groups(hass, groups) + await SonosSpeaker.wait_for_groups(hass, config_entry, groups) await hass.async_add_executor_job(_restore_players, speakers_set) @staticmethod async def wait_for_groups( - hass: HomeAssistant, groups: list[list[SonosSpeaker]] + hass: HomeAssistant, + config_entry: SonosConfigEntry, + groups: list[list[SonosSpeaker]], ) -> None: """Wait until all groups are present, or timeout.""" @@ -1151,11 +1170,11 @@ class SonosSpeaker: try: async with asyncio.timeout(5): while not _test_groups(groups): - await hass.data[DATA_SONOS].topology_condition.wait() + await config_entry.runtime_data.topology_condition.wait() except TimeoutError: _LOGGER.warning("Timeout waiting for target groups %s", groups) - any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values())) + any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() # diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 052dbd990b2..582845d10a2 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -10,7 +10,6 @@ from soco.alarms import Alarm from soco.exceptions import SoCoSlaveException, SoCoUPnPException from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -18,15 +17,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_change +from .alarms import SonosAlarms from .const import ( - DATA_SONOS, DOMAIN, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, SONOS_CREATE_SWITCHES, ) from .entity import SonosEntity, SonosPollingEntity -from .helpers import soco_error +from .helpers import SonosConfigEntry, soco_error from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -73,22 +72,22 @@ WEEKEND_DAYS = (0, 6) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" async def _async_create_alarms(speaker: SonosSpeaker, alarm_ids: list[str]) -> None: entities = [] - created_alarms = ( - hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids - ) + created_alarms = config_entry.runtime_data.alarms[ + speaker.household_id + ].created_alarm_ids for alarm_id in alarm_ids: if alarm_id in created_alarms: continue _LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name) created_alarms.add(alarm_id) - entities.append(SonosAlarmEntity(alarm_id, speaker)) + entities.append(SonosAlarmEntity(alarm_id, speaker, config_entry)) async_add_entities(entities) def available_soco_attributes(speaker: SonosSpeaker) -> list[str]: @@ -113,7 +112,7 @@ async def async_setup_entry( feature_type, speaker.zone_name, ) - entities.append(SonosSwitchEntity(feature_type, speaker)) + entities.append(SonosSwitchEntity(feature_type, speaker, config_entry)) async_add_entities(entities) config_entry.async_on_unload( @@ -127,9 +126,11 @@ async def async_setup_entry( class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): """Representation of a Sonos feature switch.""" - def __init__(self, feature_type: str, speaker: SonosSpeaker) -> None: + def __init__( + self, feature_type: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry + ) -> None: """Initialize the switch.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self.feature_type = feature_type self.needs_coordinator = feature_type in COORDINATOR_FEATURES self._attr_entity_category = EntityCategory.CONFIG @@ -185,9 +186,11 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:alarm" - def __init__(self, alarm_id: str, speaker: SonosSpeaker) -> None: + def __init__( + self, alarm_id: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry + ) -> None: """Initialize the switch.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"alarm-{speaker.household_id}:{alarm_id}" self.alarm_id = alarm_id self.household_id = speaker.household_id @@ -218,7 +221,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): @property def alarm(self) -> Alarm: """Return the alarm instance.""" - return self.hass.data[DATA_SONOS].alarms[self.household_id].get(self.alarm_id) + return self.config_entry.runtime_data.alarms[self.household_id].get( + self.alarm_id + ) @property def name(self) -> str: @@ -230,7 +235,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): async def _async_fallback_poll(self) -> None: """Call the central alarm polling method.""" - await self.hass.data[DATA_SONOS].alarms[self.household_id].async_poll() + alarms: SonosAlarms = self.config_entry.runtime_data.alarms[self.household_id] + assert alarms.async_poll + await alarms.async_poll() @callback def async_check_if_available(self) -> bool: @@ -252,9 +259,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): return if self.speaker.soco.uid != self.alarm.zone.uid: - self.speaker = self.hass.data[DATA_SONOS].discovered.get( - self.alarm.zone.uid - ) + speaker = self.config_entry.runtime_data.discovered.get(self.alarm.zone.uid) + assert speaker + self.speaker = speaker if self.speaker is None: raise RuntimeError( "No configured Sonos speaker has been found to match the alarm." diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 66d837c503f..352a2fb7fa2 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py index 88018e4f9a9..887151036aa 100644 --- a/homeassistant/components/squeezebox/button.py +++ b/homeassistant/components/squeezebox/button.py @@ -153,11 +153,6 @@ 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) diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py index 2c443c24ffd..95fd2d60461 100644 --- a/homeassistant/components/squeezebox/entity.py +++ b/homeassistant/components/squeezebox/entity.py @@ -33,6 +33,13 @@ 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.""" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 1e803c0e1ef..b29e19c1e3c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -246,11 +246,6 @@ 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.""" diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py index 3a164fa374b..b6e105b9560 100644 --- a/homeassistant/components/ssdp/server.py +++ b/homeassistant/components/ssdp/server.py @@ -97,7 +97,7 @@ async def _async_find_next_available_port(source: AddressTupleVXType) -> int: test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): - addr = (source[0],) + (port,) + source[2:] + addr = (source[0], port, *source[2:]) try: test_socket.bind(addr) except OSError: diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 8fa4c69ac5a..9426b5b04de 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -119,7 +119,7 @@ def _check_stream_client_error( Raise StreamOpenClientError if an http client error is encountered. """ - from .worker import try_open_stream # pylint: disable=import-outside-toplevel + from .worker import try_open_stream # noqa: PLC0415 pyav_options, _ = _convert_stream_options(hass, source, options or {}) try: @@ -234,7 +234,7 @@ CONFIG_SCHEMA = vol.Schema( def set_pyav_logging(enable: bool) -> None: """Turn PyAV logging on or off.""" - import av # pylint: disable=import-outside-toplevel + import av # noqa: PLC0415 av.logging.set_level(av.logging.VERBOSE if enable else av.logging.FATAL) @@ -267,8 +267,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(set_pyav_logging, debug_enabled) # Keep import here so that we can import stream integration without installing reqs - # pylint: disable-next=import-outside-toplevel - from .recorder import async_setup_recorder + from .recorder import async_setup_recorder # noqa: PLC0415 hass.data[DOMAIN] = {} hass.data[DOMAIN][ATTR_ENDPOINTS] = {} @@ -460,8 +459,7 @@ class Stream: def _run_worker(self) -> None: """Handle consuming streams and restart keepalive streams.""" # Keep import here so that we can import stream integration without installing reqs - # pylint: disable-next=import-outside-toplevel - from .worker import StreamState, stream_worker + from .worker import StreamState, stream_worker # noqa: PLC0415 stream_state = StreamState(self.hass, self.outputs, self._diagnostics) wait_timeout = 0 @@ -556,8 +554,7 @@ class Stream: """Make a .mp4 recording from a provided stream.""" # Keep import here so that we can import stream integration without installing reqs - # pylint: disable-next=import-outside-toplevel - from .recorder import RecorderOutput + from .recorder import RecorderOutput # noqa: PLC0415 # Check for file access if not self.hass.config.is_allowed_path(video_path): diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index b804055a740..44dfe2c323d 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -439,8 +439,9 @@ class KeyFrameConverter: # Keep import here so that we can import stream integration # without installing reqs - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.camera.img_util import TurboJPEGSingleton + from homeassistant.components.camera.img_util import ( # noqa: PLC0415 + TurboJPEGSingleton, + ) self._packet: Packet | None = None self._event: asyncio.Event = asyncio.Event() @@ -471,8 +472,7 @@ class KeyFrameConverter: # Keep import here so that we can import stream integration without # installing reqs - # pylint: disable-next=import-outside-toplevel - from av import CodecContext + from av import CodecContext # noqa: PLC0415 self._codec_context = cast( "VideoCodecContext", CodecContext.create(codec_context.name, "r") diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py index 5080678e3ca..3d2c40c752b 100644 --- a/homeassistant/components/stream/fmp4utils.py +++ b/homeassistant/components/stream/fmp4utils.py @@ -146,11 +146,11 @@ def get_codec_string(mp4_bytes: bytes) -> str: return ",".join(codecs) -def find_moov(mp4_io: BufferedIOBase) -> int: +def find_moov(mp4_io: BufferedIOBase) -> int: # noqa: RET503 """Find location of moov atom in a BufferedIOBase mp4.""" index = 0 # Ruff doesn't understand this loop - the exception is always raised at the end - while 1: # noqa: RET503 + while 1: mp4_io.seek(index) box_header = mp4_io.read(8) if len(box_header) != 8 or box_header[0:4] == b"\x00\x00\x00\x00": diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 0d0c4dc6169..49fe9949772 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -35,7 +35,7 @@ from .coordinator import ( SwissPublicTransportDataUpdateCoordinator, ) from .helper import offset_opendata, unique_id_from_config -from .services import setup_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -47,7 +47,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Swiss public transport component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/swiss_public_transport/services.py b/homeassistant/components/swiss_public_transport/services.py index 3abf1a14b9f..1ac116b4ca9 100644 --- a/homeassistant/components/swiss_public_transport/services.py +++ b/homeassistant/components/swiss_public_transport/services.py @@ -8,6 +8,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.selector import ( @@ -39,7 +40,7 @@ SERVICE_FETCH_CONNECTIONS_SCHEMA = vol.Schema( ) -def async_get_entry( +def _async_get_entry( hass: HomeAssistant, config_entry_id: str ) -> SwissPublicTransportConfigEntry: """Get the Swiss public transport config entry.""" @@ -58,34 +59,36 @@ def async_get_entry( return entry -def setup_services(hass: HomeAssistant) -> None: +async def _async_fetch_connections( + call: ServiceCall, +) -> ServiceResponse: + """Fetch a set of connections.""" + config_entry = _async_get_entry(call.hass, call.data[ATTR_CONFIG_ENTRY_ID]) + + limit = call.data.get(ATTR_LIMIT) or CONNECTIONS_COUNT + try: + connections = await config_entry.runtime_data.fetch_connections_as_json( + limit=int(limit) + ) + except UpdateFailed as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "error": str(e), + }, + ) from e + return {"connections": connections} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Swiss public transport integration.""" - async def async_fetch_connections( - call: ServiceCall, - ) -> ServiceResponse: - """Fetch a set of connections.""" - config_entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - - limit = call.data.get(ATTR_LIMIT) or CONNECTIONS_COUNT - try: - connections = await config_entry.runtime_data.fetch_connections_as_json( - limit=int(limit) - ) - except UpdateFailed as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - translation_placeholders={ - "error": str(e), - }, - ) from e - return {"connections": connections} - hass.services.async_register( DOMAIN, SERVICE_FETCH_CONNECTIONS, - async_fetch_connections, + _async_fetch_connections, schema=SERVICE_FETCH_CONNECTIONS_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index b3b40d975ce..e568ce5a6d1 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -45,7 +45,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Synology DSM component.""" - await async_setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/synology_dsm/services.py b/homeassistant/components/synology_dsm/services.py index 40b6fd4bc30..9522361d500 100644 --- a/homeassistant/components/synology_dsm/services.py +++ b/homeassistant/components/synology_dsm/services.py @@ -7,7 +7,7 @@ from typing import cast from synology_dsm.exceptions import SynologyDSMException -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES from .coordinator import SynologyDSMConfigEntry @@ -15,63 +15,63 @@ from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) -async def async_setup_services(hass: HomeAssistant) -> None: - """Service handler setup.""" +async def _service_handler(call: ServiceCall) -> None: + """Handle service call.""" + serial: str | None = call.data.get(CONF_SERIAL) + entries: list[SynologyDSMConfigEntry] = ( + call.hass.config_entries.async_loaded_entries(DOMAIN) + ) + dsm_devices = {cast(str, entry.unique_id): entry.runtime_data for entry in entries} - async def service_handler(call: ServiceCall) -> None: - """Handle service call.""" - serial: str | None = call.data.get(CONF_SERIAL) - entries: list[SynologyDSMConfigEntry] = ( - hass.config_entries.async_loaded_entries(DOMAIN) + if serial: + entry: SynologyDSMConfigEntry | None = ( + call.hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) ) - dsm_devices = { - cast(str, entry.unique_id): entry.runtime_data for entry in entries - } + assert entry + dsm_device = entry.runtime_data + elif len(dsm_devices) == 1: + dsm_device = next(iter(dsm_devices.values())) + serial = next(iter(dsm_devices)) + else: + LOGGER.error( + "More than one DSM configured, must specify one of serials %s", + sorted(dsm_devices), + ) + return - if serial: - entry: SynologyDSMConfigEntry | None = ( - hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) - ) - assert entry - dsm_device = entry.runtime_data - elif len(dsm_devices) == 1: - dsm_device = next(iter(dsm_devices.values())) - serial = next(iter(dsm_devices)) - else: - LOGGER.error( - "More than one DSM configured, must specify one of serials %s", - sorted(dsm_devices), - ) - return + if not dsm_device: + LOGGER.error("DSM with specified serial %s not found", serial) + return - if not dsm_device: + if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: + if serial not in dsm_devices: LOGGER.error("DSM with specified serial %s not found", serial) return - - if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: - if serial not in dsm_devices: - LOGGER.error("DSM with specified serial %s not found", serial) - return - LOGGER.debug("%s DSM with serial %s", call.service, serial) - LOGGER.warning( - ( - "The %s service is deprecated and will be removed in future" - " release. Please use the corresponding button entity" - ), + LOGGER.debug("%s DSM with serial %s", call.service, serial) + LOGGER.warning( + ( + "The %s service is deprecated and will be removed in future" + " release. Please use the corresponding button entity" + ), + call.service, + ) + dsm_device = dsm_devices[serial] + dsm_api = dsm_device.api + try: + await getattr(dsm_api, f"async_{call.service}")() + except SynologyDSMException as ex: + LOGGER.error( + "%s of DSM with serial %s not possible, because of %s", call.service, + serial, + ex, ) - dsm_device = dsm_devices[serial] - dsm_api = dsm_device.api - try: - await getattr(dsm_api, f"async_{call.service}")() - except SynologyDSMException as ex: - LOGGER.error( - "%s of DSM with serial %s not possible, because of %s", - call.service, - serial, - ex, - ) - return + return + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Service handler setup.""" for service in SERVICES: - hass.services.async_register(DOMAIN, service, service_handler) + hass.services.async_register(DOMAIN, service, _service_handler) diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 37e9ee3d929..7ab6d77e137 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -231,7 +231,7 @@ async def handle_info( "Error fetching system info for %s - %s", domain, key, - exc_info=(type(exception), exception, exception.__traceback__), + exc_info=(type(exception), exception, exception.__traceback__), # noqa: LOG014 ) event_msg["success"] = False event_msg["error"] = {"type": "failed", "error": "unknown"} diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 74768ee01fa..0513d63b893 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -35,7 +35,7 @@ from .const import ( ) from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator from .models import TadoData -from .services import setup_services +from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, @@ -58,7 +58,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Tado.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/tado/services.py b/homeassistant/components/tado/services.py index d931ea303e9..a855f323978 100644 --- a/homeassistant/components/tado/services.py +++ b/homeassistant/components/tado/services.py @@ -29,26 +29,27 @@ SCHEMA_ADD_METER_READING = vol.Schema( ) +async def _add_meter_reading(call: ServiceCall) -> None: + """Send meter reading to Tado.""" + entry_id: str = call.data[CONF_CONFIG_ENTRY] + reading: int = call.data[CONF_READING] + _LOGGER.debug("Add meter reading %s", reading) + + entry = call.hass.config_entries.async_get_entry(entry_id) + if entry is None: + raise ServiceValidationError("Config entry not found") + + coordinator = entry.runtime_data.coordinator + response: dict = await coordinator.set_meter_reading(call.data[CONF_READING]) + + if ATTR_MESSAGE in response: + raise HomeAssistantError(response[ATTR_MESSAGE]) + + @callback -def setup_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Tado integration.""" - async def add_meter_reading(call: ServiceCall) -> None: - """Send meter reading to Tado.""" - entry_id: str = call.data[CONF_CONFIG_ENTRY] - reading: int = call.data[CONF_READING] - _LOGGER.debug("Add meter reading %s", reading) - - entry = hass.config_entries.async_get_entry(entry_id) - if entry is None: - raise ServiceValidationError("Config entry not found") - - coordinator = entry.runtime_data.coordinator - response: dict = await coordinator.set_meter_reading(call.data[CONF_READING]) - - if ATTR_MESSAGE in response: - raise HomeAssistantError(response[ATTR_MESSAGE]) - hass.services.async_register( - DOMAIN, SERVICE_ADD_METER_READING, add_meter_reading, SCHEMA_ADD_METER_READING + DOMAIN, SERVICE_ADD_METER_READING, _add_meter_reading, SCHEMA_ADD_METER_READING ) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index f9472c50cae..554ddd8fc4e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -46,6 +46,7 @@ from .const import ( ATTR_DISABLE_WEB_PREV, ATTR_FILE, ATTR_IS_ANONYMOUS, + ATTR_IS_BIG, ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, ATTR_MESSAGE, @@ -58,6 +59,7 @@ from .const import ( ATTR_PARSER, ATTR_PASSWORD, ATTR_QUESTION, + ATTR_REACTION, ATTR_RESIZE_KEYBOARD, ATTR_SHOW_ALERT, ATTR_STICKER_ID, @@ -94,6 +96,7 @@ from .const import ( SERVICE_SEND_STICKER, SERVICE_SEND_VIDEO, SERVICE_SEND_VOICE, + SERVICE_SET_MESSAGE_REACTION, ) _LOGGER = logging.getLogger(__name__) @@ -250,6 +253,19 @@ SERVICE_SCHEMA_LEAVE_CHAT = vol.Schema( } ) +SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGEID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Required(ATTR_CHAT_ID): vol.Coerce(int), + vol.Required(ATTR_REACTION): cv.string, + vol.Optional(ATTR_IS_BIG, default=False): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, +) + SERVICE_MAP = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, @@ -266,6 +282,7 @@ SERVICE_MAP = { SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, SERVICE_DELETE_MESSAGE: SERVICE_SCHEMA_DELETE_MESSAGE, SERVICE_LEAVE_CHAT: SERVICE_SCHEMA_LEAVE_CHAT, + SERVICE_SET_MESSAGE_REACTION: SERVICE_SCHEMA_SET_MESSAGE_REACTION, } @@ -378,6 +395,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: messages = await notify_service.leave_chat( context=service.context, **kwargs ) + elif msgtype == SERVICE_SET_MESSAGE_REACTION: + await notify_service.set_message_reaction(context=service.context, **kwargs) else: await notify_service.edit_message( msgtype, context=service.context, **kwargs diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 58878485cb2..f313972635f 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -233,13 +233,13 @@ class TelegramNotificationService: """Initialize the service.""" self.app = app self.config = config - self._parsers = { + self._parsers: dict[str, str | None] = { PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN, PARSER_MD2: ParseMode.MARKDOWN_V2, PARSER_PLAIN_TEXT: None, } - self.parse_mode = self._parsers.get(parser) + self.parse_mode = self._parsers[parser] self.bot = bot self.hass = hass self._last_message_id: dict[int, int] = {} @@ -321,8 +321,8 @@ class TelegramNotificationService: for key in row_keyboard.split(","): if ":/" in key: # check if command or URL - if key.startswith("https://"): - label = key.split(",")[0] + if "https://" in key: + label = key.split(":")[0] url = key[len(label) + 1 :] buttons.append(InlineKeyboardButton(label, url=url)) else: @@ -786,6 +786,39 @@ class TelegramNotificationService: self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context ) + async def set_message_reaction( + self, + chat_id: int, + reaction: str, + is_big: bool = False, + context: Context | None = None, + **kwargs, + ) -> None: + """Set the bot's reaction for a given message.""" + chat_id = self._get_target_chat_ids(chat_id)[0] + message_id, _ = self._get_msg_ids(kwargs, chat_id) + params = self._get_msg_kwargs(kwargs) + + _LOGGER.debug( + "Set reaction to message %s in chat ID %s to %s with params: %s", + message_id, + chat_id, + reaction, + params, + ) + + await self._send_msg( + self.bot.set_message_reaction, + "Error setting message reaction", + params[ATTR_MESSAGE_TAG], + chat_id, + message_id, + reaction=reaction, + is_big=is_big, + read_timeout=params[ATTR_TIMEOUT], + context=context, + ) + def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> Bot: """Initialize telegram bot with proxy support.""" diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 63435a494f4..d9b334a4ac1 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -54,6 +54,7 @@ from .const import ( PARSER_HTML, PARSER_MD, PARSER_MD2, + PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS, @@ -126,8 +127,8 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema( ATTR_PARSER, ): SelectSelector( SelectSelectorConfig( - options=[PARSER_MD, PARSER_MD2, PARSER_HTML], - translation_key="parsers", + options=[PARSER_MD, PARSER_MD2, PARSER_HTML, PARSER_PLAIN_TEXT], + translation_key="parse_mode", ) ) } @@ -143,6 +144,8 @@ 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( diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index ca79fc868cf..4abdbaf9738 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -43,6 +43,7 @@ SERVICE_SEND_VOICE = "send_voice" SERVICE_SEND_DOCUMENT = "send_document" SERVICE_SEND_LOCATION = "send_location" SERVICE_SEND_POLL = "send_poll" +SERVICE_SET_MESSAGE_REACTION = "set_message_reaction" SERVICE_EDIT_MESSAGE = "edit_message" SERVICE_EDIT_CAPTION = "edit_caption" SERVICE_EDIT_REPLYMARKUP = "edit_replymarkup" @@ -87,6 +88,8 @@ ATTR_MSG = "message" ATTR_MSGID = "id" ATTR_PARSER = "parse_mode" ATTR_PASSWORD = "password" +ATTR_REACTION = "reaction" +ATTR_IS_BIG = "is_big" ATTR_REPLY_TO_MSGID = "reply_to_message_id" ATTR_REPLYMARKUP = "reply_markup" ATTR_SHOW_ALERT = "show_alert" diff --git a/homeassistant/components/telegram_bot/icons.json b/homeassistant/components/telegram_bot/icons.json index 8deecfb9c27..3a53e2b4118 100644 --- a/homeassistant/components/telegram_bot/icons.json +++ b/homeassistant/components/telegram_bot/icons.json @@ -44,6 +44,9 @@ }, "leave_chat": { "service": "mdi:exit-run" + }, + "set_message_reaction": { + "service": "mdi:emoticon-happy" } } } diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 75f7a39a25f..d5fc0e134d5 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -27,6 +27,7 @@ send_message: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -786,3 +787,29 @@ leave_chat: example: 12345 selector: text: + +set_message_reaction: + fields: + config_entry_id: + selector: + config_entry: + integration: telegram_bot + message_id: + required: true + example: 54321 + selector: + text: + chat_id: + required: true + example: 12345 + selector: + text: + reaction: + required: true + example: 👍 + selector: + text: + is_big: + required: false + selector: + boolean: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index cff3141c092..9fcc0740970 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -106,11 +106,12 @@ "webhooks": "Webhooks" } }, - "parsers": { + "parse_mode": { "options": { "markdown": "Markdown (Legacy)", "markdownv2": "MarkdownV2", - "html": "HTML" + "html": "HTML", + "plain_text": "Plain text" } } }, @@ -856,6 +857,32 @@ "description": "Chat ID of the group from which the bot should be removed." } } + }, + "set_message_reaction": { + "name": "Set message reaction", + "description": "Sets the bot's reaction for a given message.", + "fields": { + "config_entry_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::config_entry_id::name%]", + "description": "The config entry representing the Telegram bot to set the message reaction." + }, + "message_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", + "description": "ID of the message to react to." + }, + "chat_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", + "description": "ID of the chat containing the message." + }, + "reaction": { + "name": "Reaction", + "description": "Emoji reaction to use." + }, + "is_big": { + "name": "Large animation", + "description": "Whether the reaction animation should be large." + } + } } }, "exceptions": { diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 725a73338fa..29c71973f42 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -41,13 +41,12 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -105,15 +104,10 @@ ALARM_CONTROL_PANEL_SCHEMA = vol.All( CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name ): cv.enum(TemplateCodeFormat), vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) @@ -419,9 +413,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane unique_id: str | None, ) -> None: """Initialize the panel.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateAlarmControlPanel.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 4ee8844d6e7..07aa41b3811 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -26,29 +26,19 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False -BUTTON_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +BUTTON_SCHEMA = vol.Schema( + { + vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) CONFIG_BUTTON_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 0b2009e83e3..68645c718b2 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -37,14 +37,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -100,21 +99,16 @@ COVER_SCHEMA = vol.All( vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, vol.Optional(CONF_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_POSITION): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -463,9 +457,7 @@ class CoverTemplate(TemplateEntity, AbstractTemplateCover): unique_id, ) -> None: """Initialize the Template cover.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateCover.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index c353fca48df..4837ded9029 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -37,14 +37,13 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -85,12 +84,10 @@ FAN_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_DIRECTION): cv.template, - vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_OSCILLATING): cv.template, vol.Optional(CONF_PERCENTAGE): cv.template, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_PRESET_MODE): cv.template, vol.Optional(CONF_PRESET_MODES): cv.ensure_list, vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, @@ -99,11 +96,8 @@ FAN_SCHEMA = vol.All( vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) LEGACY_FAN_SCHEMA = vol.All( @@ -488,9 +482,7 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan): unique_id, ) -> None: """Initialize the fan.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateFan.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 660227f65dc..2cd587de5a1 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -54,8 +54,7 @@ async def _reload_blueprint_templates(hass: HomeAssistant, blueprint_path: str) @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get template blueprints.""" - # pylint: disable-next=import-outside-toplevel - from .config import TEMPLATE_BLUEPRINT_SCHEMA + from .config import TEMPLATE_BLUEPRINT_SCHEMA # noqa: PLC0415 return blueprint.DomainBlueprints( hass, diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 5afbca55cbb..d286a2f6b4d 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -29,7 +29,10 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE -from .template_entity import TemplateEntity, make_template_entity_common_schema +from .template_entity import ( + TemplateEntity, + make_template_entity_common_modern_attributes_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -43,7 +46,7 @@ IMAGE_SCHEMA = vol.Schema( vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, } -).extend(make_template_entity_common_schema(DEFAULT_NAME).schema) +).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) IMAGE_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index c852ee1808d..10870462bc9 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -49,14 +49,13 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -124,38 +123,31 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Light" -LIGHT_SCHEMA = ( - vol.Schema( - { - vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, - vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, - vol.Inclusive(CONF_EFFECT, "effect"): cv.template, - vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_HS): cv.template, - vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_LEVEL): cv.template, - vol.Optional(CONF_MAX_MIREDS): cv.template, - vol.Optional(CONF_MIN_MIREDS): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGB): cv.template, - vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGBW): cv.template, - vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGBWW): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, - vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TEMPERATURE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +LIGHT_SCHEMA = vol.Schema( + { + vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, + vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, + vol.Inclusive(CONF_EFFECT, "effect"): cv.template, + vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_HS): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL): cv.template, + vol.Optional(CONF_MAX_MIREDS): cv.template, + vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGB): cv.template, + vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBW): cv.template, + vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBWW): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, + vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TEMPERATURE): cv.template, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) LEGACY_LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), @@ -544,7 +536,6 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): effect, self.entity_id, self._effect_list, - exc_info=True, ) common_params["effect"] = effect @@ -955,9 +946,7 @@ class LightTemplate(TemplateEntity, AbstractTemplateLight): unique_id: str | None, ) -> None: """Initialize the light.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateLight.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 25eac8c35e4..8ed8a004e92 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -31,10 +31,9 @@ from .const import CONF_PICTURE, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -57,17 +56,13 @@ LOCK_SCHEMA = vol.All( { vol.Optional(CONF_CODE_FORMAT): cv.template, vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PICTURE): cv.template, vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) @@ -313,9 +308,7 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock): unique_id: str | None, ) -> None: """Initialize the lock.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateLock.__init__(self, config) name = self._attr_name if TYPE_CHECKING: diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 3ecf1db565a..4d9eaff0b2d 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -35,11 +35,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -49,23 +45,17 @@ CONF_SET_VALUE = "set_value" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False -NUMBER_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Required(CONF_STEP): cv.template, - vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, - vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +NUMBER_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, + vol.Required(CONF_STEP): cv.template, + vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) NUMBER_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.template, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 74d88ee96c4..8c05e8e2592 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -32,11 +32,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -47,20 +43,14 @@ CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" DEFAULT_OPTIMISTIC = False -SELECT_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Required(ATTR_OPTIONS): cv.template, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +SELECT_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_OPTIONS): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) SELECT_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 0f6d45f46ca..677686ea8d8 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -40,13 +40,12 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -60,20 +59,13 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Switch" -SWITCH_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_PICTURE): cv.template, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) LEGACY_SWITCH_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), @@ -228,7 +220,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + super().__init__(hass, config=config, unique_id=unique_id) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index f879c60ed9e..3157a60347e 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -94,16 +94,24 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = ( ) -def make_template_entity_common_schema(default_name: str) -> vol.Schema: +def make_template_entity_common_modern_schema( + default_name: str, +) -> vol.Schema: """Return a schema with default name.""" - return ( - vol.Schema( - { - vol.Optional(CONF_AVAILABILITY): cv.template, - } - ) - .extend(make_template_entity_base_schema(default_name).schema) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) + return vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + } + ).extend(make_template_entity_base_schema(default_name).schema) + + +def make_template_entity_common_modern_attributes_schema( + default_name: str, +) -> vol.Schema: + """Return a schema with default name.""" + return make_template_entity_common_modern_schema(default_name).extend( + TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema ) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f50751012b3..79e00e7e1c0 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -38,16 +38,14 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_attributes_schema, rewrite_common_legacy_to_modern_conf, ) @@ -60,6 +58,8 @@ CONF_FAN_SPEED_LIST = "fan_speeds" CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" +DEFAULT_NAME = "Template Vacuum" + ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}" _VALID_STATES = [ VacuumActivity.CLEANING, @@ -80,13 +80,9 @@ VACUUM_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED): cv.template, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, @@ -95,10 +91,7 @@ VACUUM_SCHEMA = vol.All( vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) ) LEGACY_VACUUM_SCHEMA = vol.All( @@ -353,9 +346,7 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): unique_id, ) -> None: """Initialize the vacuum.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateVacuum.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 86bab6f5ad1..ee834e757a3 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -32,7 +32,6 @@ from homeassistant.components.weather import ( WeatherEntityFeature, ) from homeassistant.const import ( - CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID, STATE_UNAVAILABLE, @@ -53,7 +52,11 @@ from homeassistant.util.unit_conversion import ( ) from .coordinator import TriggerUpdateCoordinator -from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .template_entity import ( + TemplateEntity, + make_template_entity_common_modern_schema, + rewrite_common_legacy_to_modern_conf, +) from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( @@ -104,33 +107,33 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" +DEFAULT_NAME = "Template Weather" + WEATHER_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME): cv.template, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), } -) +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index 05be56d444d..696bc40fd2d 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -156,9 +156,8 @@ def setup_platform( # These imports shouldn't be moved to the top, because they depend on code from the model_dir. # (The model_dir is created during the manual setup process. See integration docs.) - # pylint: disable=import-outside-toplevel - from object_detection.builders import model_builder - from object_detection.utils import config_util, label_map_util + from object_detection.builders import model_builder # noqa: PLC0415 + from object_detection.utils import config_util, label_map_util # noqa: PLC0415 except ImportError: _LOGGER.error( "No TensorFlow Object Detection library found! Install or compile " @@ -169,7 +168,7 @@ def setup_platform( try: # Display warning that PIL will be used if no OpenCV is found. - import cv2 # noqa: F401 pylint: disable=import-outside-toplevel + import cv2 # noqa: F401, PLC0415 except ImportError: _LOGGER.warning( "No OpenCV library found. TensorFlow will process image with " @@ -354,7 +353,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): start = time.perf_counter() try: - import cv2 # pylint: disable=import-outside-toplevel + import cv2 # noqa: PLC0415 img = cv2.imdecode(np.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED) inp = img[:, :, [2, 1, 0]] # BGR->RGB diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index f752509ee17..2628a9e134f 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -164,12 +164,6 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the climate mode and state.""" - if hvac_mode not in self.hvac_modes: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_hvac_mode", - translation_placeholders={"hvac_mode": hvac_mode}, - ) if hvac_mode == HVACMode.OFF: await self.async_turn_off() else: diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index feeb5e74ca6..ac55a380abb 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -4,14 +4,30 @@ from __future__ import annotations from collections.abc import Mapping import logging -from typing import Any +import re +from typing import Any, cast import jwt +from tesla_fleet_api import TeslaFleetApi +from tesla_fleet_api.const import SERVERS +from tesla_fleet_api.exceptions import ( + InvalidResponse, + PreconditionFailed, + TeslaFleetError, +) +import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + QrCodeSelector, + QrCodeSelectorConfig, + QrErrorCorrectionLevel, +) -from .const import DOMAIN, LOGGER +from .const import CONF_DOMAIN, DOMAIN, LOGGER +from .oauth import TeslaUserImplementation class OAuth2FlowHandler( @@ -21,36 +37,173 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + def __init__(self) -> None: + """Initialize config flow.""" + super().__init__() + self.domain: str | None = None + self.registration_status: dict[str, bool] = {} + self.tesla_apis: dict[str, TeslaFleetApi] = {} + self.failed_regions: list[str] = [] + self.data: dict[str, Any] = {} + self.uid: str | None = None + self.api: TeslaFleetApi | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" return LOGGER - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow start.""" - return await super().async_step_user() - async def async_oauth_create_entry( self, data: dict[str, Any], ) -> ConfigFlowResult: - """Handle the initial step.""" - + """Handle OAuth completion and proceed to domain registration.""" token = jwt.decode( data["token"]["access_token"], options={"verify_signature": False} ) - uid = token["sub"] - await self.async_set_unique_id(uid) + self.data = data + self.uid = token["sub"] + server = SERVERS[token["ou_code"].lower()] + + await self.async_set_unique_id(self.uid) if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") return self.async_update_reload_and_abort( self._get_reauth_entry(), data=data ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=uid, data=data) + + # OAuth done, setup a Partner API connection + implementation = cast(TeslaUserImplementation, self.flow_impl) + + session = async_get_clientsession(self.hass) + self.api = TeslaFleetApi( + session=session, + server=server, + partner_scope=True, + charging_scope=False, + energy_scope=False, + user_scope=False, + vehicle_scope=False, + ) + await self.api.get_private_key(self.hass.config.path("tesla_fleet.key")) + await self.api.partner_login( + implementation.client_id, implementation.client_secret + ) + + return await self.async_step_domain_input() + + async def async_step_domain_input( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: + """Handle domain input step.""" + + errors = errors or {} + + if user_input is not None: + domain = user_input[CONF_DOMAIN].strip().lower() + + # Validate domain format + if not self._is_valid_domain(domain): + errors[CONF_DOMAIN] = "invalid_domain" + else: + self.domain = domain + return await self.async_step_domain_registration() + + return self.async_show_form( + step_id="domain_input", + description_placeholders={ + "dashboard": "https://developer.tesla.com/en_AU/dashboard/" + }, + data_schema=vol.Schema( + { + vol.Required(CONF_DOMAIN): str, + } + ), + errors=errors, + ) + + async def async_step_domain_registration( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle domain registration for both regions.""" + + assert self.api + assert self.api.private_key + assert self.domain + + errors = {} + description_placeholders = { + "public_key_url": f"https://{self.domain}/.well-known/appspecific/com.tesla.3p.public-key.pem", + "pem": self.api.public_pem, + } + + try: + register_response = await self.api.partner.register(self.domain) + except PreconditionFailed: + return await self.async_step_domain_input( + errors={CONF_DOMAIN: "precondition_failed"} + ) + except InvalidResponse: + errors["base"] = "invalid_response" + except TeslaFleetError as e: + errors["base"] = "unknown_error" + description_placeholders["error"] = e.message + else: + # Get public key from response + registered_public_key = register_response.get("response", {}).get( + "public_key" + ) + + if not registered_public_key: + errors["base"] = "public_key_not_found" + elif ( + registered_public_key.lower() + != self.api.public_uncompressed_point.lower() + ): + errors["base"] = "public_key_mismatch" + else: + return await self.async_step_registration_complete() + + return self.async_show_form( + step_id="domain_registration", + description_placeholders=description_placeholders, + errors=errors, + ) + + async def async_step_registration_complete( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show completion and virtual key installation.""" + if user_input is not None and self.uid and self.data: + return self.async_create_entry(title=self.uid, data=self.data) + + if not self.domain: + return await self.async_step_domain_input() + + virtual_key_url = f"https://www.tesla.com/_ak/{self.domain}" + data_schema = vol.Schema({}).extend( + { + vol.Optional("qr_code"): QrCodeSelector( + config=QrCodeSelectorConfig( + data=virtual_key_url, + scale=6, + error_correction_level=QrErrorCorrectionLevel.QUARTILE, + ) + ), + } + ) + + return self.async_show_form( + step_id="registration_complete", + data_schema=data_schema, + description_placeholders={ + "virtual_key_url": virtual_key_url, + }, + ) async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -67,4 +220,11 @@ class OAuth2FlowHandler( step_id="reauth_confirm", description_placeholders={"name": "Tesla Fleet"}, ) - return await self.async_step_user() + # For reauth, skip domain registration and go straight to OAuth + return await super().async_step_user() + + def _is_valid_domain(self, domain: str) -> bool: + """Validate domain format.""" + # Basic domain validation regex + domain_pattern = re.compile(r"^(?:[a-zA-Z0-9]+\.)+[a-zA-Z0-9-]+$") + return bool(domain_pattern.match(domain)) diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 5d2dc84c49e..d73234b1fdd 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -9,6 +9,7 @@ from tesla_fleet_api.const import Scope DOMAIN = "tesla_fleet" +CONF_DOMAIN = "domain" CONF_REFRESH_TOKEN = "refresh_token" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 8f5ba1468a5..4c92e0bd222 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.1.1"] + "requirements": ["tesla-fleet-api==1.2.0"] } diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 04ccbd13b44..a9b1cfc4845 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -4,6 +4,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "already_configured": "Configuration updated for profile.", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", @@ -13,11 +14,37 @@ "reauth_account_mismatch": "The reauthentication account does not match the original account" }, "error": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "invalid_domain": "Invalid domain format. Please enter a valid domain name.", + "public_key_not_found": "Public key not found.", + "public_key_mismatch": "The public key hosted at your domain does not match the expected key. Please ensure the correct public key is hosted at the specified location.", + "precondition_failed": "The domain does not match the application's allowed origins.", + "invalid_response": "The registration was rejected by Tesla", + "unknown_error": "An unknown error occurred: {error}" }, "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } + }, + "domain_input": { + "title": "Tesla Fleet domain registration", + "description": "Enter the domain that will host your public key. This is typically the domain of the origin you specified during registration at {dashboard}.", + "data": { + "domain": "Domain" + } + }, + "domain_registration": { + "title": "Registering public key", + "description": "You must host the public key at:\n\n{public_key_url}\n\n```\n{pem}\n```" + }, + "registration_complete": { + "title": "Command signing", + "description": "To enable command signing, you must open the Tesla app, select your vehicle, and then visit the following URL to set up a virtual key. You must repeat this process for each vehicle.\n\n{virtual_key_url}" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", @@ -573,9 +600,6 @@ "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature." }, - "invalid_hvac_mode": { - "message": "Climate mode {hvac_mode} is not supported." - }, "missing_temperature": { "message": "Temperature is required for this action." }, diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index a32c5fea40e..439df76c838 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -126,7 +126,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( polling=True, polling_value_fn=lambda x: x != "", streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( - lambda value: callback(value != "Unknown") + lambda value: callback(value is not None and value != "Unknown") ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7fc621eeeae..f58783e04a4 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==1.1.1", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.0", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index d989e7b8f40..246cc097a2a 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -7,7 +7,7 @@ from voluptuous import All, Range from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -98,6 +98,7 @@ def async_get_energy_site_for_entry( return energy_data +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Teslemetry services.""" diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 9ad87e9dbbe..c0cbc2ea431 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.1.1"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.0"] } diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index e6149214af4..c66aec3bac9 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -117,9 +117,7 @@ def _get_neighbours(ndb: NDB) -> dict[str, Neighbour]: def _get_routes_and_neighbors(): """Get the routes and neighbours from pyroute2.""" # Import in the executor since import NDB can take a while - from pyroute2 import ( # pylint: disable=no-name-in-module, import-outside-toplevel - NDB, - ) + from pyroute2 import NDB # pylint: disable=no-name-in-module # noqa: PLC0415 with NDB() as ndb: routes, reverse_routes = _get_possible_thread_routes(ndb) diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index 3072896653d..07843de1a05 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "Choose your tenant to authenticate with" + "title": "Choose your tenant to authenticate with", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "agreement": { "title": "Select your agreement", diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index 66037d7476e..45e4575b4e3 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -21,11 +21,10 @@ from homeassistant.components.climate import ( ) from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry, legacy_device_id -from .const import DOMAIN, UNIT_MAPPING +from .const import UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator from .entity import ( CoordinatedTPLinkModuleEntity, @@ -161,14 +160,6 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity): await self._thermostat_module.set_state(True) elif hvac_mode is HVACMode.OFF: await self._thermostat_module.set_state(False) - else: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unsupported_mode", - translation_placeholders={ - "mode": hvac_mode, - }, - ) @async_refresh_after async def async_turn_on(self) -> None: diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index cc35b1fd142..967853da629 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -317,8 +317,7 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): value = self.entity_description.convert_fn(value) if TYPE_CHECKING: - # pylint: disable-next=import-outside-toplevel - from datetime import date, datetime + from datetime import date, datetime # noqa: PLC0415 assert isinstance(value, str | int | float | date | datetime | None) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 856b4d339a5..a7f9dfbcb09 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -487,9 +487,6 @@ "unexpected_device": { "message": "Unexpected device found at {host}; expected {expected}, found {found}" }, - "unsupported_mode": { - "message": "Tried to set unsupported mode: {mode}" - }, "invalid_alarm_duration": { "message": "Invalid duration {duration} available: 1-{duration_max}s" } diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 5b9bc2551b7..e8c151179ce 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -1,9 +1,12 @@ """Support for Traccar Client.""" from http import HTTPStatus +from json import JSONDecodeError +import logging from aiohttp import web import voluptuous as vol +from voluptuous.humanize import humanize_error from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry @@ -20,7 +23,6 @@ from .const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_SPEED, - ATTR_TIMESTAMP, DOMAIN, ) @@ -29,6 +31,7 @@ PLATFORMS = [Platform.DEVICE_TRACKER] TRACKER_UPDATE = f"{DOMAIN}_tracker_update" +LOGGER = logging.getLogger(__name__) DEFAULT_ACCURACY = 200 DEFAULT_BATTERY = -1 @@ -49,21 +52,50 @@ WEBHOOK_SCHEMA = vol.Schema( vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float), vol.Optional(ATTR_BEARING): vol.Coerce(float), vol.Optional(ATTR_SPEED): vol.Coerce(float), - vol.Optional(ATTR_TIMESTAMP): vol.Coerce(int), }, extra=vol.REMOVE_EXTRA, ) +def _parse_json_body(json_body: dict) -> dict: + """Parse JSON body from request.""" + location = json_body.get("location", {}) + coords = location.get("coords", {}) + battery_level = location.get("battery", {}).get("level") + return { + "id": json_body.get("device_id"), + "lat": coords.get("latitude"), + "lon": coords.get("longitude"), + "accuracy": coords.get("accuracy"), + "altitude": coords.get("altitude"), + "batt": battery_level * 100 if battery_level is not None else DEFAULT_BATTERY, + "bearing": coords.get("heading"), + "speed": coords.get("speed"), + } + + async def handle_webhook( - hass: HomeAssistant, webhook_id: str, request: web.Request + hass: HomeAssistant, + webhook_id: str, + request: web.Request, ) -> web.Response: """Handle incoming webhook with Traccar Client request.""" + if not (requestdata := dict(request.query)): + try: + requestdata = _parse_json_body(await request.json()) + except JSONDecodeError as error: + LOGGER.error("Error parsing JSON body: %s", error) + return web.Response( + text="Invalid JSON", + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) try: - data = WEBHOOK_SCHEMA(dict(request.query)) + data = WEBHOOK_SCHEMA(requestdata) except vol.MultipleInvalid as error: + LOGGER.warning(humanize_error(requestdata, error)) return web.Response( - text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY + text=error.error_message, + status=HTTPStatus.UNPROCESSABLE_ENTITY, ) attrs = { diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py index df4bfa8ec99..f6928cc9ee9 100644 --- a/homeassistant/components/traccar/const.py +++ b/homeassistant/components/traccar/const.py @@ -17,7 +17,6 @@ ATTR_LONGITUDE = "lon" ATTR_MOTION = "motion" ATTR_SPEED = "speed" ATTR_STATUS = "status" -ATTR_TIMESTAMP = "timestamp" ATTR_TRACKER = "tracker" ATTR_TRACCAR_ID = "traccar_id" diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index 2c3fd446d2f..dc6f22570fc 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -89,11 +89,11 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH """Return a mapping with the default options.""" return self._attr_default_options - @classmethod - def async_supports_streaming_input(cls) -> bool: + def async_supports_streaming_input(self) -> bool: """Return if the TTS engine supports streaming input.""" return ( - cls.async_stream_tts_audio is not TextToSpeechEntity.async_stream_tts_audio + self.__class__.async_stream_tts_audio + is not TextToSpeechEntity.async_stream_tts_audio ) @callback diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 91192fdca13..4ff4f93d9cd 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -40,7 +40,7 @@ def generate_media_source_id( cache: bool | None = None, ) -> str: """Generate a media source ID for text-to-speech.""" - from . import async_resolve_engine # pylint: disable=import-outside-toplevel + from . import async_resolve_engine # noqa: PLC0415 if (engine := async_resolve_engine(hass, engine)) is None: raise HomeAssistantError("Invalid TTS provider selected") @@ -193,7 +193,7 @@ class TTSMediaSource(MediaSource): @callback def _engine_item(self, engine: str, params: str | None = None) -> BrowseMediaSource: """Return provider item.""" - from . import TextToSpeechEntity # pylint: disable=import-outside-toplevel + from . import TextToSpeechEntity # noqa: PLC0415 if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise BrowseError("Unknown provider") diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 32119add5f4..106075e9314 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -94,7 +94,7 @@ class SharingMQCompat(SharingMQ): """Start the MQTT client.""" # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt # noqa: PLC0415 mqttc = mqtt.Client(client_id=mq_config.client_id) mqttc.username_pw_set(mq_config.username, mq_config.password) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 3c32935a995..47e2a01e798 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==7.13.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.14.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 402aae2eeba..40fe0a991f2 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -303,6 +303,7 @@ SERVICES = [ ] +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the global UniFi Protect services.""" diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 3b1eee8509c..83c68fb61b6 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -312,7 +312,7 @@ class StateVacuumEntity( @property def capability_attributes(self) -> dict[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: + if VacuumEntityFeature.FAN_SPEED in self.supported_features: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -330,7 +330,7 @@ class StateVacuumEntity( def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -369,19 +369,6 @@ class StateVacuumEntity( """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> VacuumEntityFeature: - """Return the supported features as VacuumEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: - new_features = VacuumEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError diff --git a/homeassistant/components/vodafone_station/utils.py b/homeassistant/components/vodafone_station/utils.py index 4f900412faf..faa498afdd6 100644 --- a/homeassistant/components/vodafone_station/utils.py +++ b/homeassistant/components/vodafone_station/utils.py @@ -9,5 +9,5 @@ from homeassistant.helpers import aiohttp_client async def async_client_session(hass: HomeAssistant) -> ClientSession: """Return a new aiohttp session.""" return aiohttp_client.async_create_clientsession( - hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True, quote_cookie=False) ) diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index d68d950e641..b2b2bac6480 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -52,7 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await hass.async_add_executor_job( - partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) + partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) # type: ignore[arg-type] ) hass.services.async_register( diff --git a/homeassistant/components/wake_on_lan/manifest.json b/homeassistant/components/wake_on_lan/manifest.json index c716a851ae4..34e9ccd5d21 100644 --- a/homeassistant/components/wake_on_lan/manifest.json +++ b/homeassistant/components/wake_on_lan/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wake_on_lan", "iot_class": "local_push", - "requirements": ["wakeonlan==2.1.0"] + "requirements": ["wakeonlan==3.1.0"] } diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index d978e1ec7c9..5aa659a0527 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -74,3 +74,4 @@ class EcoSmartMode(StrEnum): OFF = "off" ECO_MODE = "eco_mode" FULL_SOLAR = "full_solar" + DISABLED = "disabled" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 60f062e57cc..8276ee14eaf 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -166,13 +166,20 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) # Set current solar charging mode - eco_smart_enabled = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ - CHARGER_ECO_SMART_STATUS_KEY - ] - eco_smart_mode = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ - CHARGER_ECO_SMART_MODE_KEY - ] - if eco_smart_enabled is False: + eco_smart_enabled = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_STATUS_KEY) + ) + + eco_smart_mode = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_MODE_KEY) + ) + if eco_smart_mode is None: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED + elif eco_smart_enabled is False: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF elif eco_smart_mode == 0: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 7ad7a135bc8..0048aa35c7c 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -63,15 +63,15 @@ async def async_setup_entry( ) -> None: """Create wallbox select entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities( - WallboxSelect(coordinator, description) - for ent in coordinator.data - if ( - (description := SELECT_TYPES.get(ent)) - and description.supported_fn(coordinator) + if coordinator.data[CHARGER_ECO_SMART_KEY] != EcoSmartMode.DISABLED: + async_add_entities( + WallboxSelect(coordinator, description) + for ent in coordinator.data + if ( + (description := SELECT_TYPES.get(ent)) + and description.supported_fn(coordinator) + ) ) - ) class WallboxSelect(WallboxEntity, SelectEntity): diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 63d093745d1..9e9e1c8866e 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.5"] + "requirements": ["aiowebdav2==0.4.6"] } diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 9c371a8399d..498a986e806 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -735,8 +735,7 @@ async def handle_subscribe_trigger( ) -> None: """Handle subscribe trigger command.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers import trigger + from homeassistant.helpers import trigger # noqa: PLC0415 trigger_config = await trigger.async_validate_trigger_config(hass, msg["trigger"]) @@ -786,8 +785,7 @@ async def handle_test_condition( ) -> None: """Handle test condition command.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers import condition + from homeassistant.helpers import condition # noqa: PLC0415 # Do static + dynamic validation of the condition config = await condition.async_validate_condition_config(hass, msg["condition"]) @@ -812,8 +810,10 @@ async def handle_execute_script( ) -> None: """Handle execute script command.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers.script import Script, async_validate_actions_config + from homeassistant.helpers.script import ( # noqa: PLC0415 + Script, + async_validate_actions_config, + ) script_config = await async_validate_actions_config(hass, msg["sequence"]) @@ -877,8 +877,7 @@ async def handle_validate_config( ) -> None: """Handle validate config command.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers import condition, script, trigger + from homeassistant.helpers import condition, script, trigger # noqa: PLC0415 result = {} diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 93a3fbaad30..b3c2af71803 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "find_devices": { "title": "Select your heat pump" diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 75967bb81d4..0113d3c99d6 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -130,9 +130,7 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity): await self._appliance.set_power_on(False) return - if not (mode := HVAC_MODE_TO_AIRCON_MODE.get(hvac_mode)): - raise ValueError(f"Invalid hvac mode {hvac_mode}") - + mode = HVAC_MODE_TO_AIRCON_MODE[hvac_mode] await self._appliance.set_mode(mode) if not self._appliance.get_power_on(): await self._appliance.set_power_on(True) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 14c7bf640e9..4792e3362bd 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 9091dd131dd..f9fae38f1f5 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.74"] + "requirements": ["holidays==0.75"] } diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 88939f0ba77..75c227f8537 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import AsyncGenerator import io import logging +import time from typing import Any, Final import wave @@ -36,6 +37,7 @@ from homeassistant.components.assist_satellite import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.ulid import ulid_now from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_WIDTH from .data import WyomingService @@ -53,6 +55,7 @@ _PING_SEND_DELAY: Final = 2 _PIPELINE_FINISH_TIMEOUT: Final = 1 _TTS_SAMPLE_RATE: Final = 22050 _ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples +_TTS_TIMEOUT_EXTRA: Final = 1.0 # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -125,6 +128,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._ffmpeg_manager: ffmpeg.FFmpegManager | None = None self._played_event_received: asyncio.Event | None = None + # Randomly set on each pipeline loop run. + # Used to ensure TTS timeout is acted on correctly. + self._run_loop_id: str | None = None + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -511,6 +518,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): wake_word_phrase: str | None = None run_pipeline: RunPipeline | None = None send_ping = True + self._run_loop_id = ulid_now() # Read events and check for pipeline end in parallel pipeline_ended_task = self.config_entry.async_create_background_task( @@ -698,38 +706,52 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): f"Cannot stream audio format to satellite: {tts_result.extension}" ) - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + # Track the total duration of TTS audio for response timeout + total_seconds = 0.0 + start_time = time.monotonic() - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - sample_channels = wav_file.getnchannels() - _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + try: + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) - timestamp = 0 - await self._client.write_event( - AudioStart( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - timestamp=timestamp, - ).event() - ) + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) - # Stream audio chunks - while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): - chunk = AudioChunk( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - audio=audio_bytes, - timestamp=timestamp, + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() ) - await self._client.write_event(chunk.event()) - timestamp += chunk.seconds - await self._client.write_event(AudioStop(timestamp=timestamp).event()) - _LOGGER.debug("TTS streaming complete") + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + total_seconds += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + finally: + send_duration = time.monotonic() - start_time + timeout_seconds = max(0, total_seconds - send_duration + _TTS_TIMEOUT_EXTRA) + self.config_entry.async_create_background_task( + self.hass, + self._tts_timeout(timeout_seconds, self._run_loop_id), + name="wyoming TTS timeout", + ) async def _stt_stream(self) -> AsyncGenerator[bytes]: """Yield audio chunks from a queue.""" @@ -744,6 +766,18 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): yield chunk + async def _tts_timeout( + self, timeout_seconds: float, run_loop_id: str | None + ) -> None: + """Force state change to IDLE in case TTS played event isn't received.""" + await asyncio.sleep(timeout_seconds + _TTS_TIMEOUT_EXTRA) + + if run_loop_id != self._run_loop_id: + # On a different pipeline run now + return + + self.tts_response_finished() + @callback def _handle_timer( self, event_type: intent.TimerEventType, timer: intent.TimerInfo diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index 0d9a12137ce..a59e8b90221 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index c69bd150226..d10bdaad217 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -25,6 +25,8 @@ from miio.integrations.airpurifier.zhimi.airpurifier import ( from miio.integrations.airpurifier.zhimi.airpurifier_miot import ( OperationMode as AirpurifierMiotOperationMode, ) +from miio.integrations.fan.dmaker.fan import FanStatusP5 +from miio.integrations.fan.dmaker.fan_miot import FanStatusMiot from miio.integrations.fan.zhimi.zhimi_miot import ( OperationModeFanZA5 as FanZA5OperationMode, ) @@ -330,6 +332,12 @@ class XiaomiGenericDevice( """Return the percentage based speed of the fan.""" return None + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + # Base FanEntity uses percentage to determine if the device is on. + return self._attr_is_on + async def async_turn_on( self, percentage: int | None = None, @@ -1077,12 +1085,14 @@ class XiaomiFan(XiaomiGenericFan): class XiaomiFanP5(XiaomiGenericFan): """Representation of a Xiaomi Fan P5.""" + coordinator: DataUpdateCoordinator[FanStatusP5] + def __init__( self, device: MiioDevice, entry: XiaomiMiioConfigEntry, unique_id: str | None, - coordinator: DataUpdateCoordinator[Any], + coordinator: DataUpdateCoordinator[FanStatusP5], ) -> None: """Initialize the fan.""" super().__init__(device, entry, unique_id, coordinator) @@ -1140,13 +1150,15 @@ class XiaomiFanP5(XiaomiGenericFan): class XiaomiFanMiot(XiaomiGenericFan): """Representation of a Xiaomi Fan Miot.""" + coordinator: DataUpdateCoordinator[FanStatusMiot] + @property - def operation_mode_class(self): + def operation_mode_class(self) -> type[FanOperationMode]: """Hold operation mode class.""" return FanOperationMode @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Fetch state from the device.""" self._attr_is_on = self.coordinator.data.is_on self._attr_preset_mode = self.coordinator.data.mode.name diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index abda8703e02..129acf53740 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -1,6 +1,6 @@ { "domain": "xiaomi_miio", - "name": "Xiaomi Miio", + "name": "Xiaomi Home", "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index a5af3d8bd1f..fef185daf41 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -5,37 +5,37 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "incomplete_info": "Incomplete information to set up device, no host or token supplied.", - "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio.", + "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Home integration.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "wrong_token": "Checksum error, wrong token", "unknown_device": "The device model is not known, not able to set up the device using config flow.", - "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", - "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not log in to Xiaomi Miio Cloud, check the credentials." + "cloud_no_devices": "No devices found in this Xiaomi Home account.", + "cloud_credentials_incomplete": "Credentials incomplete, please fill in username, password and server region", + "cloud_login_error": "Could not log in to Xiaomi Home, check the credentials." }, "flow_title": "{name}", "step": { "reauth_confirm": { - "description": "The Xiaomi Miio integration needs to re-authenticate your account in order to update the tokens or add missing cloud credentials.", + "description": "The Xiaomi Home integration needs to re-authenticate your account in order to update the tokens or add missing credentials.", "title": "[%key:common::config_flow::title::reauth%]" }, "cloud": { "data": { - "cloud_username": "Cloud username", - "cloud_password": "Cloud password", - "cloud_country": "Cloud server country", + "cloud_username": "[%key:common::config_flow::data::username%]", + "cloud_password": "[%key:common::config_flow::data::password%]", + "cloud_country": "Server region", "manual": "Configure manually (not recommended)" }, - "description": "Log in to the Xiaomi Miio cloud, see https://www.openhab.org/addons/bindings/miio/#country-servers for the cloud server to use." + "description": "Log in to Xiaomi Home, see https://www.openhab.org/addons/bindings/miio/#country-servers for the server region to use." }, "select": { "data": { - "select_device": "Miio device" + "select_device": "[%key:common::config_flow::data::device%]" }, - "description": "Select the Xiaomi Miio device to set up." + "description": "Select the Xiaomi Home device to set up." }, "manual": { "data": { @@ -58,7 +58,7 @@ "step": { "init": { "data": { - "cloud_subdevices": "Use cloud to get connected subdevices" + "cloud_subdevices": "Use Xiaomi Home service to get connected subdevices" } } } @@ -331,7 +331,7 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Name of the Xiaomi Miio entity." + "description": "Name of the Xiaomi Home entity." } } }, diff --git a/homeassistant/components/yale/strings.json b/homeassistant/components/yale/strings.json index 3fb1345a3b0..f5078ac2ece 100644 --- a/homeassistant/components/yale/strings.json +++ b/homeassistant/components/yale/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index 10d90d274a4..5bc5f2f9660 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -4,7 +4,7 @@ import voluptuous as vol from yolink.client_request import ClientRequest from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -25,6 +25,7 @@ _SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS = ( ) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for YoLink integration.""" diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index d38ea248c31..0eb9de97469 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4a5ec7be1dc..4908298847b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.59"], + "requirements": ["zha==0.0.60"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 95bf339f7d9..1327a78b0b3 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -182,9 +182,9 @@ "group_members_assume_state": "Group members assume state of group", "enable_identify_on_join": "Enable identify effect when devices join the network", "default_light_transition": "Default light transition time (seconds)", - "consider_unavailable_mains": "Consider mains powered devices unavailable after (seconds)", - "enable_mains_startup_polling": "Refresh state for mains powered devices on startup", - "consider_unavailable_battery": "Consider battery powered devices unavailable after (seconds)" + "consider_unavailable_mains": "Consider mains-powered devices unavailable after (seconds)", + "enable_mains_startup_polling": "Refresh state for mains-powered devices on startup", + "consider_unavailable_battery": "Consider battery-powered devices unavailable after (seconds)" }, "zha_alarm_options": { "title": "Alarm control panel options", diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 07d897bcfd6..08097880591 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -772,7 +772,7 @@ async def websocket_device_cluster_commands( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster commands.""" - import voluptuous_serialize # pylint: disable=import-outside-toplevel + import voluptuous_serialize # noqa: PLC0415 zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] @@ -1080,7 +1080,7 @@ async def websocket_get_configuration( ) -> None: """Get ZHA configuration.""" config_entry: ConfigEntry = get_config_entry(hass) - import voluptuous_serialize # pylint: disable=import-outside-toplevel + import voluptuous_serialize # noqa: PLC0415 def custom_serializer(schema: Any) -> Any: """Serialize additional types for voluptuous_serialize.""" diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index c1a24b6ea65..168df5edcaa 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -32,19 +32,19 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) -from zwave_js_server.firmware import controller_firmware_update_otw, update_firmware +from zwave_js_server.firmware import driver_firmware_update_otw, update_firmware from zwave_js_server.model.controller import ( ControllerStatistics, InclusionGrant, ProvisioningEntry, QRProvisioningInformation, ) -from zwave_js_server.model.controller.firmware import ( - ControllerFirmwareUpdateData, - ControllerFirmwareUpdateProgress, - ControllerFirmwareUpdateResult, -) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.driver.firmware import ( + DriverFirmwareUpdateData, + DriverFirmwareUpdateProgress, + DriverFirmwareUpdateResult, +) from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage @@ -2340,8 +2340,8 @@ def _get_node_firmware_update_progress_dict( } -def _get_controller_firmware_update_progress_dict( - progress: ControllerFirmwareUpdateProgress, +def _get_driver_firmware_update_progress_dict( + progress: DriverFirmwareUpdateProgress, ) -> dict[str, int | float]: """Get a dictionary of a controller's firmware update progress.""" return { @@ -2370,7 +2370,8 @@ async def websocket_subscribe_firmware_update_status( ) -> None: """Subscribe to the status of a firmware update.""" assert node.client.driver - controller = node.client.driver.controller + driver = node.client.driver + controller = driver.controller @callback def async_cleanup() -> None: @@ -2408,21 +2409,21 @@ async def websocket_subscribe_firmware_update_status( ) @callback - def forward_controller_progress(event: dict) -> None: - progress: ControllerFirmwareUpdateProgress = event["firmware_update_progress"] + def forward_driver_progress(event: dict) -> None: + progress: DriverFirmwareUpdateProgress = event["firmware_update_progress"] connection.send_message( websocket_api.event_message( msg[ID], { "event": event["event"], - **_get_controller_firmware_update_progress_dict(progress), + **_get_driver_firmware_update_progress_dict(progress), }, ) ) @callback - def forward_controller_finished(event: dict) -> None: - finished: ControllerFirmwareUpdateResult = event["firmware_update_finished"] + def forward_driver_finished(event: dict) -> None: + finished: DriverFirmwareUpdateResult = event["firmware_update_finished"] connection.send_message( websocket_api.event_message( msg[ID], @@ -2436,8 +2437,8 @@ async def websocket_subscribe_firmware_update_status( if controller.own_node == node: msg[DATA_UNSUBSCRIBE] = unsubs = [ - controller.on("firmware update progress", forward_controller_progress), - controller.on("firmware update finished", forward_controller_finished), + driver.on("firmware update progress", forward_driver_progress), + driver.on("firmware update finished", forward_driver_finished), ] else: msg[DATA_UNSUBSCRIBE] = unsubs = [ @@ -2447,17 +2448,13 @@ async def websocket_subscribe_firmware_update_status( connection.subscriptions[msg["id"]] = async_cleanup connection.send_result(msg[ID]) - if node.is_controller_node and ( - controller_progress := controller.firmware_update_progress - ): + if node.is_controller_node and (driver_progress := driver.firmware_update_progress): connection.send_message( websocket_api.event_message( msg[ID], { "event": "firmware update progress", - **_get_controller_firmware_update_progress_dict( - controller_progress - ), + **_get_driver_firmware_update_progress_dict(driver_progress), }, ) ) @@ -2559,9 +2556,9 @@ class FirmwareUploadView(HomeAssistantView): try: if node.client.driver.controller.own_node == node: - await controller_firmware_update_otw( + await driver_firmware_update_otw( node.client.ws_server_url, - ControllerFirmwareUpdateData( + DriverFirmwareUpdateData( uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), ), diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 1439aa0ca0f..d70690ace31 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -318,12 +318,37 @@ PROPERTY_SENSOR_MAPPINGS: dict[str, PropertyZWaveJSEntityDescription] = { # Mappings for boolean sensors -BOOLEAN_SENSOR_MAPPINGS: dict[int, BinarySensorEntityDescription] = { - CommandClass.BATTERY: BinarySensorEntityDescription( - key=str(CommandClass.BATTERY), +BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescription] = { + (CommandClass.BATTERY, "backup"): BinarySensorEntityDescription( + key="battery_backup", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "disconnected"): BinarySensorEntityDescription( + key="battery_disconnected", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "isLow"): BinarySensorEntityDescription( + key="battery_is_low", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + (CommandClass.BATTERY, "lowFluid"): BinarySensorEntityDescription( + key="battery_low_fluid", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "overheating"): BinarySensorEntityDescription( + key="battery_overheating", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "rechargeable"): BinarySensorEntityDescription( + key="battery_rechargeable", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), } @@ -432,8 +457,9 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): # Entity class attributes self._attr_name = self.generate_name(include_value_name=True) + primary_value = self.info.primary_value if description := BOOLEAN_SENSOR_MAPPINGS.get( - self.info.primary_value.command_class + (primary_value.command_class, primary_value.property_) ): self.entity_description = description diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index b27dbdad1a0..809d3543fe4 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -492,8 +492,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - if (hvac_mode_id := self._hvac_modes.get(hvac_mode)) is None: - raise ValueError(f"Received an invalid hvac mode: {hvac_mode}") if not self._current_mode: # Thermostat(valve) has no support for setting a mode, so we make it a no-op @@ -503,7 +501,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # can set it again when turning the device back on. if hvac_mode == HVACMode.OFF and self._current_mode.value != ThermostatMode.OFF: self._last_hvac_mode_id_before_off = self._current_mode.value - await self._async_set_value(self._current_mode, hvac_mode_id) + await self._async_set_value(self._current_mode, self._hvac_modes[hvac_mode]) async def async_turn_off(self) -> None: """Turn the entity off.""" diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 3d626710d52..a99e9fd0113 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -139,7 +139,10 @@ ATTR_TWIST_ASSIST = "twist_assist" ADDON_SLUG = "core_zwave_js" # Sensor entity description constants -ENTITY_DESC_KEY_BATTERY = "battery" +ENTITY_DESC_KEY_BATTERY_LEVEL = "battery_level" +ENTITY_DESC_KEY_BATTERY_LIST_STATE = "battery_list_state" +ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY = "battery_maximum_capacity" +ENTITY_DESC_KEY_BATTERY_TEMPERATURE = "battery_temperature" ENTITY_DESC_KEY_CURRENT = "current" ENTITY_DESC_KEY_VOLTAGE = "voltage" ENTITY_DESC_KEY_ENERGY_MEASUREMENT = "energy_measurement" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index b46735e4040..3b541a733cc 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -896,6 +896,7 @@ DISCOVERY_SCHEMAS = [ writeable=False, ), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # generic text sensors ZWaveDiscoverySchema( @@ -912,7 +913,6 @@ DISCOVERY_SCHEMAS = [ hint="numeric_sensor", primary_value=ZWaveValueDiscoverySchema( command_class={ - CommandClass.BATTERY, CommandClass.ENERGY_PRODUCTION, CommandClass.SENSOR_ALARM, CommandClass.SENSOR_MULTILEVEL, @@ -921,6 +921,36 @@ DISCOVERY_SCHEMAS = [ ), data_template=NumericSensorDataTemplate(), ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"level", "maximumCapacity"}, + ), + data_template=NumericSensorDataTemplate(), + ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"temperature"}, + ), + data_template=NumericSensorDataTemplate(), + ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="list", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"chargingStatus", "rechargeOrReplace"}, + ), + data_template=NumericSensorDataTemplate(), + ), ZWaveDiscoverySchema( platform=Platform.SENSOR, hint="numeric_sensor", @@ -932,6 +962,7 @@ DISCOVERY_SCHEMAS = [ ), data_template=NumericSensorDataTemplate(), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # Meter sensors for Meter CC ZWaveDiscoverySchema( @@ -957,6 +988,7 @@ DISCOVERY_SCHEMAS = [ writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # button for Indicator CC ZWaveDiscoverySchema( @@ -980,6 +1012,7 @@ DISCOVERY_SCHEMAS = [ writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # binary switch # barrier operator signaling states @@ -1184,6 +1217,7 @@ DISCOVERY_SCHEMAS = [ any_available_states={(0, "idle")}, ), allow_multi=True, + entity_registry_enabled_default=False, ), # event # stateful = False @@ -1300,21 +1334,49 @@ def async_discover_single_value( continue # check device_class_generic + # If the value has an endpoint but it is missing on the node + # we can't match the endpoint device class to the schema device class. + # This could happen if the value is discovered after the node is ready. if schema.device_class_generic and ( - not value.node.device_class - or not any( - value.node.device_class.generic.label == val - for val in schema.device_class_generic + ( + (endpoint := value.endpoint) is None + or (node_endpoint := value.node.endpoints.get(endpoint)) is None + or (device_class := node_endpoint.device_class) is None + or not any( + device_class.generic.label == val + for val in schema.device_class_generic + ) + ) + and ( + (device_class := value.node.device_class) is None + or not any( + device_class.generic.label == val + for val in schema.device_class_generic + ) ) ): continue # check device_class_specific + # If the value has an endpoint but it is missing on the node + # we can't match the endpoint device class to the schema device class. + # This could happen if the value is discovered after the node is ready. if schema.device_class_specific and ( - not value.node.device_class - or not any( - value.node.device_class.specific.label == val - for val in schema.device_class_specific + ( + (endpoint := value.endpoint) is None + or (node_endpoint := value.node.endpoints.get(endpoint)) is None + or (device_class := node_endpoint.device_class) is None + or not any( + device_class.specific.label == val + for val in schema.device_class_specific + ) + ) + and ( + (device_class := value.node.device_class) is None + or not any( + device_class.specific.label == val + for val in schema.device_class_specific + ) ) ): continue diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index e619c6afc7c..731a786d226 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -133,7 +133,10 @@ from homeassistant.const import ( ) from .const import ( - ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_BATTERY_LEVEL, + ENTITY_DESC_KEY_BATTERY_LIST_STATE, + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, ENTITY_DESC_KEY_CO, ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, @@ -380,8 +383,31 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): def resolve_data(self, value: ZwaveValue) -> NumericSensorDataTemplateData: """Resolve helper class data for a discovered value.""" - if value.command_class == CommandClass.BATTERY: - return NumericSensorDataTemplateData(ENTITY_DESC_KEY_BATTERY, PERCENTAGE) + if value.command_class == CommandClass.BATTERY and value.property_ == "level": + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_LEVEL, PERCENTAGE + ) + if value.command_class == CommandClass.BATTERY and value.property_ in ( + "chargingStatus", + "rechargeOrReplace", + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_LIST_STATE, None + ) + if ( + value.command_class == CommandClass.BATTERY + and value.property_ == "maximumCapacity" + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, PERCENTAGE + ) + if ( + value.command_class == CommandClass.BATTERY + and value.property_ == "temperature" + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, UnitOfTemperature.CELSIUS + ) if value.command_class == CommandClass.METER: try: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 8719c333753..082a3dd9f95 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.63.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.64.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 4db14d003b1..05fa785760b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -58,7 +58,10 @@ from .const import ( ATTR_VALUE, DATA_CLIENT, DOMAIN, - ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_BATTERY_LEVEL, + ENTITY_DESC_KEY_BATTERY_LIST_STATE, + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, ENTITY_DESC_KEY_CO, ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, @@ -95,17 +98,33 @@ from .migrate import async_migrate_statistics_sensors PARALLEL_UPDATES = 0 -# These descriptions should include device class. -ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ - tuple[str, str], SensorEntityDescription -] = { - (ENTITY_DESC_KEY_BATTERY, PERCENTAGE): SensorEntityDescription( - key=ENTITY_DESC_KEY_BATTERY, +# These descriptions should have a non None unit of measurement. +ENTITY_DESCRIPTION_KEY_UNIT_MAP: dict[tuple[str, str], SensorEntityDescription] = { + (ENTITY_DESC_KEY_BATTERY_LEVEL, PERCENTAGE): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), + (ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, PERCENTAGE): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + ( + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, + UnitOfTemperature.CELSIUS, + ): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_registry_enabled_default=False, + ), (ENTITY_DESC_KEY_CURRENT, UnitOfElectricCurrent.AMPERE): SensorEntityDescription( key=ENTITY_DESC_KEY_CURRENT, device_class=SensorDeviceClass.CURRENT, @@ -285,8 +304,14 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ), } -# These descriptions are without device class. +# These descriptions are without unit of measurement. ENTITY_DESCRIPTION_KEY_MAP = { + ENTITY_DESC_KEY_BATTERY_LIST_STATE: SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_LIST_STATE, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ENTITY_DESC_KEY_CO: SensorEntityDescription( key=ENTITY_DESC_KEY_CO, state_class=SensorStateClass.MEASUREMENT, @@ -538,7 +563,7 @@ def get_entity_description( """Return the entity description for the given data.""" data_description_key = data.entity_description_key or "" data_unit = data.unit_of_measurement or "" - return ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP.get( + return ENTITY_DESCRIPTION_KEY_UNIT_MAP.get( (data_description_key, data_unit), ENTITY_DESCRIPTION_KEY_MAP.get( data_description_key, @@ -588,6 +613,10 @@ async def async_setup_entry( entities.append( ZWaveListSensor(config_entry, driver, info, entity_description) ) + elif info.platform_hint == "list": + entities.append( + ZWaveListSensor(config_entry, driver, info, entity_description) + ) elif info.platform_hint == "config_parameter": entities.append( ZWaveConfigParameterSensor( diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 33195fe6c8b..076e3b6a50d 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -58,6 +58,7 @@ TARGET_VALIDATORS = { } +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register integration services.""" services = ZWaveServices(hass, er.async_get(hass), dr.async_get(hass)) diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index b8b8662c0b5..f74357327e9 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -166,9 +166,9 @@ async def async_attach_trigger( if ( config[ATTR_PARTIAL_DICT_MATCH] and isinstance(event_data[key], dict) - and isinstance(event_data_filter[key], dict) + and isinstance(val, dict) ): - for key2, val2 in event_data_filter[key].items(): + for key2, val2 in val.items(): if key2 not in event_data[key] or event_data[key][key2] != val2: return continue diff --git a/homeassistant/config.py b/homeassistant/config.py index c3f02539f7d..ca1c87e4a11 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1321,8 +1321,7 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> str | None: This method is a coroutine. """ - # pylint: disable-next=import-outside-toplevel - from .helpers import check_config + from .helpers import check_config # noqa: PLC0415 res = await check_config.async_check_ha_config_file(hass) diff --git a/homeassistant/const.py b/homeassistant/const.py index f692f428920..0abdcd59b77 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -40,6 +40,7 @@ PLATFORM_FORMAT: Final = "{platform}.{domain}" class Platform(StrEnum): """Available entity platforms.""" + AI_TASK = "ai_task" AIR_QUALITY = "air_quality" ALARM_CONTROL_PANEL = "alarm_control_panel" ASSIST_SATELLITE = "assist_satellite" diff --git a/homeassistant/core.py b/homeassistant/core.py index afffb883741..c5d4ca79371 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -179,8 +179,7 @@ class EventStateReportedData(EventStateEventData): def _deprecated_core_config() -> Any: - # pylint: disable-next=import-outside-toplevel - from . import core_config + from . import core_config # noqa: PLC0415 return core_config.Config @@ -428,8 +427,7 @@ class HomeAssistant: def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" - # pylint: disable-next=import-outside-toplevel - from .core_config import Config + from .core_config import Config # noqa: PLC0415 # This is a dictionary that any component can store any data on. self.data = HassDict() @@ -458,7 +456,7 @@ class HomeAssistant: """Report and raise if we are not running in the event loop thread.""" if self.loop_thread_id != threading.get_ident(): # frame is a circular import, so we import it here - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_non_thread_safe_operation(what) @@ -522,8 +520,7 @@ class HomeAssistant: await self.async_start() if attach_signals: - # pylint: disable-next=import-outside-toplevel - from .helpers.signal import async_register_signal_handling + from .helpers.signal import async_register_signal_handling # noqa: PLC0415 async_register_signal_handling(self) @@ -643,7 +640,7 @@ class HomeAssistant: args: parameters for method to call. """ # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_add_job`, which should be reviewed against " @@ -699,7 +696,7 @@ class HomeAssistant: args: parameters for method to call. """ # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_add_hass_job`, which should be reviewed against " @@ -802,7 +799,7 @@ class HomeAssistant: target: target to call. """ if self.loop_thread_id != threading.get_ident(): - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_non_thread_safe_operation("hass.async_create_task") return self.async_create_task_internal(target, name, eager_start) @@ -973,7 +970,7 @@ class HomeAssistant: args: parameters for method to call. """ # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_run_job`, which should be reviewed against " @@ -1517,7 +1514,7 @@ class EventBus: """ _verify_event_type_length_or_raise(event_type) if self._hass.loop_thread_id != threading.get_ident(): - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_non_thread_safe_operation("hass.bus.async_fire") return self.async_fire_internal( @@ -1622,7 +1619,7 @@ class EventBus: """ if run_immediately in (True, False): # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_listen` with run_immediately", @@ -1692,7 +1689,7 @@ class EventBus: """ if run_immediately in (True, False): # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_listen_once` with run_immediately", diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f1ba96daae4..5ccd8a49f32 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -538,8 +538,7 @@ class Config: def __init__(self, hass: HomeAssistant, config_dir: str) -> None: """Initialize a new config object.""" - # pylint: disable-next=import-outside-toplevel - from .components.zone import DEFAULT_RADIUS + from .components.zone import DEFAULT_RADIUS # noqa: PLC0415 self.hass = hass @@ -845,8 +844,7 @@ class Config: ) -> dict[str, Any]: """Migrate to the new version.""" - # pylint: disable-next=import-outside-toplevel - from .components.zone import DEFAULT_RADIUS + from .components.zone import DEFAULT_RADIUS # noqa: PLC0415 data = old_data if old_major_version == 1 and old_minor_version < 2: @@ -863,8 +861,9 @@ class Config: try: owner = await self.hass.auth.async_get_owner() if owner is not None: - # pylint: disable-next=import-outside-toplevel - from .components.frontend import storage as frontend_store + from .components.frontend import ( # noqa: PLC0415 + storage as frontend_store, + ) owner_store = await frontend_store.async_user_store( self.hass, owner.id diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 0b2d2c071c5..23416480dd7 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -23,8 +23,7 @@ def import_async_get_exception_message() -> Callable[ Defaults to English, requires translations to already be cached. """ - # pylint: disable-next=import-outside-toplevel - from .helpers.translation import ( + from .helpers.translation import ( # noqa: PLC0415 async_get_exception_message as async_get_exception_message_import, ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 86f45c44fdc..119830b6111 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -47,7 +47,7 @@ FLOWS = { "airzone", "airzone_cloud", "alarmdecoder", - "amazon_devices", + "alexa_devices", "amberelectric", "ambient_network", "ambient_station", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index dc46ddc6e16..3795bd838ea 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -207,11 +207,11 @@ "amazon": { "name": "Amazon", "integrations": { - "amazon_devices": { + "alexa_devices": { "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", - "name": "Amazon Devices" + "name": "Alexa Devices" }, "amazon_polly": { "integration_type": "hub", @@ -7475,7 +7475,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", - "name": "Xiaomi Miio" + "name": "Xiaomi Home" }, "xiaomi_tv": { "integration_type": "hub", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index ba02ed51f6b..cfc250754ec 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -475,8 +475,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): @callback def _async_setup_cleanup(self) -> None: """Set up the area registry cleanup.""" - # pylint: disable-next=import-outside-toplevel - from . import ( # Circular dependencies + from . import ( # Circular dependencies # noqa: PLC0415 floor_registry as fr, label_registry as lr, ) @@ -543,8 +542,7 @@ def async_entries_for_label(registry: AreaRegistry, label_id: str) -> list[AreaE def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: """Validate temperature entity.""" - # pylint: disable=import-outside-toplevel - from homeassistant.components.sensor import SensorDeviceClass + from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415 if not (state := hass.states.get(entity_id)): raise ValueError(f"Entity {entity_id} does not exist") @@ -558,8 +556,7 @@ def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: """Validate humidity entity.""" - # pylint: disable=import-outside-toplevel - from homeassistant.components.sensor import SensorDeviceClass + from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415 if not (state := hass.states.get(entity_id)): raise ValueError(f"Entity {entity_id} does not exist") diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py index b3607f6653c..e445bef4aae 100644 --- a/homeassistant/helpers/backup.py +++ b/homeassistant/helpers/backup.py @@ -43,8 +43,7 @@ def async_initialize_backup(hass: HomeAssistant) -> None: registers the basic backup websocket API which is used by frontend to subscribe to backup events. """ - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.backup import basic_websocket + from homeassistant.components.backup import basic_websocket # noqa: PLC0415 hass.data[DATA_BACKUP] = BackupData() basic_websocket.async_register_websocket_handlers(hass) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 45e2e7cf35f..761a9c5714e 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -222,16 +222,14 @@ class WebhookFlowHandler(config_entries.ConfigFlow): return self.async_show_form(step_id="user") # Local import to be sure cloud is loaded and setup - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import ( + from homeassistant.components.cloud import ( # noqa: PLC0415 async_active_subscription, async_create_cloudhook, async_is_connected, ) # Local import to be sure webhook is loaded and setup - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.webhook import ( + from homeassistant.components.webhook import ( # noqa: PLC0415 async_generate_id, async_generate_url, ) @@ -281,7 +279,6 @@ async def webhook_async_remove_entry( return # Local import to be sure cloud is loaded and setup - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import async_delete_cloudhook + from homeassistant.components.cloud import async_delete_cloudhook # noqa: PLC0415 await async_delete_cloudhook(hass, entry.data["webhook_id"]) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 31a3e365071..5445cb51ac9 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -721,8 +721,7 @@ def template(value: Any | None) -> template_helper.Template: if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") if not (hass := _async_get_hass_or_none()): - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage + from .frame import ReportBehavior, report_usage # noqa: PLC0415 report_usage( ( @@ -750,8 +749,7 @@ def dynamic_template(value: Any | None) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") if not (hass := _async_get_hass_or_none()): - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage + from .frame import ReportBehavior, report_usage # noqa: PLC0415 report_usage( ( @@ -1151,9 +1149,9 @@ def custom_serializer(schema: Any) -> Any: def _custom_serializer(schema: Any, *, allow_section: bool) -> Any: """Serialize additional types for voluptuous_serialize.""" - from homeassistant import data_entry_flow # pylint: disable=import-outside-toplevel + from homeassistant import data_entry_flow # noqa: PLC0415 - from . import selector # pylint: disable=import-outside-toplevel + from . import selector # noqa: PLC0415 if schema is positive_time_period_dict: return {"type": "positive_time_period_dict"} @@ -1216,8 +1214,7 @@ def _no_yaml_config_schema( """Return a config schema which logs if attempted to setup from YAML.""" def raise_issue() -> None: - # pylint: disable-next=import-outside-toplevel - from .issue_registry import IssueSeverity, async_create_issue + from .issue_registry import IssueSeverity, async_create_issue # noqa: PLC0415 # HomeAssistantError is raised if called from the wrong thread with contextlib.suppress(HomeAssistantError): diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 101b9731caf..20b5b7ebab9 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -190,11 +190,10 @@ def _print_deprecation_warning_internal_impl( *, log_when_no_integration_is_found: bool, ) -> None: - # pylint: disable=import-outside-toplevel - from homeassistant.core import async_get_hass_or_none - from homeassistant.loader import async_suggest_report_issue + from homeassistant.core import async_get_hass_or_none # noqa: PLC0415 + from homeassistant.loader import async_suggest_report_issue # noqa: PLC0415 - from .frame import MissingIntegrationFrame, get_integration_frame + from .frame import MissingIntegrationFrame, get_integration_frame # noqa: PLC0415 logger = logging.getLogger(module_name) if breaks_in_ha_version: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 4f36ff8ec94..a6313381492 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1018,8 +1018,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): and old.area_id is None ): # Circular dep - # pylint: disable-next=import-outside-toplevel - from . import area_registry as ar + from . import area_registry as ar # noqa: PLC0415 area = ar.async_get(self.hass).async_get_or_create(suggested_area) area_id = area.id @@ -1622,8 +1621,7 @@ def async_cleanup( @callback def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: """Clean up device registry when entities removed.""" - # pylint: disable-next=import-outside-toplevel - from . import entity_registry, label_registry as lr + from . import entity_registry, label_registry as lr # noqa: PLC0415 @callback def _label_removed_from_registry_filter( diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 8b13ee2409a..832bbf219f8 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,7 +7,7 @@ import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping import dataclasses -from enum import Enum, IntFlag, auto +from enum import Enum, auto import functools as ft import logging import math @@ -199,7 +199,6 @@ class EntityInfo(TypedDict): """Entity info.""" domain: str - custom_component: bool config_entry: NotRequired[str] @@ -1450,10 +1449,8 @@ 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 @@ -1625,31 +1622,6 @@ class Entity( self.hass, integration_domain=platform_name, module=type(self).__module__ ) - @callback - def _report_deprecated_supported_features_values( - self, replacement: IntFlag - ) -> None: - """Report deprecated supported features values.""" - if self._deprecated_supported_features_reported is True: - return - self._deprecated_supported_features_reported = True - report_issue = self._suggest_report_issue() - report_issue += ( - " and reference " - "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" - ) - _LOGGER.warning( - ( - "Entity %s (%s) is using deprecated supported features" - " values which will be removed in HA Core 2025.1. Instead it should use" - " %s, please %s" - ), - self.entity_id, - type(self), - repr(replacement), - report_issue, - ) - class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0cb668a5ffd..0b61c3e8f16 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1745,8 +1745,7 @@ def async_config_entry_disabled_by_changed( @callback def _async_setup_cleanup(hass: HomeAssistant, registry: EntityRegistry) -> None: """Clean up device registry when entities removed.""" - # pylint: disable-next=import-outside-toplevel - from . import category_registry as cr, event, label_registry as lr + from . import category_registry as cr, event, label_registry as lr # noqa: PLC0415 @callback def _removed_from_registry_filter( diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index a97dd48bf61..176bcfcd7c4 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -235,10 +235,7 @@ def find_paths_unserializable_data( This method is slow! Only use for error handling. """ - from homeassistant.core import ( # pylint: disable=import-outside-toplevel - Event, - State, - ) + from homeassistant.core import Event, State # noqa: PLC0415 to_process = deque([(bad_data, "$")]) invalid = {} diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index adf113e0f30..5d9e4c3bdef 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -160,11 +160,19 @@ class LLMContext: """Tool input to be processed.""" platform: str + """Integration that is handling the LLM request.""" + context: Context | None - user_prompt: str | None + """Context of the LLM request.""" + language: str | None + """Language of the LLM request.""" + assistant: str | None + """Assistant domain that is handling the LLM request.""" + device_id: str | None + """Device that is making the request.""" @dataclass(slots=True) @@ -208,8 +216,7 @@ class APIInstance: async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: """Call a LLM tool, validate args and return the response.""" - # pylint: disable=import-outside-toplevel - from homeassistant.components.conversation import ( + from homeassistant.components.conversation import ( # noqa: PLC0415 ConversationTraceEventType, async_conversation_trace_append, ) @@ -302,7 +309,7 @@ class IntentTool(Tool): platform=llm_context.platform, intent_type=self.name, slots=slots, - text_input=llm_context.user_prompt, + text_input=None, context=llm_context.context, language=llm_context.language, assistant=llm_context.assistant, @@ -893,6 +900,12 @@ class ActionTool(Tool): self._domain = domain self._action = action self.name = f"{domain}.{action}" + # Note: _get_cached_action_parameters only works for services which + # add their description directly to the service description cache. + # This is not the case for most services, but it is for scripts. + # If we want to use `ActionTool` for services other than scripts, we + # need to add a coroutine function to fetch the non-cached description + # and schema. self.description, self.parameters = _get_cached_action_parameters( hass, domain, action ) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 67c4448724e..6f4aadaf786 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -186,8 +186,7 @@ def get_url( known_hostnames = ["localhost"] if is_hassio(hass): # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.hassio import get_host_info + from homeassistant.components.hassio import get_host_info # noqa: PLC0415 if host_info := get_host_info(hass): known_hostnames.extend( @@ -318,8 +317,7 @@ def _get_cloud_url(hass: HomeAssistant, require_current_request: bool = False) - """Get external Home Assistant Cloud URL of this instance.""" if "cloud" in hass.config.components: # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import ( + from homeassistant.components.cloud import ( # noqa: PLC0415 CloudNotAvailable, async_remote_ui_url, ) diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 7ad319419c1..1698646d6b5 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -35,8 +35,7 @@ class RecorderData: @callback def async_migration_in_progress(hass: HomeAssistant) -> bool: """Check to see if a recorder migration is in progress.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder + from homeassistant.components import recorder # noqa: PLC0415 return recorder.util.async_migration_in_progress(hass) @@ -44,8 +43,7 @@ def async_migration_in_progress(hass: HomeAssistant) -> bool: @callback def async_migration_is_live(hass: HomeAssistant) -> bool: """Check to see if a recorder migration is live.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder + from homeassistant.components import recorder # noqa: PLC0415 return recorder.util.async_migration_is_live(hass) @@ -58,8 +56,9 @@ def async_initialize_recorder(hass: HomeAssistant) -> None: registers the basic recorder websocket API which is used by frontend to determine if the recorder is migrating the database. """ - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder.basic_websocket_api import async_setup + from homeassistant.components.recorder.basic_websocket_api import ( # noqa: PLC0415 + async_setup, + ) hass.data[DATA_RECORDER] = RecorderData() async_setup(hass) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 2d7fd51cac7..322cfe34042 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1216,6 +1216,39 @@ class SelectSelector(Selector[SelectSelectorConfig]): return [parent_schema(vol.Schema(str)(val)) for val in data] +class StatisticSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent a statistic selector config.""" + + multiple: bool + + +@SELECTORS.register("statistic") +class StatisticSelector(Selector[StatisticSelectorConfig]): + """Selector of a single or list of statistics.""" + + selector_type = "statistic" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("multiple", default=False): cv.boolean, + } + ) + + def __init__(self, config: StatisticSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str | list[str]: + """Validate the passed selection.""" + + if not self.config["multiple"]: + stat: str = vol.Schema(str)(data) + return stat + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [vol.Schema(str)(val) for val in data] + + class TargetSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a target selector config.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index f157e82bc53..51d9c97ceeb 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -85,8 +85,7 @@ ALL_SERVICE_DESCRIPTIONS_CACHE: HassKey[ @cache def _base_components() -> dict[str, ModuleType]: """Return a cached lookup of base components.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import ( + from homeassistant.components import ( # noqa: PLC0415 alarm_control_panel, assist_satellite, calendar, @@ -682,9 +681,12 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T def _load_services_files( hass: HomeAssistant, integrations: Iterable[Integration] -) -> list[JSON_TYPE]: +) -> dict[str, JSON_TYPE]: """Load service files for multiple integrations.""" - return [_load_services_file(hass, integration) for integration in integrations] + return { + integration.domain: _load_services_file(hass, integration) + for integration in integrations + } @callback @@ -715,7 +717,6 @@ async def async_get_all_descriptions( for service_name in services_by_domain } # If we have a complete cache, check if it is still valid - all_cache: tuple[set[tuple[str, str]], dict[str, dict[str, Any]]] | None if all_cache := hass.data.get(ALL_SERVICE_DESCRIPTIONS_CACHE): previous_all_services, previous_descriptions_cache = all_cache # If the services are the same, we can return the cache @@ -741,13 +742,16 @@ async def async_get_all_descriptions( continue if TYPE_CHECKING: assert isinstance(int_or_exc, Exception) - _LOGGER.error("Failed to load integration: %s", domain, exc_info=int_or_exc) + _LOGGER.error( + "Failed to load services.yaml for integration: %s", + domain, + exc_info=int_or_exc, + ) if integrations: - contents = await hass.async_add_executor_job( + loaded = await hass.async_add_executor_job( _load_services_files, hass, integrations ) - loaded = dict(zip(domains_with_missing_services, contents, strict=False)) # Load translations for all service domains translations = await translation.async_get_translations( @@ -770,7 +774,7 @@ async def async_get_all_descriptions( # Cache missing descriptions domain_yaml = loaded.get(domain) or {} # The YAML may be empty for dynamically defined - # services (ie shell_command) that never call + # services (e.g. shell_command) that never call # service.async_set_service_schema for the dynamic # service @@ -1291,8 +1295,7 @@ def async_register_entity_service( if schema is None or isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) elif not cv.is_entity_service_schema(schema): - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage + from .frame import ReportBehavior, report_usage # noqa: PLC0415 report_usage( "registers an entity service with a non entity service schema", diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index fe94be68763..2dd9decb582 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -354,7 +354,7 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]: corrupt_path, err, ) - from .issue_registry import ( # pylint: disable=import-outside-toplevel + from .issue_registry import ( # noqa: PLC0415 IssueSeverity, async_create_issue, ) diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 8f5e2418b14..1c35f45d713 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -31,8 +31,8 @@ def get_astral_location( hass: HomeAssistant, ) -> tuple[astral.location.Location, astral.Elevation]: """Get an astral location for the current Home Assistant configuration.""" - from astral import LocationInfo # pylint: disable=import-outside-toplevel - from astral.location import Location # pylint: disable=import-outside-toplevel + from astral import LocationInfo # noqa: PLC0415 + from astral.location import Location # noqa: PLC0415 latitude = hass.config.latitude longitude = hass.config.longitude diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index df9679dcb08..30b7616319d 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -42,8 +42,7 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: # may not be loaded yet and we don't want to # do blocking I/O in the event loop to import it. if TYPE_CHECKING: - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import hassio + from homeassistant.components import hassio # noqa: PLC0415 else: hassio = await async_import_module(hass, "homeassistant.components.hassio") diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9079d6af300..acf78f70380 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -210,9 +210,7 @@ def async_setup(hass: HomeAssistant) -> bool: if new_size > current_size: lru.set_size(new_size) - from .event import ( # pylint: disable=import-outside-toplevel - async_track_time_interval, - ) + from .event import async_track_time_interval # noqa: PLC0415 cancel = async_track_time_interval( hass, _async_adjust_lru_sizes, timedelta(minutes=10) @@ -527,8 +525,7 @@ class Template: Note: A valid hass instance should always be passed in. The hass parameter will be non optional in Home Assistant Core 2025.10. """ - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage + from .frame import ReportBehavior, report_usage # noqa: PLC0415 if not isinstance(template, str): raise TypeError("Expected template to be a string") @@ -1141,8 +1138,7 @@ class TemplateStateBase(State): def format_state(self, rounded: bool, with_unit: bool) -> str: """Return a formatted version of the state.""" # Import here, not at top-level, to avoid circular import - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.sensor import ( + from homeassistant.components.sensor import ( # noqa: PLC0415 DOMAIN as SENSOR_DOMAIN, async_rounded_state, ) @@ -1278,7 +1274,7 @@ def forgiving_boolean[_T]( """Try to convert value to a boolean.""" try: # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 return cv.boolean(value) except vol.Invalid: @@ -1303,7 +1299,7 @@ def result_as_boolean(template_result: Any | None) -> bool: def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: """Expand out any groups and zones into entity states.""" # circular import. - from . import entity as entity_helper # pylint: disable=import-outside-toplevel + from . import entity as entity_helper # noqa: PLC0415 search = list(args) found = {} @@ -1376,8 +1372,7 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: return entities # fallback to just returning all entities for a domain - # pylint: disable-next=import-outside-toplevel - from .entity import entity_sources + from .entity import entity_sources # noqa: PLC0415 return [ entity_id @@ -1421,7 +1416,7 @@ def device_name(hass: HomeAssistant, lookup_value: str) -> str | None: ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1579,7 +1574,7 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1617,7 +1612,7 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: dev_reg = device_registry.async_get(hass) ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1698,7 +1693,7 @@ def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 lookup_value = str(lookup_value) diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index 65774a0b168..dde456bf7bc 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -41,8 +41,7 @@ def _deprecated_typing_helper(attr: str) -> DeferredDeprecatedAlias: """Help to make a DeferredDeprecatedAlias.""" def value_fn() -> Any: - # pylint: disable-next=import-outside-toplevel - import homeassistant.core + import homeassistant.core # noqa: PLC0415 return getattr(homeassistant.core, attr) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 0980a6f2ba9..6a3061b0d2a 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -291,7 +291,7 @@ def _get_custom_components(hass: HomeAssistant) -> dict[str, Integration]: return {} try: - import custom_components # pylint: disable=import-outside-toplevel + import custom_components # noqa: PLC0415 except ImportError: return {} @@ -1392,7 +1392,7 @@ async def async_get_integrations( # Now the rest use resolve_from_root if needed: - from . import components # pylint: disable=import-outside-toplevel + from . import components # noqa: PLC0415 integrations = await hass.async_add_executor_job( _resolve_integrations_from_root, hass, components, needed @@ -1728,7 +1728,7 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: sys.path.insert(0, hass.config.config_dir) with suppress(ImportError): - import custom_components # pylint: disable=import-outside-toplevel # noqa: F401 + import custom_components # noqa: F401, PLC0415 sys.path.remove(hass.config.config_dir) sys.path_importer_cache.pop(hass.config.config_dir, None) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3ecb015a9e7..3eb77beed93 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,11 +2,12 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 -aiodns==3.4.0 +aiodns==3.5.0 +aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.12 +aiohttp==3.12.13 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 @@ -35,10 +36,10 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==3.49.0 -hass-nabucasa==0.101.0 +hass-nabucasa==0.103.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.0 +home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 httpx==0.28.1 ifaddr==0.2.0 @@ -130,7 +131,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.5 +pydantic==2.11.7 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -201,14 +202,6 @@ 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 diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index f0600b70f48..52d96109bf2 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -46,10 +46,8 @@ def run(args: list[str]) -> int: config_dir = extract_config_dir() - loop = asyncio.get_event_loop() - if not is_virtual_env(): - loop.run_until_complete(async_mount_local_lib_path(config_dir)) + asyncio.run(async_mount_local_lib_path(config_dir)) _pip_kwargs = pip_kwargs(config_dir) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 981f0a26926..213a45a48e9 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -47,8 +47,7 @@ WARNING_STR = "General Warnings" def color(the_color, *args, reset=None): """Color helper.""" - # pylint: disable-next=import-outside-toplevel - from colorlog.escape_codes import escape_codes, parse_colors + from colorlog.escape_codes import escape_codes, parse_colors # noqa: PLC0415 try: if not args: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 39f0a7656f3..a631eb07ca2 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -101,8 +101,7 @@ def async_notify_setup_error( This method must be run in the event loop. """ - # pylint: disable-next=import-outside-toplevel - from .components import persistent_notification + from .components import persistent_notification # noqa: PLC0415 if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None: errors = hass.data[_DATA_PERSISTENT_ERRORS] = {} diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 6175f587318..6e47163e90a 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -53,6 +53,7 @@ "email": "Email", "host": "Host", "ip": "IP address", + "implementation": "Application Credentials", "language": "Language", "latitude": "Latitude", "llm_hass_api": "Control Home Assistant", @@ -71,7 +72,8 @@ "verify_ssl": "Verify SSL certificate" }, "description": { - "confirm_setup": "Do you want to start setup?" + "confirm_setup": "Do you want to start setup?", + "implementation": "The credentials you want to use to authenticate." }, "error": { "cannot_connect": "Failed to connect", diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index f8901d11114..593a169f75e 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -36,8 +36,7 @@ def create_eager_task[_T]( # If there is no running loop, create_eager_task is being called from # the wrong thread. # Late import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers import frame + from homeassistant.helpers import frame # noqa: PLC0415 frame.report_usage("attempted to create an asyncio task from a thread") raise diff --git a/homeassistant/util/signal_type.pyi b/homeassistant/util/signal_type.pyi index 9987c3a0931..933467c351a 100644 --- a/homeassistant/util/signal_type.pyi +++ b/homeassistant/util/signal_type.pyi @@ -31,9 +31,8 @@ def _test_signal_type_typing() -> None: # noqa: PYI048 This is tested during the mypy run. Do not move it to 'tests'! """ - # pylint: disable=import-outside-toplevel - from homeassistant.core import HomeAssistant - from homeassistant.helpers.dispatcher import ( + from homeassistant.core import HomeAssistant # noqa: PLC0415 + from homeassistant.helpers.dispatcher import ( # noqa: PLC0415 async_dispatcher_connect, async_dispatcher_send, ) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 1f8338a1ff7..0b5a9ca3c0e 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -6,7 +6,7 @@ from io import StringIO import os from typing import TextIO -from annotatedyaml import YAMLException, YamlTypeError +import annotatedyaml from annotatedyaml.loader import ( HAS_C_LOADER, JSON_TYPE, @@ -35,6 +35,10 @@ __all__ = [ ] +class YamlTypeError(HomeAssistantError): + """Raised by load_yaml_dict if top level data is not a dict.""" + + def load_yaml( fname: str | os.PathLike[str], secrets: Secrets | None = None ) -> JSON_TYPE | None: @@ -45,7 +49,7 @@ def load_yaml( """ try: return load_annotated_yaml(fname, secrets) - except YAMLException as exc: + except annotatedyaml.YAMLException as exc: raise HomeAssistantError(str(exc)) from exc @@ -59,9 +63,9 @@ def load_yaml_dict( """ try: return load_annotated_yaml_dict(fname, secrets) - except YamlTypeError: - raise - except YAMLException as exc: + except annotatedyaml.YamlTypeError as exc: + raise YamlTypeError(str(exc)) from exc + except annotatedyaml.YAMLException as exc: raise HomeAssistantError(str(exc)) from exc @@ -71,7 +75,7 @@ def parse_yaml( """Parse YAML with the fastest available loader.""" try: return parse_annotated_yaml(content, secrets) - except YAMLException as exc: + except annotatedyaml.YAMLException as exc: raise HomeAssistantError(str(exc)) from exc @@ -79,5 +83,5 @@ def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" try: return annotated_secret_yaml(loader, node) - except YAMLException as exc: + except annotatedyaml.YAMLException as exc: raise HomeAssistantError(str(exc)) from exc diff --git a/mypy.ini b/mypy.ini index da76e4ae2cd..1fdab75663e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -405,7 +405,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.alpha_vantage.*] +[mypy-homeassistant.components.alexa_devices.*] 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.amazon_devices.*] +[mypy-homeassistant.components.alpha_vantage.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index cc7b33d9946..41c07819fe8 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -70,7 +70,7 @@ _MODULES: dict[str, set[str]] = { "todo": {"TodoListEntity"}, "tts": {"TextToSpeechEntity"}, "update": {"UpdateEntity", "UpdateEntityDescription"}, - "vacuum": {"StateVacuumEntity", "VacuumEntity", "VacuumEntityDescription"}, + "vacuum": {"StateVacuumEntity", "VacuumEntityDescription"}, "wake_word": {"WakeWordDetectionEntity"}, "water_heater": {"WaterHeaterEntity"}, "weather": { diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 45a3e41f91a..0760cd33821 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2789,12 +2789,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { matches=_RESTORE_ENTITY_MATCH, ), ClassTypeHintMatch( - base_class="ToggleEntity", - matches=_TOGGLE_ENTITY_MATCH, - ), - ClassTypeHintMatch( - base_class="_BaseVacuum", + base_class="StateVacuumEntity", matches=[ + TypeHintMatch( + function_name="state", + return_type=["str", None], + ), TypeHintMatch( function_name="battery_level", return_type=["int", None], @@ -2821,6 +2821,16 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { return_type=None, has_async_counterpart=True, ), + TypeHintMatch( + function_name="start", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="pause", + return_type=None, + has_async_counterpart=True, + ), TypeHintMatch( function_name="return_to_base", kwargs_type="Any", @@ -2860,63 +2870,6 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), ], ), - ClassTypeHintMatch( - base_class="VacuumEntity", - matches=[ - TypeHintMatch( - function_name="status", - return_type=["str", None], - ), - TypeHintMatch( - function_name="start_pause", - kwargs_type="Any", - return_type=None, - has_async_counterpart=True, - ), - TypeHintMatch( - function_name="async_pause", - return_type=None, - ), - TypeHintMatch( - function_name="async_start", - return_type=None, - ), - ], - ), - ClassTypeHintMatch( - base_class="StateVacuumEntity", - matches=[ - TypeHintMatch( - function_name="state", - return_type=["str", None], - ), - TypeHintMatch( - function_name="start", - return_type=None, - has_async_counterpart=True, - ), - TypeHintMatch( - function_name="pause", - return_type=None, - has_async_counterpart=True, - ), - TypeHintMatch( - function_name="async_turn_on", - kwargs_type="Any", - return_type=None, - ), - TypeHintMatch( - function_name="async_turn_off", - kwargs_type="Any", - return_type=None, - ), - TypeHintMatch( - function_name="async_toggle", - kwargs_type="Any", - return_type=None, - ), - ], - ), ], "water_heater": [ ClassTypeHintMatch( diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 156309caba1..38dbf035604 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -25,18 +25,6 @@ _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", diff --git a/pyproject.toml b/pyproject.toml index ab84b14dc63..4295c23740f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,13 @@ classifiers = [ ] requires-python = ">=3.13.2" dependencies = [ - "aiodns==3.4.0", + "aiodns==3.5.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 "aiohasupervisor==0.3.1", - "aiohttp==3.12.12", + "aiohttp==3.12.13", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", @@ -52,7 +53,7 @@ dependencies = [ "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.101.0", + "hass-nabucasa==0.103.0", # hassil is indirectly imported from onboarding via the import chain # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs # to be setup in stage 0, but we don't want to also promote cloud with all its @@ -286,6 +287,7 @@ disable = [ # "global-statement", # PLW0603, ruff catches new occurrences, needs more work "global-variable-not-assigned", # PLW0602 "implicit-str-concat", # ISC001 + "import-outside-toplevel", # PLC0415 "import-self", # PLW0406 "inconsistent-quotes", # Q000 "invalid-envvar-default", # PLW1508 @@ -526,15 +528,11 @@ filterwarnings = [ # -- fixed, waiting for release / update # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 - "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", + "ignore:.*invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 "ignore:pkg_resources is deprecated as an API:UserWarning:datadog.util.compat", # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/majuss/lupupy/pull/15 - >0.3.2 - "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", - # https://github.com/nextcord/nextcord/pull/1095 - >=3.0.0 - "ignore:pkg_resources is deprecated as an API:UserWarning:nextcord.health_check", # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 "ignore::DeprecationWarning:holidays", # https://github.com/ReactiveX/RxPY/pull/716 - >4.0.4 @@ -548,7 +546,7 @@ filterwarnings = [ # https://github.com/rytilahti/python-miio/pull/1993 - >0.6.0.dev0 "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 - "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", + "ignore:.*invalid escape sequence:SyntaxWarning:.*stringcase", # https://github.com/xchwarze/samsung-tv-ws-api/pull/151 - >2.7.2 - 2024-12-06 # wrong stacklevel in aiohttp "ignore:verify_ssl is deprecated, use ssl=False instead:DeprecationWarning:aiohttp.client", @@ -571,47 +569,53 @@ filterwarnings = [ # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", - # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 + # https://github.com/thecynic/pylutron - v0.2.18 - 2025-04-15 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", # https://pypi.org/project/PyMetEireann/ - v2024.11.0 - 2024-11-23 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.17/pysnmp/smi/compiler.py#L23-L31 - v7.1.17 - 2025-03-19 + # https://github.com/lextudio/pysnmp/blob/v7.1.21/pysnmp/smi/compiler.py#L23-L31 - v7.1.21 - 2025-06-19 "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysnmp.smi.compiler", - # https://github.com/Python-roborock/python-roborock/issues/305 - 2.18.0 - 2025-04-06 + # https://github.com/Python-roborock/python-roborock/issues/305 - 2.19.0 - 2025-05-13 "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", - # https://github.com/Teslemetry/python-tesla-fleet-api - v1.1.1 - 2025-05-29 - "ignore:Protobuf gencode .* exactly one major version older than the runtime version 6.* at (car_server|common|errors|keys|managed_charging|signatures|universal_message|vcsec|vehicle):UserWarning:google.protobuf.runtime_version", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", # New in aiohttp - v3.9.0 "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", # - SyntaxWarnings # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 - "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", + "ignore:.*invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 - "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", + "ignore:.*invalid escape sequence:SyntaxWarning:.*panasonic_viera", # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 # https://github.com/koolsb/pyblackbird/pull/9 -> closed - "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", + "ignore:.*invalid escape sequence:SyntaxWarning:.*pyblackbird", # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", + "ignore:.*invalid escape sequence:SyntaxWarning:.*pyws66i", # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 - "ignore:invalid escape sequence:SyntaxWarning:.*sanix", + "ignore:.*invalid escape sequence:SyntaxWarning:.*sanix", # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 - "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty + "ignore:.*invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty # - pkg_resources + # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 + "ignore:pkg_resources is deprecated as an API:UserWarning:aiomusiccast", # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 "ignore:pkg_resources is deprecated as an API:UserWarning:pysiaalarm.data.data", - # https://pypi.org/project/pybotvac/ - v0.0.26 - 2025-02-26 + # https://pypi.org/project/pybotvac/ - v0.0.28 - 2025-06-11 "ignore:pkg_resources is deprecated as an API:UserWarning:pybotvac.version", # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 "ignore:pkg_resources is deprecated as an API:UserWarning:pymystrom", + # - SyntaxWarning - is with literal + # https://github.com/majuss/lupupy/pull/15 - >0.3.2 + # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 + # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 + # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 + "ignore:\"is.*\" with '.*' literal:SyntaxWarning:importlib._bootstrap", # -- New in Python 3.13 # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 @@ -627,11 +631,11 @@ filterwarnings = [ # -- Websockets 14.1 # https://websockets.readthedocs.io/en/stable/howto/upgrade.html "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", - # https://github.com/bluecurrent/HomeAssistantAPI + # https://github.com/bluecurrent/HomeAssistantAPI/pull/19 - >=1.2.4 "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", - # https://github.com/graphql-python/gql + # https://github.com/graphql-python/gql/pull/543 - >=4.0.0a0 "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", # -- unmaintained projects, last release about 2+ years @@ -654,21 +658,16 @@ filterwarnings = [ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", - # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 - "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 "ignore:pkg_resources is deprecated as an API:UserWarning:pilight", # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 - "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", + "ignore:.*invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 - "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", + "ignore:.*invalid escape sequence:SyntaxWarning:.*ppadb", # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", - # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", + "ignore:.*invalid escape sequence:SyntaxWarning:.*pydub.utils", # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 - "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", + "ignore:.*invalid escape sequence:SyntaxWarning:.*pypasser.utils", # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", @@ -814,6 +813,7 @@ ignore = [ "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW1641", # __eq__ without __hash__ "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception "PT018", # Assertion should be broken down into multiple parts @@ -837,6 +837,9 @@ ignore = [ "TRY400", # Use `logging.exception` instead of `logging.error` # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "UP046", # Non PEP 695 generic class + "UP047", # Non PEP 696 generic function + "UP049", # Avoid private type parameter names # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", diff --git a/requirements.txt b/requirements.txt index 73433860f71..b47d33e7a44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,10 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==3.4.0 +aiodns==3.5.0 +aiofiles==24.1.0 aiohasupervisor==0.3.1 -aiohttp==3.12.12 +aiohttp==3.12.13 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 @@ -23,7 +24,7 @@ ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 ha-ffmpeg==3.2.2 -hass-nabucasa==0.101.0 +hass-nabucasa==0.103.0 hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 447ec1f1df8..0a4627f2e59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -181,8 +181,8 @@ aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 -# homeassistant.components.amazon_devices -aioamazondevices==3.0.6 +# homeassistant.components.alexa_devices +aioamazondevices==3.1.14 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.5.1 +aioautomower==2025.6.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -223,7 +223,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 # homeassistant.components.dnsip -aiodns==3.4.0 +aiodns==3.5.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.1 +aioesphomeapi==32.2.4 # homeassistant.components.flo aioflo==2021.11.0 @@ -265,7 +265,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.1 +aiohomeconnect==0.18.1 # homeassistant.components.homekit_controller aiohomekit==3.2.15 @@ -369,7 +369,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.2 +aiorussound==4.6.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -429,7 +429,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.5 +aiowebdav2==0.4.6 # homeassistant.components.webostv aiowebostv==0.7.3 @@ -683,7 +683,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.12.4 +bthome-ble==3.13.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -765,7 +765,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.3.0 +deebot-client==13.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -893,7 +893,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.4.1 +eq3btsmart==2.1.0 # homeassistant.components.esphome esphome-dashboard-api==1.3.0 @@ -1124,22 +1124,23 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.101.0 +hass-nabucasa==0.103.0 # homeassistant.components.splunk hass-splunk==0.1.1 +# homeassistant.components.assist_satellite # homeassistant.components.conversation hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.0 +hdate[astral]==1.1.2 # homeassistant.components.heatmiser heatmiserV3==2.0.3 # homeassistant.components.here_travel_time -here-routing==1.0.1 +here-routing==1.2.0 # homeassistant.components.here_travel_time here-transit==1.2.1 @@ -1161,16 +1162,16 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.74 +holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.0 +home-assistant-frontend==20250531.3 # homeassistant.components.conversation home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.4 +homematicip==2.0.6 # homeassistant.components.horizon horimote==0.4.1 @@ -1185,7 +1186,7 @@ huawei-lte-api==1.11.0 huum==0.7.12 # homeassistant.components.hyperion -hyperion-py==0.7.5 +hyperion-py==0.7.6 # homeassistant.components.iammeter iammeter==0.2.1 @@ -1203,7 +1204,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 @@ -1448,7 +1449,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.27 +motionblinds==0.6.28 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1484,7 +1485,7 @@ nad-receiver==0.3.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.1.2 +nessclient==1.2.0 # homeassistant.components.netdata netdata==1.3.0 @@ -1505,7 +1506,7 @@ nexia==2.10.0 nextcloudmonitor==1.5.1 # homeassistant.components.discord -nextcord==2.6.0 +nextcord==3.1.0 # homeassistant.components.nextdns nextdns==4.0.0 @@ -1617,7 +1618,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.3 +opower==0.12.4 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1632,7 +1633,7 @@ orvibo==1.1.2 ourgroceries==1.5.4 # homeassistant.components.ovo_energy -ovoenergy==2.0.0 +ovoenergy==2.0.1 # homeassistant.components.p1_monitor p1monitor==3.1.0 @@ -1762,7 +1763,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.2.0 +py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1798,7 +1799,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.8 +pyHomee==1.2.10 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -2096,7 +2097,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.8 +pylamarzocco==2.0.9 # homeassistant.components.lastfm pylast==5.1.0 @@ -2114,7 +2115,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.0.0 +pylitterbot==2024.2.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 @@ -2156,7 +2157,7 @@ pymonoprice==0.4 pymsteams==0.1.12 # homeassistant.components.mysensors -pymysensors==0.24.0 +pymysensors==0.25.0 # homeassistant.components.iron_os pynecil==4.1.0 @@ -2210,7 +2211,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.4 +pyosoenergyapi==1.1.5 # homeassistant.components.opentherm_gw pyotgw==2.2.2 @@ -2236,7 +2237,7 @@ pypaperless==4.1.0 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.6 +pypck==0.8.9 # homeassistant.components.pglab pypglab==0.0.5 @@ -2320,7 +2321,7 @@ pyserial==3.5 pysesame2==1.0.1 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.2 +pyseventeentrack==1.1.1 # homeassistant.components.sia pysiaalarm==3.1.1 @@ -2338,10 +2339,10 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.8.2 +pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.4 +pysmartthings==3.2.5 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -2350,7 +2351,7 @@ pysmarty2==0.10.2 pysmhi==1.0.2 # homeassistant.components.edl21 -pysml==0.0.12 +pysml==0.1.5 # homeassistant.components.smlight pysmlight==0.2.6 @@ -2434,7 +2435,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==8.3.3 +python-homewizard-energy==9.1.1 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2452,7 +2453,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.11 +python-linkplay==0.2.12 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2652,13 +2653,13 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.5 +reolink-aio==0.14.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.66 +rflink==0.0.67 # homeassistant.components.ring ring-doorbell==0.9.13 @@ -2673,7 +2674,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.19.3 +rokuecp==0.19.5 # homeassistant.components.romy romy==0.0.10 @@ -2900,7 +2901,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.1.1 +tesla-fleet-api==1.2.0 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2987,7 +2988,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.13.0 +uiprotect==7.14.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -3059,7 +3060,7 @@ vultr==0.1.2 # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan -wakeonlan==2.1.0 +wakeonlan==3.1.0 # homeassistant.components.wallbox wallbox==0.9.0 @@ -3162,7 +3163,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.05.22 +yt-dlp[default]==2025.06.09 # homeassistant.components.zabbix zabbix-utils==2.0.2 @@ -3180,7 +3181,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.59 +zha==0.0.60 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 @@ -3192,7 +3193,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.63.0 +zwave-js-server-python==0.64.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index dca3b8f9675..29d2618c69d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -15,7 +15,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.17.0a2 pre-commit==4.2.0 -pydantic==2.11.5 +pydantic==2.11.7 pylint==3.3.7 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 @@ -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.6.1 +pytest-unordered==0.7.0 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.20250516 +types-aiofiles==24.1.0.20250606 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.31.0.3 +types-requests==2.32.4.20250611 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 588651b976a..fceefd04355 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,8 +169,8 @@ aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 -# homeassistant.components.amazon_devices -aioamazondevices==3.0.6 +# homeassistant.components.alexa_devices +aioamazondevices==3.1.14 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.5.1 +aioautomower==2025.6.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 @@ -211,7 +211,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 # homeassistant.components.dnsip -aiodns==3.4.0 +aiodns==3.5.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.1 +aioesphomeapi==32.2.4 # homeassistant.components.flo aioflo==2021.11.0 @@ -250,7 +250,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.1 +aiohomeconnect==0.18.1 # homeassistant.components.homekit_controller aiohomekit==3.2.15 @@ -351,7 +351,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.2 +aiorussound==4.6.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -411,7 +411,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.5 +aiowebdav2==0.4.6 # homeassistant.components.webostv aiowebostv==0.7.3 @@ -607,7 +607,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.12.4 +bthome-ble==3.13.1 # homeassistant.components.buienradar buienradar==1.0.6 @@ -665,7 +665,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.3.0 +deebot-client==13.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -772,7 +772,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.4.1 +eq3btsmart==2.1.0 # homeassistant.components.esphome esphome-dashboard-api==1.3.0 @@ -982,16 +982,17 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.101.0 +hass-nabucasa==0.103.0 +# homeassistant.components.assist_satellite # homeassistant.components.conversation hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.0 +hdate[astral]==1.1.2 # homeassistant.components.here_travel_time -here-routing==1.0.1 +here-routing==1.2.0 # homeassistant.components.here_travel_time here-transit==1.2.1 @@ -1007,16 +1008,16 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.74 +holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.0 +home-assistant-frontend==20250531.3 # homeassistant.components.conversation home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.4 +homematicip==2.0.6 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1028,7 +1029,7 @@ huawei-lte-api==1.11.0 huum==0.7.12 # homeassistant.components.hyperion -hyperion-py==0.7.5 +hyperion-py==0.7.6 # homeassistant.components.iaqualink iaqualink==0.5.3 @@ -1040,7 +1041,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 @@ -1237,7 +1238,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.27 +motionblinds==0.6.28 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 @@ -1270,7 +1271,7 @@ myuplink==0.7.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.1.2 +nessclient==1.2.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 @@ -1285,7 +1286,7 @@ nexia==2.10.0 nextcloudmonitor==1.5.1 # homeassistant.components.discord -nextcord==2.6.0 +nextcord==3.1.0 # homeassistant.components.nextdns nextdns==4.0.0 @@ -1370,7 +1371,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.3 +opower==0.12.4 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1379,7 +1380,7 @@ oralb-ble==0.17.6 ourgroceries==1.5.4 # homeassistant.components.ovo_energy -ovoenergy==2.0.0 +ovoenergy==2.0.1 # homeassistant.components.p1_monitor p1monitor==3.1.0 @@ -1485,7 +1486,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.2.0 +py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 @@ -1509,7 +1510,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.8 +pyHomee==1.2.10 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -1738,7 +1739,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.8 +pylamarzocco==2.0.9 # homeassistant.components.lastfm pylast==5.1.0 @@ -1756,7 +1757,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.0.0 +pylitterbot==2024.2.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 @@ -1789,7 +1790,7 @@ pymodbus==3.9.2 pymonoprice==0.4 # homeassistant.components.mysensors -pymysensors==0.24.0 +pymysensors==0.25.0 # homeassistant.components.iron_os pynecil==4.1.0 @@ -1834,7 +1835,7 @@ pyopenweathermap==0.2.2 pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.4 +pyosoenergyapi==1.1.5 # homeassistant.components.opentherm_gw pyotgw==2.2.2 @@ -1857,7 +1858,7 @@ pypalazzetti==0.1.19 pypaperless==4.1.0 # homeassistant.components.lcn -pypck==0.8.6 +pypck==0.8.9 # homeassistant.components.pglab pypglab==0.0.5 @@ -1923,7 +1924,7 @@ pysensibo==1.2.1 pyserial==3.5 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.2 +pyseventeentrack==1.1.1 # homeassistant.components.sia pysiaalarm==3.1.1 @@ -1938,10 +1939,10 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.8.2 +pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.4 +pysmartthings==3.2.5 # homeassistant.components.smarty pysmarty2==0.10.2 @@ -1950,7 +1951,7 @@ pysmarty2==0.10.2 pysmhi==1.0.2 # homeassistant.components.edl21 -pysml==0.0.12 +pysml==0.1.5 # homeassistant.components.smlight pysmlight==0.2.6 @@ -2010,7 +2011,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==8.3.3 +python-homewizard-energy==9.1.1 # homeassistant.components.izone python-izone==1.2.9 @@ -2022,7 +2023,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.11 +python-linkplay==0.2.12 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -2195,16 +2196,16 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.5 +reolink-aio==0.14.1 # homeassistant.components.rflink -rflink==0.0.66 +rflink==0.0.67 # homeassistant.components.ring ring-doorbell==0.9.13 # homeassistant.components.roku -rokuecp==0.19.3 +rokuecp==0.19.5 # homeassistant.components.romy romy==0.0.10 @@ -2383,7 +2384,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.1.1 +tesla-fleet-api==1.2.0 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2458,7 +2459,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.13.0 +uiprotect==7.14.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2518,7 +2519,7 @@ vultr==0.1.2 # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan -wakeonlan==2.1.0 +wakeonlan==3.1.0 # homeassistant.components.wallbox wallbox==0.9.0 @@ -2606,7 +2607,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.05.22 +yt-dlp[default]==2025.06.09 # homeassistant.components.zamg zamg==0.3.6 @@ -2621,10 +2622,10 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.59 +zha==0.0.60 # homeassistant.components.zwave_js -zwave-js-server-python==0.63.0 +zwave-js-server-python==0.64.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index ba05be7043b..1abbf3977cf 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.11.12 +ruff==0.12.0 yamllint==1.37.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 486434c6b00..2e3ecccf5d2 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -155,7 +155,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.5 +pydantic==2.11.7 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -226,14 +226,6 @@ 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 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 95966ddbdab..72bd1ab3e7d 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -27,7 +27,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ stdlib-list==0.10.0 \ pipdeptree==2.26.1 \ tqdm==4.67.1 \ - ruff==0.11.12 \ + ruff==0.12.0 \ PyTurboJPEG==1.8.0 \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 73cd0bc37d9..52e5f935117 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -480,7 +480,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "hko", "hlk_sw16", "holiday", - "home_connect", "homekit", "homekit_controller", "homematic", @@ -1528,7 +1527,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "hko", "hlk_sw16", "holiday", - "home_connect", "homekit", "homekit_controller", "homematic", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index b8265e4e58d..0576a5b9b6a 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -109,11 +109,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "devialet": {"async-upnp-client": {"async-timeout"}}, "dlna_dmr": {"async-upnp-client": {"async-timeout"}}, "dlna_dms": {"async-upnp-client": {"async-timeout"}}, - "edl21": { - # https://github.com/mtdcr/pysml/issues/21 - # pysml > pyserial-asyncio - "pysml": {"pyserial-asyncio", "async-timeout"}, - }, "efergy": { # https://github.com/tkdrob/pyefergy/issues/46 # pyefergy > codecov @@ -225,21 +220,11 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # pymonoprice > pyserial-asyncio "pymonoprice": {"pyserial-asyncio"} }, - "mysensors": { - # https://github.com/theolind/pymysensors/issues/818 - # pymysensors > pyserial-asyncio - "pymysensors": {"pyserial-asyncio"} - }, "mystrom": { # https://github.com/home-assistant-ecosystem/python-mystrom/issues/55 # python-mystrom > setuptools "python-mystrom": {"setuptools"} }, - "ness_alarm": { - # https://github.com/nickw444/nessclient/issues/73 - # nessclient > pyserial-asyncio - "nessclient": {"pyserial-asyncio"} - }, "nibe_heatpump": {"nibe": {"async-timeout"}}, "norway_air": {"pymetno": {"async-timeout"}}, "nx584": { @@ -260,16 +245,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # opower > arrow > types-python-dateutil "arrow": {"types-python-dateutil"} }, - "osoenergy": { - # https://github.com/osohotwateriot/apyosohotwaterapi/pull/4 - # pyosoenergyapi > unasync > setuptools - "unasync": {"setuptools"} - }, - "ovo_energy": { - # https://github.com/timmo001/ovoenergy/issues/132 - # ovoenergy > incremental > setuptools - "incremental": {"setuptools"} - }, "pi_hole": {"hole": {"async-timeout"}}, "pvpc_hourly_pricing": {"aiopvpc": {"async-timeout"}}, "remote_rpi_gpio": { @@ -277,11 +252,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # gpiozero > colorzero > setuptools "colorzero": {"setuptools"} }, - "rflink": { - # https://github.com/aequitas/python-rflink/issues/78 - # rflink > pyserial-asyncio - "rflink": {"pyserial-asyncio", "async-timeout"} - }, "ring": {"ring-doorbell": {"async-timeout"}}, "rmvtransport": {"pyrmvtransport": {"async-timeout"}}, "roborock": {"python-roborock": {"async-timeout"}}, @@ -331,10 +301,6 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # https://github.com/hbldh/bleak/pull/1718 (not yet released) "homeassistant": {"bleak"} }, - "eq3btsmart": { - # https://github.com/EuleMitKeule/eq3btsmart/releases/tag/2.0.0 - "homeassistant": {"eq3btsmart"} - }, "python_script": { # Security audits are needed for each Python version "homeassistant": {"restrictedpython"} diff --git a/script/lint_and_test.py b/script/lint_and_test.py index fb350c113b9..44d9e5d8eb7 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -42,8 +42,7 @@ def printc(the_color, *args): def validate_requirements_ok(): """Validate requirements, returns True of ok.""" - # pylint: disable-next=import-outside-toplevel - from gen_requirements_all import main as req_main + from gen_requirements_all import main as req_main # noqa: PLC0415 return req_main(True) == 0 diff --git a/script/version_bump.py b/script/version_bump.py index ff94c01a5a2..2a7d82937f1 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -198,7 +198,7 @@ def main() -> None: def test_bump_version() -> None: """Make sure it all works.""" - import pytest + import pytest # noqa: PLC0415 assert bump_version(Version("0.56.0"), "beta") == Version("0.56.1b0") assert bump_version(Version("0.56.0b3"), "beta") == Version("0.56.0b4") diff --git a/tests/common.py b/tests/common.py index 66129ecc9c3..d184d2b46fb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -452,11 +452,9 @@ def async_fire_mqtt_message( # Local import to avoid processing MQTT modules when running a testcase # which does not use MQTT. - # pylint: disable-next=import-outside-toplevel - from paho.mqtt.client import MQTTMessage + from paho.mqtt.client import MQTTMessage # noqa: PLC0415 - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.mqtt import MqttData + from homeassistant.components.mqtt import MqttData # noqa: PLC0415 if isinstance(payload, str): payload = payload.encode("utf-8") @@ -569,6 +567,12 @@ def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.P ) +@lru_cache +def load_fixture_bytes(filename: str, integration: str | None = None) -> bytes: + """Load a fixture.""" + return get_fixture_path(filename, integration).read_bytes() + + @lru_cache def load_fixture(filename: str, integration: str | None = None) -> str: """Load a fixture.""" @@ -1736,8 +1740,7 @@ def async_get_persistent_notifications( def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> None: """Mock a signal the cloud disconnected.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import ( + from homeassistant.components.cloud import ( # noqa: PLC0415 SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState, ) diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index 69094a80d30..c7fe200e66d 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -177,7 +177,7 @@ async def test_climate_myzone_zone( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, blocking=True, ) mock_update.assert_called_once() diff --git a/tests/components/ai_task/__init__.py b/tests/components/ai_task/__init__.py new file mode 100644 index 00000000000..b4ca4688eb4 --- /dev/null +++ b/tests/components/ai_task/__init__.py @@ -0,0 +1 @@ +"""Tests for the AI Task integration.""" diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py new file mode 100644 index 00000000000..2060c51bfa4 --- /dev/null +++ b/tests/components/ai_task/conftest.py @@ -0,0 +1,127 @@ +"""Test helpers for AI Task integration.""" + +import pytest + +from homeassistant.components.ai_task import ( + DOMAIN, + AITaskEntity, + AITaskEntityFeature, + GenTextTask, + GenTextTaskResult, +) +from homeassistant.components.conversation import AssistantContent, ChatLog +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" +TEST_ENTITY_ID = "ai_task.test_task_entity" + + +class MockAITaskEntity(AITaskEntity): + """Mock AI Task entity for testing.""" + + _attr_name = "Test Task Entity" + _attr_supported_features = AITaskEntityFeature.GENERATE_TEXT + + def __init__(self) -> None: + """Initialize the mock entity.""" + super().__init__() + self.mock_generate_text_tasks = [] + + async def _async_generate_text( + self, task: GenTextTask, chat_log: ChatLog + ) -> GenTextTaskResult: + """Mock handling of generate text task.""" + self.mock_generate_text_tasks.append(task) + chat_log.async_add_assistant_content_without_tools( + AssistantContent(self.entity_id, "Mock result") + ) + return GenTextTaskResult( + conversation_id=chat_log.conversation_id, + text="Mock result", + ) + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a configuration entry for AI Task.""" + entry = MockConfigEntry(domain=TEST_DOMAIN, entry_id="mock-test-entry") + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_ai_task_entity( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockAITaskEntity: + """Mock AI Task entity.""" + return MockAITaskEntity() + + +@pytest.fixture +async def init_components( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ai_task_entity: MockAITaskEntity, +): + """Initialize the AI Task integration with a mock entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.AI_TASK] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, Platform.AI_TASK + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test tts platform via config entry.""" + async_add_entities([mock_ai_task_entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, ConfigFlow): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr new file mode 100644 index 00000000000..6d155c82a68 --- /dev/null +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_run_text_task_updates_chat_log + list([ + dict({ + 'content': ''' + You are a Home Assistant expert and help users with their tasks. + Current time is 15:59:00. Today's date is 2025-06-14. + ''', + 'role': 'system', + }), + dict({ + 'content': 'Test prompt', + 'role': 'user', + }), + dict({ + 'agent_id': 'ai_task.test_task_entity', + 'content': 'Mock result', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/ai_task/test_entity.py b/tests/components/ai_task/test_entity.py new file mode 100644 index 00000000000..aa9afbf6560 --- /dev/null +++ b/tests/components/ai_task/test_entity.py @@ -0,0 +1,39 @@ +"""Tests for the AI Task entity model.""" + +from freezegun import freeze_time + +from homeassistant.components.ai_task import async_generate_text +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY_ID, MockAITaskEntity + +from tests.common import MockConfigEntry + + +@freeze_time("2025-06-08 16:28:13") +async def test_state_generate_text( + hass: HomeAssistant, + init_components: None, + mock_config_entry: MockConfigEntry, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test the state of the AI Task entity is updated when generating text.""" + entity = hass.states.get(TEST_ENTITY_ID) + assert entity is not None + assert entity.state == STATE_UNKNOWN + + result = await async_generate_text( + hass, + task_name="Test task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + ) + assert result.text == "Mock result" + + entity = hass.states.get(TEST_ENTITY_ID) + assert entity.state == "2025-06-08T16:28:13+00:00" + + assert mock_ai_task_entity.mock_generate_text_tasks + task = mock_ai_task_entity.mock_generate_text_tasks[0] + assert task.instructions == "Test prompt" diff --git a/tests/components/ai_task/test_http.py b/tests/components/ai_task/test_http.py new file mode 100644 index 00000000000..4436e1d45d5 --- /dev/null +++ b/tests/components/ai_task/test_http.py @@ -0,0 +1,84 @@ +"""Test the HTTP API for AI Task integration.""" + +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + + +async def test_ws_preferences( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components: None, +) -> None: + """Test preferences via the WebSocket API.""" + client = await hass_ws_client(hass) + + # Get initial preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": None, + } + + # Set preferences + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_text_entity_id": "ai_task.summary_1", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_1", + } + + # Get updated preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_1", + } + + # Update an existing preference + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_text_entity_id": "ai_task.summary_2", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_2", + } + + # Get updated preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_2", + } + + # No preferences set will preserve existing preferences + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_2", + } + + # Get updated preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_2", + } diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py new file mode 100644 index 00000000000..2f45d812b1f --- /dev/null +++ b/tests/components/ai_task/test_init.py @@ -0,0 +1,84 @@ +"""Test initialization of the AI Task component.""" + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.ai_task import AITaskPreferences +from homeassistant.components.ai_task.const import DATA_PREFERENCES +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY_ID + +from tests.common import flush_store + + +async def test_preferences_storage_load( + hass: HomeAssistant, +) -> None: + """Test that AITaskPreferences are stored and loaded correctly.""" + preferences = AITaskPreferences(hass) + await preferences.async_load() + + # Initial state should be None for entity IDs + for key in AITaskPreferences.KEYS: + assert getattr(preferences, key) is None, f"Initial {key} should be None" + + new_values = {key: f"ai_task.test_{key}" for key in AITaskPreferences.KEYS} + + preferences.async_set_preferences(**new_values) + + # Verify that current preferences object is updated + for key, value in new_values.items(): + assert getattr(preferences, key) == value, ( + f"Current {key} should match set value" + ) + + await flush_store(preferences._store) + + # Create a new preferences instance to test loading from store + new_preferences_instance = AITaskPreferences(hass) + await new_preferences_instance.async_load() + + for key in AITaskPreferences.KEYS: + assert getattr(preferences, key) == getattr(new_preferences_instance, key), ( + f"Loaded {key} should match saved value" + ) + + +@pytest.mark.parametrize( + ("set_preferences", "msg_extra"), + [ + ( + {"gen_text_entity_id": TEST_ENTITY_ID}, + {}, + ), + ( + {}, + {"entity_id": TEST_ENTITY_ID}, + ), + ], +) +async def test_generate_text_service( + hass: HomeAssistant, + init_components: None, + freezer: FrozenDateTimeFactory, + set_preferences: dict[str, str | None], + msg_extra: dict[str, str], +) -> None: + """Test the generate text service.""" + preferences = hass.data[DATA_PREFERENCES] + preferences.async_set_preferences(**set_preferences) + + result = await hass.services.async_call( + "ai_task", + "generate_text", + { + "task_name": "Test Name", + "instructions": "Test prompt", + } + | msg_extra, + blocking=True, + return_response=True, + ) + + assert result["text"] == "Mock result" diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py new file mode 100644 index 00000000000..d4df66d83f9 --- /dev/null +++ b/tests/components/ai_task/test_task.py @@ -0,0 +1,123 @@ +"""Test tasks for the AI Task integration.""" + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_text +from homeassistant.components.conversation import async_get_chat_log +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session + +from .conftest import TEST_ENTITY_ID, MockAITaskEntity + +from tests.typing import WebSocketGenerator + + +async def test_run_task_preferred_entity( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test running a task with an unknown entity.""" + client = await hass_ws_client(hass) + + with pytest.raises( + ValueError, match="No entity_id provided and no preferred entity set" + ): + await async_generate_text( + hass, + task_name="Test Task", + instructions="Test prompt", + ) + + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_text_entity_id": "ai_task.unknown", + } + ) + msg = await client.receive_json() + assert msg["success"] + + with pytest.raises(ValueError, match="AI Task entity ai_task.unknown not found"): + await async_generate_text( + hass, + task_name="Test Task", + instructions="Test prompt", + ) + + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_text_entity_id": TEST_ENTITY_ID, + } + ) + msg = await client.receive_json() + assert msg["success"] + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + result = await async_generate_text( + hass, + task_name="Test Task", + instructions="Test prompt", + ) + assert result.text == "Mock result" + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + mock_ai_task_entity.supported_features = AITaskEntityFeature(0) + with pytest.raises( + ValueError, + match="AI Task entity ai_task.test_task_entity does not support generating text", + ): + await async_generate_text( + hass, + task_name="Test Task", + instructions="Test prompt", + ) + + +async def test_run_text_task_unknown_entity( + hass: HomeAssistant, + init_components: None, +) -> None: + """Test running a text task with an unknown entity.""" + + with pytest.raises( + ValueError, match="AI Task entity ai_task.unknown_entity not found" + ): + await async_generate_text( + hass, + task_name="Test Task", + entity_id="ai_task.unknown_entity", + instructions="Test prompt", + ) + + +@freeze_time("2025-06-14 22:59:00") +async def test_run_text_task_updates_chat_log( + hass: HomeAssistant, + init_components: None, + snapshot: SnapshotAssertion, +) -> None: + """Test that running a text task updates the chat log.""" + result = await async_generate_text( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + ) + assert result.text == "Mock result" + + with ( + chat_session.async_get_chat_session(hass, result.conversation_id) as session, + async_get_chat_log(hass, session) as chat_log, + ): + assert chat_log.content == snapshot diff --git a/tests/components/amazon_devices/__init__.py b/tests/components/alexa_devices/__init__.py similarity index 88% rename from tests/components/amazon_devices/__init__.py rename to tests/components/alexa_devices/__init__.py index 47ee520b124..24348248e0c 100644 --- a/tests/components/amazon_devices/__init__.py +++ b/tests/components/alexa_devices/__init__.py @@ -1,4 +1,4 @@ -"""Tests for the Amazon Devices integration.""" +"""Tests for the Alexa Devices integration.""" from homeassistant.core import HomeAssistant diff --git a/tests/components/amazon_devices/conftest.py b/tests/components/alexa_devices/conftest.py similarity index 80% rename from tests/components/amazon_devices/conftest.py rename to tests/components/alexa_devices/conftest.py index f0ee29d44e5..f1f40eebd27 100644 --- a/tests/components/amazon_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -1,4 +1,4 @@ -"""Amazon Devices tests configuration.""" +"""Alexa 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.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_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.amazon_devices.async_setup_entry", + "homeassistant.components.alexa_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 Amazon Devices client.""" + """Mock an Alexa Devices client.""" with ( patch( - "homeassistant.components.amazon_devices.coordinator.AmazonEchoApi", + "homeassistant.components.alexa_devices.coordinator.AmazonEchoApi", autospec=True, ) as mock_client, patch( - "homeassistant.components.amazon_devices.config_flow.AmazonEchoApi", + "homeassistant.components.alexa_devices.config_flow.AmazonEchoApi", new=mock_client, ), ): @@ -56,6 +56,9 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: do_not_disturb=False, response_style=None, bluetooth_state=True, + entity_id="11111111-2222-3333-4444-555555555555", + appliance_id="G1234567890123456789012345678A", + sensors={}, ) } client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( diff --git a/tests/components/amazon_devices/const.py b/tests/components/alexa_devices/const.py similarity index 82% rename from tests/components/amazon_devices/const.py rename to tests/components/alexa_devices/const.py index a2600ba98a6..8a2f5b6b158 100644 --- a/tests/components/amazon_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -1,4 +1,4 @@ -"""Amazon Devices tests const.""" +"""Alexa Devices tests const.""" TEST_CODE = "023123" TEST_COUNTRY = "IT" diff --git a/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr similarity index 97% rename from tests/components/amazon_devices/snapshots/test_binary_sensor.ambr rename to tests/components/alexa_devices/snapshots/test_binary_sensor.ambr index e914541d19c..16f9eeaedae 100644 --- a/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr +++ b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr @@ -25,7 +25,7 @@ 'original_device_class': None, 'original_icon': None, 'original_name': 'Bluetooth', - 'platform': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, @@ -73,7 +73,7 @@ 'original_device_class': , 'original_icon': None, 'original_name': 'Connectivity', - 'platform': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, diff --git a/tests/components/amazon_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr similarity index 98% rename from tests/components/amazon_devices/snapshots/test_diagnostics.ambr rename to tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 0b5164418aa..95798fca817 100644 --- a/tests/components/amazon_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -57,7 +57,7 @@ 'disabled_by': None, 'discovery_keys': dict({ }), - 'domain': 'amazon_devices', + 'domain': 'alexa_devices', 'minor_version': 1, 'options': dict({ }), diff --git a/tests/components/amazon_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr similarity index 96% rename from tests/components/amazon_devices/snapshots/test_init.ambr rename to tests/components/alexa_devices/snapshots/test_init.ambr index be0a5894eea..e0460c4c173 100644 --- a/tests/components/amazon_devices/snapshots/test_init.ambr +++ b/tests/components/alexa_devices/snapshots/test_init.ambr @@ -13,7 +13,7 @@ 'id': , 'identifiers': set({ tuple( - 'amazon_devices', + 'alexa_devices', 'echo_test_serial_number', ), }), diff --git a/tests/components/amazon_devices/snapshots/test_notify.ambr b/tests/components/alexa_devices/snapshots/test_notify.ambr similarity index 97% rename from tests/components/amazon_devices/snapshots/test_notify.ambr rename to tests/components/alexa_devices/snapshots/test_notify.ambr index a47bf7a63ae..64776c14420 100644 --- a/tests/components/amazon_devices/snapshots/test_notify.ambr +++ b/tests/components/alexa_devices/snapshots/test_notify.ambr @@ -25,7 +25,7 @@ 'original_device_class': None, 'original_icon': None, 'original_name': 'Announce', - 'platform': 'amazon_devices', + 'platform': 'alexa_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': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, diff --git a/tests/components/amazon_devices/snapshots/test_switch.ambr b/tests/components/alexa_devices/snapshots/test_switch.ambr similarity index 97% rename from tests/components/amazon_devices/snapshots/test_switch.ambr rename to tests/components/alexa_devices/snapshots/test_switch.ambr index 8a2ce8d529a..c622cc67ea7 100644 --- a/tests/components/amazon_devices/snapshots/test_switch.ambr +++ b/tests/components/alexa_devices/snapshots/test_switch.ambr @@ -25,7 +25,7 @@ 'original_device_class': None, 'original_icon': None, 'original_name': 'Do not disturb', - 'platform': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, diff --git a/tests/components/amazon_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py similarity index 92% rename from tests/components/amazon_devices/test_binary_sensor.py rename to tests/components/alexa_devices/test_binary_sensor.py index b31d85e06aa..a2e38b3459b 100644 --- a/tests/components/amazon_devices/test_binary_sensor.py +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -1,4 +1,4 @@ -"""Tests for the Amazon Devices binary sensor platform.""" +"""Tests for the Alexa 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.amazon_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.alexa_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.amazon_devices.PLATFORMS", [Platform.BINARY_SENSOR] + "homeassistant.components.alexa_devices.PLATFORMS", [Platform.BINARY_SENSOR] ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/amazon_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py similarity index 96% rename from tests/components/amazon_devices/test_config_flow.py rename to tests/components/alexa_devices/test_config_flow.py index ce1ac44d102..9bf174c5955 100644 --- a/tests/components/amazon_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -1,11 +1,11 @@ -"""Tests for the Amazon Devices config flow.""" +"""Tests for the Alexa Devices config flow.""" from unittest.mock import AsyncMock from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect import pytest -from homeassistant.components.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_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 diff --git a/tests/components/amazon_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py similarity index 93% rename from tests/components/amazon_devices/test_diagnostics.py rename to tests/components/alexa_devices/test_diagnostics.py index e548702650b..3c18d432543 100644 --- a/tests/components/amazon_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -1,4 +1,4 @@ -"""Tests for Amazon Devices diagnostics platform.""" +"""Tests for Alexa 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.amazon_devices.const import DOMAIN +from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/tests/components/amazon_devices/test_init.py b/tests/components/alexa_devices/test_init.py similarity index 87% rename from tests/components/amazon_devices/test_init.py rename to tests/components/alexa_devices/test_init.py index 489952dbd4c..3100cfe5fa9 100644 --- a/tests/components/amazon_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -1,10 +1,10 @@ -"""Tests for the Amazon Devices integration.""" +"""Tests for the Alexa Devices integration.""" from unittest.mock import AsyncMock from syrupy.assertion import SnapshotAssertion -from homeassistant.components.amazon_devices.const import DOMAIN +from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/tests/components/amazon_devices/test_notify.py b/tests/components/alexa_devices/test_notify.py similarity index 92% rename from tests/components/amazon_devices/test_notify.py rename to tests/components/alexa_devices/test_notify.py index b486380fd07..6067874e370 100644 --- a/tests/components/amazon_devices/test_notify.py +++ b/tests/components/alexa_devices/test_notify.py @@ -1,4 +1,4 @@ -"""Tests for the Amazon Devices notify platform.""" +"""Tests for the Alexa 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.amazon_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.alexa_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.amazon_devices.PLATFORMS", [Platform.NOTIFY]): + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.NOTIFY]): await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/amazon_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py similarity index 94% rename from tests/components/amazon_devices/test_switch.py rename to tests/components/alexa_devices/test_switch.py index 24af96db280..26a18fb731a 100644 --- a/tests/components/amazon_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -1,4 +1,4 @@ -"""Tests for the Amazon Devices switch platform.""" +"""Tests for the Alexa 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.amazon_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.alexa_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.amazon_devices.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.SWITCH]): await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index 500b9e75cb3..27171d4366a 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -1,5 +1,6 @@ """Define patches used for androidtv tests.""" +import os.path from typing import Any from unittest.mock import patch @@ -12,6 +13,8 @@ from homeassistant.components.androidtv.const import ( DEVICE_FIRETV, ) +_original_isfile = os.path.isfile + ADB_SERVER_HOST = "127.0.0.1" KEY_PYTHON = "python" KEY_SERVER = "server" @@ -185,7 +188,9 @@ def patch_androidtv_update( def isfile(filepath): """Mock `os.path.isfile`.""" - return filepath.endswith("adbkey") + if str(filepath).endswith("adbkey"): + return True + return _original_isfile(filepath) PATCH_SCREENCAP = patch( diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 3e01e91976d..6aadcf3eeb4 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -415,7 +415,6 @@ async def test_function_call( llm.LLMContext( platform="anthropic", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, @@ -510,7 +509,6 @@ async def test_function_exception( llm.LLMContext( platform="anthropic", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, diff --git a/tests/components/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr index 9c0b2de4fdc..2e991d7cfa6 100644 --- a/tests/components/apcupsd/snapshots/test_sensor.ambr +++ b/tests/components/apcupsd/snapshots/test_sensor.ambr @@ -11,7 +11,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_alarm_delay', 'has_entity_name': True, 'hidden_by': None, @@ -113,7 +113,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_nominal_voltage', 'has_entity_name': True, 'hidden_by': None, @@ -214,7 +214,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_shutdown', 'has_entity_name': True, 'hidden_by': None, @@ -263,7 +263,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_timeout', 'has_entity_name': True, 'hidden_by': None, @@ -368,7 +368,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_cable_type', 'has_entity_name': True, 'hidden_by': None, @@ -416,7 +416,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_daemon_version', 'has_entity_name': True, 'hidden_by': None, @@ -464,7 +464,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_date_and_time', 'has_entity_name': True, 'hidden_by': None, @@ -512,7 +512,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_driver', 'has_entity_name': True, 'hidden_by': None, @@ -560,7 +560,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_firmware_version', 'has_entity_name': True, 'hidden_by': None, @@ -768,7 +768,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_last_transfer', 'has_entity_name': True, 'hidden_by': None, @@ -916,7 +916,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_model', 'has_entity_name': True, 'hidden_by': None, @@ -964,7 +964,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_name', 'has_entity_name': True, 'hidden_by': None, @@ -1012,7 +1012,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_apparent_power', 'has_entity_name': True, 'hidden_by': None, @@ -1065,7 +1065,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_input_voltage', 'has_entity_name': True, 'hidden_by': None, @@ -1118,7 +1118,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_output_power', 'has_entity_name': True, 'hidden_by': None, @@ -1227,7 +1227,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_self_test_interval', 'has_entity_name': True, 'hidden_by': None, @@ -1324,7 +1324,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_sensitivity', 'has_entity_name': True, 'hidden_by': None, @@ -1372,7 +1372,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_serial_number', 'has_entity_name': True, 'hidden_by': None, @@ -1420,7 +1420,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_shutdown_time', 'has_entity_name': True, 'hidden_by': None, @@ -1517,7 +1517,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_data', 'has_entity_name': True, 'hidden_by': None, @@ -1565,7 +1565,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_date', 'has_entity_name': True, 'hidden_by': None, @@ -1613,7 +1613,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_flag', 'has_entity_name': True, 'hidden_by': None, @@ -1880,7 +1880,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_from_battery', 'has_entity_name': True, 'hidden_by': None, @@ -1928,7 +1928,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_high', 'has_entity_name': True, 'hidden_by': None, @@ -1981,7 +1981,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_low', 'has_entity_name': True, 'hidden_by': None, @@ -2034,7 +2034,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_to_battery', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index abdcb55054c..9ea3802d9f6 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1779,11 +1779,11 @@ async def test_chat_log_tts_streaming( conversation_input, ) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=conversation_input, + await chat_log.async_provide_llm_data( + conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, + user_extra_system_prompt=conversation_input.extra_system_prompt, ) async for _content in chat_log.async_add_delta_content_stream( agent_id, stream_llm_response() diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 8050b23f5ff..3473b23bedd 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Generator +from dataclasses import asdict from unittest.mock import Mock, patch import pytest @@ -20,6 +21,7 @@ from homeassistant.components.assist_pipeline import ( ) from homeassistant.components.assist_satellite import ( AssistSatelliteAnnouncement, + AssistSatelliteAnswer, SatelliteBusyError, ) from homeassistant.components.assist_satellite.const import PREANNOUNCE_URL @@ -708,6 +710,127 @@ async def test_start_conversation_default_preannounce( ) +@pytest.mark.parametrize( + ("service_data", "response_text", "expected_answer"), + [ + ( + {"preannounce": False}, + "jazz", + AssistSatelliteAnswer(id=None, sentence="jazz"), + ), + ( + { + "answers": [ + {"id": "jazz", "sentences": ["[some] jazz [please]"]}, + {"id": "rock", "sentences": ["[some] rock [please]"]}, + ], + "preannounce": False, + }, + "Some Rock, please.", + AssistSatelliteAnswer(id="rock", sentence="Some Rock, please."), + ), + ( + { + "answers": [ + { + "id": "genre", + "sentences": ["genre {genre} [please]"], + }, + { + "id": "artist", + "sentences": ["artist {artist} [please]"], + }, + ], + "preannounce": False, + }, + "artist Pink Floyd", + AssistSatelliteAnswer( + id="artist", + sentence="artist Pink Floyd", + slots={"artist": "Pink Floyd"}, + ), + ), + ], +) +async def test_ask_question( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + response_text: str, + expected_answer: AssistSatelliteAnswer, +) -> None: + """Test asking a question on a device and matching an answer.""" + entity_id = "assist_satellite.test_entity" + question_text = "What kind of music would you like to listen to?" + + await async_update_pipeline( + hass, async_get_pipeline(hass), stt_engine="test-stt-engine", stt_language="en" + ) + + async def speech_to_text(self, *args, **kwargs): + self.process_event( + PipelineEvent( + PipelineEventType.STT_END, {"stt_output": {"text": response_text}} + ) + ) + + return response_text + + original_start_conversation = entity.async_start_conversation + + async def async_start_conversation(start_announcement): + # Verify state change + assert entity.state == AssistSatelliteState.RESPONDING + await original_start_conversation(start_announcement) + + audio_stream = object() + with ( + patch( + "homeassistant.components.assist_pipeline.pipeline.PipelineRun.prepare_speech_to_text" + ), + patch( + "homeassistant.components.assist_pipeline.pipeline.PipelineRun.speech_to_text", + speech_to_text, + ), + ): + await entity.async_accept_pipeline_from_satellite( + audio_stream, start_stage=PipelineStage.STT + ) + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + patch.object(entity, "async_start_conversation", new=async_start_conversation), + ): + response = await hass.services.async_call( + "assist_satellite", + "ask_question", + {"entity_id": entity_id, "question": question_text, **service_data}, + blocking=True, + return_response=True, + ) + assert entity.state == AssistSatelliteState.IDLE + assert response == asdict(expected_answer) + + async def test_wake_word_start_keeps_responding( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index 14b70811cde..83c3204d239 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -175,7 +175,12 @@ async def test_error_invalid_ssh(hass: HomeAssistant, patch_is_file) -> None: config_data = {k: v for k, v in CONFIG_DATA_SSH.items() if k != CONF_PASSWORD} config_data[CONF_SSH_KEY] = SSH_KEY - patch_is_file.return_value = False + def mock_is_file(file) -> bool: + if str(file).endswith(SSH_KEY): + return False + return True + + patch_is_file.side_effect = mock_is_file result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index 8fffdba7cc2..b2dac6a6f8f 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -166,8 +166,7 @@ def mock_backup_generation_fixture( @pytest.fixture def mock_backups() -> Generator[None]: """Fixture to setup test backups.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.backup import backup as core_backup + from homeassistant.components.backup import backup as core_backup # noqa: PLC0415 class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): def __init__(self, hass: HomeAssistant) -> None: diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 5cd5bc9091a..4ccbe91fbcd 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -127,9 +127,6 @@ async def test_spa_hvac_action( state = await _patch_spa_heatstate(hass, client, 1) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - state = await _patch_spa_heatstate(hass, client, 2) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - async def test_spa_preset_modes( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 7c56d142920..09aae385a89 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1,5 +1,6 @@ """The tests for the camera component.""" +from collections.abc import Callable from http import HTTPStatus import io from types import ModuleType @@ -40,7 +41,6 @@ from homeassistant.util import dt as dt_util from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( - MockEntityPlatform, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, @@ -833,30 +833,6 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockCamera(camera.Camera): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockCamera() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert entity.supported_features_compat is camera.CameraEntityFeature(1) - assert "MockCamera" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "CameraEntityFeature.ON_OFF" in caplog.text - caplog.clear() - assert entity.supported_features_compat is camera.CameraEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - @pytest.mark.usefixtures("mock_camera") async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None: """Test the token is rotated and entity entity picture cache is cleared.""" @@ -876,6 +852,41 @@ async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) - assert "token=" in new_entity_picture +async def _register_test_webrtc_provider(hass: HomeAssistant) -> Callable[[], None]: + class SomeTestProvider(CameraWebRTCProvider): + """Test provider.""" + + @property + def domain(self) -> str: + """Return domain.""" + return "test" + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Determine if the provider supports the stream source.""" + return True + + async def async_handle_async_webrtc_offer( + self, + camera: Camera, + offer_sdp: str, + session_id: str, + send_message: WebRTCSendMessage, + ) -> None: + """Handle the WebRTC offer and return the answer via the provided callback.""" + send_message(WebRTCAnswer("answer")) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidateInit + ) -> None: + """Handle the WebRTC candidate.""" + + provider = SomeTestProvider() + unsub = async_register_webrtc_provider(hass, provider) + await hass.async_block_till_done() + return unsub + + async def _test_capabilities( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -908,38 +919,7 @@ async def _test_capabilities( await test(expected_stream_types) # Test with WebRTC provider - - class SomeTestProvider(CameraWebRTCProvider): - """Test provider.""" - - @property - def domain(self) -> str: - """Return domain.""" - return "test" - - @callback - def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - return True - - async def async_handle_async_webrtc_offer( - self, - camera: Camera, - offer_sdp: str, - session_id: str, - send_message: WebRTCSendMessage, - ) -> None: - """Handle the WebRTC offer and return the answer via the provided callback.""" - send_message(WebRTCAnswer("answer")) - - async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidateInit - ) -> None: - """Handle the WebRTC candidate.""" - - provider = SomeTestProvider() - async_register_webrtc_provider(hass, provider) - await hass.async_block_till_done() + await _register_test_webrtc_provider(hass) await test(expected_stream_types_with_webrtc_provider) @@ -1026,3 +1006,82 @@ async def test_camera_capabilities_changing_native_support( await hass.async_block_till_done() await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_snapshot_service_webrtc_provider( + hass: HomeAssistant, +) -> None: + """Test snapshot service with the webrtc provider.""" + await async_setup_component(hass, "camera", {}) + await hass.async_block_till_done() + unsub = await _register_test_webrtc_provider(hass) + camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") + assert camera_obj._webrtc_provider + + with ( + patch.object(camera_obj, "use_stream_for_stills", return_value=True), + patch("homeassistant.components.camera.open"), + patch.object( + camera_obj._webrtc_provider, + "async_get_image", + wraps=camera_obj._webrtc_provider.async_get_image, + ) as webrtc_get_image_mock, + patch.object(camera_obj, "stream", AsyncMock()) as stream_mock, + patch( + "homeassistant.components.camera.os.makedirs", + ), + patch.object(hass.config, "is_allowed_path", return_value=True), + ): + # WebRTC is not supporting get_image and the default implementation returns None + await hass.services.async_call( + camera.DOMAIN, + camera.SERVICE_SNAPSHOT, + { + ATTR_ENTITY_ID: camera_obj.entity_id, + camera.ATTR_FILENAME: "/test/snapshot.jpg", + }, + blocking=True, + ) + stream_mock.async_get_image.assert_called_once() + webrtc_get_image_mock.assert_called_once_with( + camera_obj, width=None, height=None + ) + + webrtc_get_image_mock.reset_mock() + stream_mock.reset_mock() + + # Now provider supports get_image + webrtc_get_image_mock.return_value = b"Images bytes" + await hass.services.async_call( + camera.DOMAIN, + camera.SERVICE_SNAPSHOT, + { + ATTR_ENTITY_ID: camera_obj.entity_id, + camera.ATTR_FILENAME: "/test/snapshot.jpg", + }, + blocking=True, + ) + stream_mock.async_get_image.assert_not_called() + webrtc_get_image_mock.assert_called_once_with( + camera_obj, width=None, height=None + ) + + # Deregister provider + unsub() + await hass.async_block_till_done() + assert camera_obj._webrtc_provider is None + webrtc_get_image_mock.reset_mock() + stream_mock.reset_mock() + + await hass.services.async_call( + camera.DOMAIN, + camera.SERVICE_SNAPSHOT, + { + ATTR_ENTITY_ID: camera_obj.entity_id, + camera.ATTR_FILENAME: "/test/snapshot.jpg", + }, + blocking=True, + ) + stream_mock.async_get_image.assert_called_once() + webrtc_get_image_mock.assert_not_called() diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index a81efa1640c..06bd9c0c096 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -323,22 +323,23 @@ async def test_mode_validation( assert state.attributes.get(ATTR_SWING_MODE) == "off" assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "off" - await hass.services.async_call( - DOMAIN, - SERVICE_SET_HVAC_MODE, - { - "entity_id": "climate.test", - "hvac_mode": "auto", - }, - blocking=True, - ) - + with pytest.raises( + ServiceValidationError, + match="HVAC mode auto is not valid. Valid HVAC modes are: off, heat", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + { + "entity_id": "climate.test", + "hvac_mode": "auto", + }, + blocking=True, + ) assert ( - "MockClimateEntity sets the hvac_mode auto which is not valid " - "for this entity with modes: off, heat. This will stop working " - "in 2025.4 and raise an error instead. " - "Please" in caplog.text + str(exc.value) == "HVAC mode auto is not valid. Valid HVAC modes are: off, heat" ) + assert exc.value.translation_key == "not_valid_hvac_mode" with pytest.raises( ServiceValidationError, diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index a0c69765c9a..30523e8c740 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -126,7 +126,8 @@ 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, with message: test message" in caplog.text + assert "Running command: echo" in caplog.text + assert "Running with message: test message" in caplog.text async def test_command_template(hass: HomeAssistant) -> None: diff --git a/tests/components/conftest.py b/tests/components/conftest.py index e0db306cae9..48198757c25 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -98,8 +98,9 @@ def entity_registry_enabled_by_default() -> Generator[None]: @pytest.fixture(name="stub_blueprint_populate") def stub_blueprint_populate_fixture() -> Generator[None]: """Stub copying the blueprints to the config folder.""" - # pylint: disable-next=import-outside-toplevel - from .blueprint.common import stub_blueprint_populate_fixture_helper + from .blueprint.common import ( # noqa: PLC0415 + stub_blueprint_populate_fixture_helper, + ) yield from stub_blueprint_populate_fixture_helper() @@ -108,8 +109,7 @@ def stub_blueprint_populate_fixture() -> Generator[None]: @pytest.fixture(name="mock_tts_get_cache_files") def mock_tts_get_cache_files_fixture() -> Generator[MagicMock]: """Mock the list TTS cache function.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import mock_tts_get_cache_files_fixture_helper + from .tts.common import mock_tts_get_cache_files_fixture_helper # noqa: PLC0415 yield from mock_tts_get_cache_files_fixture_helper() @@ -119,8 +119,7 @@ def mock_tts_init_cache_dir_fixture( init_tts_cache_dir_side_effect: Any, ) -> Generator[MagicMock]: """Mock the TTS cache dir in memory.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import mock_tts_init_cache_dir_fixture_helper + from .tts.common import mock_tts_init_cache_dir_fixture_helper # noqa: PLC0415 yield from mock_tts_init_cache_dir_fixture_helper(init_tts_cache_dir_side_effect) @@ -128,8 +127,9 @@ def mock_tts_init_cache_dir_fixture( @pytest.fixture(name="init_tts_cache_dir_side_effect") def init_tts_cache_dir_side_effect_fixture() -> Any: """Return the cache dir.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import init_tts_cache_dir_side_effect_fixture_helper + from .tts.common import ( # noqa: PLC0415 + init_tts_cache_dir_side_effect_fixture_helper, + ) return init_tts_cache_dir_side_effect_fixture_helper() @@ -142,8 +142,7 @@ def mock_tts_cache_dir_fixture( request: pytest.FixtureRequest, ) -> Generator[Path]: """Mock the TTS cache dir with empty dir.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import mock_tts_cache_dir_fixture_helper + from .tts.common import mock_tts_cache_dir_fixture_helper # noqa: PLC0415 yield from mock_tts_cache_dir_fixture_helper( tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request @@ -153,8 +152,7 @@ def mock_tts_cache_dir_fixture( @pytest.fixture(name="tts_mutagen_mock") def tts_mutagen_mock_fixture() -> Generator[MagicMock]: """Mock writing tags.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import tts_mutagen_mock_fixture_helper + from .tts.common import tts_mutagen_mock_fixture_helper # noqa: PLC0415 yield from tts_mutagen_mock_fixture_helper() @@ -162,8 +160,9 @@ def tts_mutagen_mock_fixture() -> Generator[MagicMock]: @pytest.fixture(name="mock_conversation_agent") def mock_conversation_agent_fixture(hass: HomeAssistant) -> MockAgent: """Mock a conversation agent.""" - # pylint: disable-next=import-outside-toplevel - from .conversation.common import mock_conversation_agent_fixture_helper + from .conversation.common import ( # noqa: PLC0415 + mock_conversation_agent_fixture_helper, + ) return mock_conversation_agent_fixture_helper(hass) @@ -180,8 +179,7 @@ def prevent_ffmpeg_subprocess() -> Generator[None]: @pytest.fixture def mock_light_entities() -> list[MockLight]: """Return mocked light entities.""" - # pylint: disable-next=import-outside-toplevel - from .light.common import MockLight + from .light.common import MockLight # noqa: PLC0415 return [ MockLight("Ceiling", STATE_ON), @@ -193,8 +191,7 @@ def mock_light_entities() -> list[MockLight]: @pytest.fixture def mock_sensor_entities() -> dict[str, MockSensor]: """Return mocked sensor entities.""" - # pylint: disable-next=import-outside-toplevel - from .sensor.common import get_mock_sensor_entities + from .sensor.common import get_mock_sensor_entities # noqa: PLC0415 return get_mock_sensor_entities() @@ -202,8 +199,7 @@ def mock_sensor_entities() -> dict[str, MockSensor]: @pytest.fixture def mock_switch_entities() -> list[MockSwitch]: """Return mocked toggle entities.""" - # pylint: disable-next=import-outside-toplevel - from .switch.common import get_mock_switch_entities + from .switch.common import get_mock_switch_entities # noqa: PLC0415 return get_mock_switch_entities() @@ -211,8 +207,7 @@ def mock_switch_entities() -> list[MockSwitch]: @pytest.fixture def mock_legacy_device_scanner() -> MockScanner: """Return mocked legacy device scanner entity.""" - # pylint: disable-next=import-outside-toplevel - from .device_tracker.common import MockScanner + from .device_tracker.common import MockScanner # noqa: PLC0415 return MockScanner() @@ -220,8 +215,7 @@ def mock_legacy_device_scanner() -> MockScanner: @pytest.fixture def mock_legacy_device_tracker_setup() -> Callable[[HomeAssistant, MockScanner], None]: """Return setup callable for legacy device tracker setup.""" - # pylint: disable-next=import-outside-toplevel - from .device_tracker.common import mock_legacy_device_tracker_setup + from .device_tracker.common import mock_legacy_device_tracker_setup # noqa: PLC0415 return mock_legacy_device_tracker_setup @@ -231,8 +225,7 @@ def addon_manager_fixture( hass: HomeAssistant, supervisor_client: AsyncMock ) -> AddonManager: """Return an AddonManager instance.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_manager + from .hassio.common import mock_addon_manager # noqa: PLC0415 return mock_addon_manager(hass) @@ -288,8 +281,7 @@ def addon_store_info_fixture( addon_store_info_side_effect: Any | None, ) -> AsyncMock: """Mock Supervisor add-on store info.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_store_info + from .hassio.common import mock_addon_store_info # noqa: PLC0415 return mock_addon_store_info(supervisor_client, addon_store_info_side_effect) @@ -305,8 +297,7 @@ def addon_info_fixture( supervisor_client: AsyncMock, addon_info_side_effect: Any | None ) -> AsyncMock: """Mock Supervisor add-on info.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_info + from .hassio.common import mock_addon_info # noqa: PLC0415 return mock_addon_info(supervisor_client, addon_info_side_effect) @@ -316,8 +307,7 @@ def addon_not_installed_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock ) -> AsyncMock: """Mock add-on not installed.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_not_installed + from .hassio.common import mock_addon_not_installed # noqa: PLC0415 return mock_addon_not_installed(addon_store_info, addon_info) @@ -327,8 +317,7 @@ def addon_installed_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock ) -> AsyncMock: """Mock add-on already installed but not running.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_installed + from .hassio.common import mock_addon_installed # noqa: PLC0415 return mock_addon_installed(addon_store_info, addon_info) @@ -338,8 +327,7 @@ def addon_running_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock ) -> AsyncMock: """Mock add-on already running.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_running + from .hassio.common import mock_addon_running # noqa: PLC0415 return mock_addon_running(addon_store_info, addon_info) @@ -350,8 +338,7 @@ def install_addon_side_effect_fixture( ) -> Any | None: """Return the install add-on side effect.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_install_addon_side_effect + from .hassio.common import mock_install_addon_side_effect # noqa: PLC0415 return mock_install_addon_side_effect(addon_store_info, addon_info) @@ -371,8 +358,7 @@ def start_addon_side_effect_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock ) -> Any | None: """Return the start add-on options side effect.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_start_addon_side_effect + from .hassio.common import mock_start_addon_side_effect # noqa: PLC0415 return mock_start_addon_side_effect(addon_store_info, addon_info) @@ -419,8 +405,7 @@ def set_addon_options_side_effect_fixture( addon_options: dict[str, Any], ) -> Any | None: """Return the set add-on options side effect.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_set_addon_options_side_effect + from .hassio.common import mock_set_addon_options_side_effect # noqa: PLC0415 return mock_set_addon_options_side_effect(addon_options) @@ -446,8 +431,7 @@ def uninstall_addon_fixture(supervisor_client: AsyncMock) -> AsyncMock: @pytest.fixture(name="create_backup") def create_backup_fixture() -> Generator[AsyncMock]: """Mock create backup.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_create_backup + from .hassio.common import mock_create_backup # noqa: PLC0415 yield from mock_create_backup() @@ -486,8 +470,7 @@ def store_info_fixture( @pytest.fixture(name="addon_stats") def addon_stats_fixture(supervisor_client: AsyncMock) -> AsyncMock: """Mock addon stats info.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_stats + from .hassio.common import mock_addon_stats # noqa: PLC0415 return mock_addon_stats(supervisor_client) diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index c9e72ae5a03..0e2a384f1da 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -106,9 +106,8 @@ async def test_llm_api( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -128,9 +127,8 @@ async def test_unknown_llm_api( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, pytest.raises(ConverseError) as exc_info, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="unknown-api", user_llm_prompt=None, ) @@ -170,9 +168,8 @@ async def test_multiple_llm_apis( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=["assist", "my-api"], user_llm_prompt=None, ) @@ -192,9 +189,8 @@ async def test_template_error( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, pytest.raises(ConverseError) as exc_info, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt="{{ invalid_syntax", ) @@ -217,9 +213,8 @@ async def test_template_variables( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=( "The instance name is {{ ha_name }}. " @@ -249,11 +244,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) chat_log.async_add_assistant_content_without_tools( AssistantContent( @@ -273,11 +268,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) assert chat_log.extra_system_prompt == extra_system_prompt @@ -290,11 +285,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) chat_log.async_add_assistant_content_without_tools( AssistantContent( @@ -314,11 +309,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) assert chat_log.extra_system_prompt == extra_system_prompt2 @@ -357,9 +352,8 @@ async def test_tool_call( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -434,9 +428,8 @@ async def test_tool_call_exception( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): mock_get_tools.return_value = [mock_tool] - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -595,9 +588,8 @@ async def test_add_delta_content_stream( ) as chat_log, ): mock_get_tools.return_value = [mock_tool] - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index f1997066638..e43b64b16a7 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -2,8 +2,6 @@ from enum import Enum -import pytest - from homeassistant.components import cover from homeassistant.components.cover import CoverState from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TOGGLE @@ -13,11 +11,7 @@ from homeassistant.setup import async_setup_component from .common import MockCover -from tests.common import ( - MockEntityPlatform, - help_test_all, - setup_test_component_platform, -) +from tests.common import help_test_all, setup_test_component_platform async def test_services( @@ -159,24 +153,3 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s def test_all() -> None: """Test module.__all__ is correctly set.""" help_test_all(cover) - - -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockCoverEntity(cover.CoverEntity): - _attr_supported_features = 1 - - entity = MockCoverEntity() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert entity.supported_features is cover.CoverEntityFeature(1) - assert "MockCoverEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "CoverEntityFeature.OPEN" in caplog.text - caplog.clear() - assert entity.supported_features is cover.CoverEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 723ff12ad37..9f6ee5afec1 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -136,7 +136,7 @@ async def test_simple_climate_device( # Service set HVAC mode to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -239,7 +239,7 @@ async def test_climate_device_without_cooling_support( # Service set HVAC mode to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index fd28ce2fdf6..b2a58ef5038 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -19,7 +18,6 @@ from .mocks import ( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_binary_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -58,7 +56,6 @@ async def test_binary_sensor( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_remote_control( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -99,7 +96,6 @@ async def test_remote_control( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_disabled(hass: HomeAssistant) -> None: """Test setup of a disabled device.""" entry = configure_integration(hass) @@ -113,7 +109,6 @@ async def test_disabled(hass: HomeAssistant) -> None: assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door") is None -@pytest.mark.usefixtures("mock_zeroconf") async def test_remove_from_hass(hass: HomeAssistant) -> None: """Test removing entity.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py index aab3e69b38f..9367d746d2e 100644 --- a/tests/components/devolo_home_control/test_config_flow.py +++ b/tests/components/devolo_home_control/test_config_flow.py @@ -66,44 +66,6 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_advanced_options(hass: HomeAssistant) -> None: - """Test if we get the advanced options if user has enabled it.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.devolo_home_control.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "homeassistant.components.devolo_home_control.Mydevolo.uuid", - return_value="123456", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "devolo Home Control" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - } - - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_form_zeroconf(hass: HomeAssistant) -> None: """Test that the zeroconf confirmation form is served.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/devolo_home_control/test_diagnostics.py b/tests/components/devolo_home_control/test_diagnostics.py index dfadc4d1c4b..558ed6394fa 100644 --- a/tests/components/devolo_home_control/test_diagnostics.py +++ b/tests/components/devolo_home_control/test_diagnostics.py @@ -1,7 +1,5 @@ """Tests for the devolo Home Control diagnostics.""" -from __future__ import annotations - from unittest.mock import patch from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index da007303688..fb97447264d 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -19,7 +19,6 @@ from .mocks import HomeControlMock, HomeControlMockBinarySensor from tests.typing import WebSocketGenerator -@pytest.mark.usefixtures("mock_zeroconf") async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup entry.""" entry = configure_integration(hass) @@ -44,7 +43,6 @@ async def test_setup_entry_maintenance(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -@pytest.mark.usefixtures("mock_zeroconf") async def test_setup_gateway_offline(hass: HomeAssistant) -> None: """Test setup entry fails on gateway offline.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_control/test_siren.py b/tests/components/devolo_home_control/test_siren.py index 71f4dfdd34d..7c943e05cef 100644 --- a/tests/components/devolo_home_control/test_siren.py +++ b/tests/components/devolo_home_control/test_siren.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN @@ -14,7 +13,6 @@ from . import configure_integration from .mocks import HomeControlMock, HomeControlMockSiren -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -45,7 +43,6 @@ async def test_siren( assert hass.states.get(f"{SIREN_DOMAIN}.test").state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren_switching( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -98,7 +95,6 @@ async def test_siren_switching( property_set.assert_called_once_with(0) -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren_change_default_tone( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -130,7 +126,6 @@ async def test_siren_change_default_tone( property_set.assert_called_once_with(2) -@pytest.mark.usefixtures("mock_zeroconf") async def test_remove_from_hass(hass: HomeAssistant) -> None: """Test removing entity.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr index 950aff87752..9011439c42b 100644 --- a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr +++ b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr @@ -3,12 +3,13 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'band': '5 GHz', + 'friendly_name': 'AA:BB:CC:DD:EE:FF', 'mac': 'AA:BB:CC:DD:EE:FF', 'source_type': , 'wifi': 'Main', }), 'context': , - 'entity_id': 'device_tracker.devolo_home_network_1234567890_aa_bb_cc_dd_ee_ff', + 'entity_id': 'device_tracker.aa_bb_cc_dd_ee_ff', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 2af6a1e3759..cb92b8bc3d9 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -17,13 +17,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import configure_integration -from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS +from .const import CONNECTED_STATIONS, NO_CONNECTED_STATIONS from .mock import MockDevice from tests.common import async_fire_time_changed STATION = CONNECTED_STATIONS[0] -SERIAL = DISCOVERY_INFO.properties["SN"] @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -35,9 +34,7 @@ async def test_device_tracker( snapshot: SnapshotAssertion, ) -> None: """Test device tracker states.""" - state_key = ( - f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" - ) + state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -77,14 +74,12 @@ async def test_restoring_clients( entity_registry: er.EntityRegistry, ) -> None: """Test restoring existing device_tracker entities.""" - state_key = ( - f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" - ) + state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" entry = configure_integration(hass) entity_registry.async_get_or_create( PLATFORM, DOMAIN, - f"{SERIAL}_{STATION.mac_address}", + f"{STATION.mac_address}", config_entry=entry, ) diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 5576066f781..e9a03f9fb31 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -275,7 +275,7 @@ async def test_resolve_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> requested_count=1, ) for parent_id, title in zip( - ["0"] + object_ids[:-1], path.split("/"), strict=False + ["0", *object_ids[:-1]], path.split("/"), strict=False ) ] assert result.url == res_abs_url @@ -293,7 +293,7 @@ async def test_resolve_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> requested_count=1, ) for parent_id, title in zip( - ["0"] + object_ids[:-1], path.split("/"), strict=False + ["0", *object_ids[:-1]], path.split("/"), strict=False ) ] assert result.url == res_abs_url @@ -351,7 +351,7 @@ async def test_resolve_path_browsed(hass: HomeAssistant, dms_device_mock: Mock) requested_count=1, ) for parent_id, title in zip( - ["0"] + object_ids[:-1], path.split("/"), strict=False + ["0", *object_ids[:-1]], path.split("/"), strict=False ) ] assert result.didl_metadata.id == object_ids[-1] diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index ec6091307b9..3acdc1f2029 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -240,6 +240,17 @@ async def test_pipeline_api_audio( ) assert satellite.state == AssistSatelliteState.PROCESSING + event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"tts_start_streaming": "1"}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS, + {"tts_start_streaming": "1"}, + ) + event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, diff --git a/tests/components/feedreader/__init__.py b/tests/components/feedreader/__init__.py index cb017ed944d..9973741a8c3 100644 --- a/tests/components/feedreader/__init__.py +++ b/tests/components/feedreader/__init__.py @@ -7,13 +7,7 @@ from homeassistant.components.feedreader.const import CONF_MAX_ENTRIES, DOMAIN from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture - - -def load_fixture_bytes(src: str) -> bytes: - """Return byte stream of fixture.""" - feed_data = load_fixture(src, DOMAIN) - return bytes(feed_data, "utf-8") +from tests.common import MockConfigEntry def create_mock_entry( diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py index 1e7d50c3835..296d345cca7 100644 --- a/tests/components/feedreader/conftest.py +++ b/tests/components/feedreader/conftest.py @@ -2,78 +2,77 @@ import pytest +from homeassistant.components.feedreader.const import DOMAIN from homeassistant.components.feedreader.coordinator import EVENT_FEEDREADER from homeassistant.core import Event, HomeAssistant -from . import load_fixture_bytes - -from tests.common import async_capture_events +from tests.common import async_capture_events, load_fixture_bytes @pytest.fixture(name="feed_one_event") def fixture_feed_one_event(hass: HomeAssistant) -> bytes: """Load test feed data for one event.""" - return load_fixture_bytes("feedreader.xml") + return load_fixture_bytes("feedreader.xml", DOMAIN) @pytest.fixture(name="feed_two_event") def fixture_feed_two_events(hass: HomeAssistant) -> bytes: """Load test feed data for two event.""" - return load_fixture_bytes("feedreader1.xml") + return load_fixture_bytes("feedreader1.xml", DOMAIN) @pytest.fixture(name="feed_21_events") def fixture_feed_21_events(hass: HomeAssistant) -> bytes: """Load test feed data for twenty one events.""" - return load_fixture_bytes("feedreader2.xml") + return load_fixture_bytes("feedreader2.xml", DOMAIN) @pytest.fixture(name="feed_three_events") def fixture_feed_three_events(hass: HomeAssistant) -> bytes: """Load test feed data for three events.""" - return load_fixture_bytes("feedreader3.xml") + return load_fixture_bytes("feedreader3.xml", DOMAIN) @pytest.fixture(name="feed_four_events") def fixture_feed_four_events(hass: HomeAssistant) -> bytes: """Load test feed data for three events.""" - return load_fixture_bytes("feedreader4.xml") + return load_fixture_bytes("feedreader4.xml", DOMAIN) @pytest.fixture(name="feed_atom_event") def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: """Load test feed data for atom event.""" - return load_fixture_bytes("feedreader5.xml") + return load_fixture_bytes("feedreader5.xml", DOMAIN) @pytest.fixture(name="feed_identically_timed_events") def fixture_feed_identically_timed_events(hass: HomeAssistant) -> bytes: """Load test feed data for two events published at the exact same time.""" - return load_fixture_bytes("feedreader6.xml") + return load_fixture_bytes("feedreader6.xml", DOMAIN) @pytest.fixture(name="feed_without_items") def fixture_feed_without_items(hass: HomeAssistant) -> bytes: """Load test feed without any items.""" - return load_fixture_bytes("feedreader7.xml") + return load_fixture_bytes("feedreader7.xml", DOMAIN) @pytest.fixture(name="feed_only_summary") def fixture_feed_only_summary(hass: HomeAssistant) -> bytes: """Load test feed data with one event containing only a summary, no content.""" - return load_fixture_bytes("feedreader8.xml") + return load_fixture_bytes("feedreader8.xml", DOMAIN) @pytest.fixture(name="feed_htmlentities") def fixture_feed_htmlentities(hass: HomeAssistant) -> bytes: """Load test feed data with HTML Entities.""" - return load_fixture_bytes("feedreader9.xml") + return load_fixture_bytes("feedreader9.xml", DOMAIN) @pytest.fixture(name="feed_atom_htmlentities") def fixture_feed_atom_htmlentities(hass: HomeAssistant) -> bytes: """Load test ATOM feed data with HTML Entities.""" - return load_fixture_bytes("feedreader10.xml") + return load_fixture_bytes("feedreader10.xml", DOMAIN) @pytest.fixture(name="events") diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index fde92faa673..952efbbb8ec 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -83,8 +83,8 @@ def mock_power_sensor() -> Mock: @pytest.fixture -def mock_cover() -> Mock: - """Fixture for a cover.""" +def mock_positionable_cover() -> Mock: + """Fixture for a positionable cover.""" cover = Mock() cover.fibaro_id = 3 cover.parent_fibaro_id = 0 @@ -112,6 +112,42 @@ def mock_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.""" @@ -136,6 +172,39 @@ def mock_light() -> Mock: return light +@pytest.fixture +def mock_zigbee_light() -> Mock: + """Fixture for a dimmmable zigbee light.""" + light = Mock() + light.fibaro_id = 12 + light.parent_fibaro_id = 0 + light.name = "Test light" + light.room_id = 1 + light.dead = False + light.visible = True + light.enabled = True + light.type = "com.fibaro.multilevelSwitch" + light.base_type = "com.fibaro.binarySwitch" + light.properties = { + "manufacturer": "", + "isLight": True, + "interfaces": ["autoTurnOff", "favoritePosition", "light", "zigbee"], + } + light.actions = {"setValue": 1, "toggle": 0, "turnOn": 0, "turnOff": 0} + light.supported_features = {} + light.has_interface.return_value = False + light.raw_data = { + "fibaro_id": 12, + "name": "Test light", + "properties": {"value": 20}, + } + value_mock = Mock() + value_mock.has_value = True + value_mock.int_value.return_value = 20 + light.value = value_mock + return light + + @pytest.fixture def mock_thermostat() -> Mock: """Fixture for a thermostat.""" diff --git a/tests/components/fibaro/test_cover.py b/tests/components/fibaro/test_cover.py index d5b08f7d1f8..23c704415da 100644 --- a/tests/components/fibaro/test_cover.py +++ b/tests/components/fibaro/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch -from homeassistant.components.cover import CoverState +from homeassistant.components.cover import CoverEntityFeature, CoverState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -12,6 +12,98 @@ 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, @@ -30,20 +122,28 @@ async def test_cover_setup( # Act await init_integration(hass, mock_config_entry) # Assert - entry = entity_registry.async_get("cover.room_1_test_cover_3") + entry = entity_registry.async_get("cover.room_1_test_cover_4") assert entry - assert entry.unique_id == "hc2_111111.3" + 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.original_name == "Room 1 Test cover" -async def test_cover_opening( +async def test_cover_open_action( hass: HomeAssistant, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_cover: Mock, mock_room: Mock, ) -> None: - """Test that the cover opening state is reported.""" + """Test that open_cover works.""" # Arrange mock_fibaro_client.read_rooms.return_value = [mock_room] @@ -52,47 +152,147 @@ async def test_cover_opening( 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 - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPENING + mock_cover.execute_action.assert_called_once_with("open", ()) -async def test_cover_opening_closing_none( +async def test_cover_close_action( hass: HomeAssistant, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_cover: Mock, mock_room: Mock, ) -> None: - """Test that the cover opening closing states return None if not available.""" + """Test that close_cover works.""" # 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 - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPEN + mock_cover.execute_action.assert_called_once_with("close", ()) -async def test_cover_closing( +async def test_cover_stop_action( hass: HomeAssistant, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_cover: Mock, mock_room: Mock, ) -> None: - """Test that the cover closing state is reported.""" + """Test that stop_cover works.""" # 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 - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.CLOSING + 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", ()) diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py index 88576e86dc6..e44036e3f08 100644 --- a/tests/components/fibaro/test_light.py +++ b/tests/components/fibaro/test_light.py @@ -58,6 +58,28 @@ async def test_light_brightness( assert state.state == "on" +async def test_zigbee_light_brightness( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_zigbee_light: Mock, + mock_room: Mock, +) -> None: + """Test that the zigbee dimmable light is detected.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_zigbee_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("light.room_1_test_light_12") + assert state.attributes["brightness"] == 51 + assert state.state == "on" + + async def test_light_turn_off( hass: HomeAssistant, mock_fibaro_client: Mock, diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py index 9a8f0c5030c..d6e97d62ac9 100644 --- a/tests/components/freedompro/test_climate.py +++ b/tests/components/freedompro/test_climate.py @@ -19,6 +19,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow @@ -135,7 +136,7 @@ async def test_climate_set_unsupported_hvac_mode( assert entry assert entry.unique_id == uid - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 7d606bee93a..d082308236a 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -896,7 +896,7 @@ async def test_heating_cooling_switch_toggles_when_outside_min_cycle_duration( "expected_triggered_service_call", ), [ - (True, HVACMode.COOL, False, 30, 25, HVACMode.HEAT, SERVICE_TURN_ON), + (True, HVACMode.COOL, False, 30, 25, HVACMode.COOL, SERVICE_TURN_ON), (True, HVACMode.COOL, True, 25, 30, HVACMode.OFF, SERVICE_TURN_OFF), (False, HVACMode.HEAT, False, 25, 30, HVACMode.HEAT, SERVICE_TURN_ON), (False, HVACMode.HEAT, True, 30, 25, HVACMode.OFF, SERVICE_TURN_OFF), diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py index 0971541efa5..26a8c467c0d 100644 --- a/tests/components/go2rtc/__init__.py +++ b/tests/components/go2rtc/__init__.py @@ -1 +1,32 @@ """Go2rtc tests.""" + +from homeassistant.components.camera import Camera, CameraEntityFeature + + +class MockCamera(Camera): + """Mock Camera Entity.""" + + _attr_name = "Test" + _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM + + def __init__(self) -> None: + """Initialize the mock entity.""" + super().__init__() + self._stream_source: str | None = "rtsp://stream" + + def set_stream_source(self, stream_source: str | None) -> None: + """Set the stream source.""" + self._stream_source = stream_source + + async def stream_source(self) -> str | None: + """Return the source of the stream. + + This is used by cameras with CameraEntityFeature.STREAM + and StreamType.HLS. + """ + return self._stream_source + + @property + def use_stream_for_stills(self) -> bool: + """Always use the RTSP stream to generate snapshots.""" + return True diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index abb139b89bf..bd6d3841dad 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -7,8 +7,24 @@ from awesomeversion import AwesomeVersion from go2rtc_client.rest import _StreamClient, _WebRTCClient import pytest -from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.go2rtc.const import DOMAIN, RECOMMENDED_VERSION from homeassistant.components.go2rtc.server import Server +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import MockCamera + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, + setup_test_component_platform, +) GO2RTC_PATH = "homeassistant.components.go2rtc" @@ -18,7 +34,7 @@ def rest_client() -> Generator[AsyncMock]: """Mock a go2rtc rest client.""" with ( patch( - "homeassistant.components.go2rtc.Go2RtcRestClient", + "homeassistant.components.go2rtc.Go2RtcRestClient", autospec=True ) as mock_client, patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): @@ -94,3 +110,104 @@ def server(server_start: AsyncMock, server_stop: AsyncMock) -> Generator[AsyncMo """Mock a go2rtc server.""" with patch(f"{GO2RTC_PATH}.Server", wraps=Server) as mock_server: yield mock_server + + +@pytest.fixture(name="is_docker_env") +def is_docker_env_fixture() -> bool: + """Fixture to provide is_docker_env return value.""" + return True + + +@pytest.fixture +def mock_is_docker_env(is_docker_env: bool) -> Generator[Mock]: + """Mock is_docker_env.""" + with patch( + "homeassistant.components.go2rtc.is_docker_env", + return_value=is_docker_env, + ) as mock_is_docker_env: + yield mock_is_docker_env + + +@pytest.fixture(name="go2rtc_binary") +def go2rtc_binary_fixture() -> str: + """Fixture to provide go2rtc binary name.""" + return "/usr/bin/go2rtc" + + +@pytest.fixture +def mock_get_binary(go2rtc_binary: str) -> Generator[Mock]: + """Mock _get_binary.""" + with patch( + "homeassistant.components.go2rtc.shutil.which", + return_value=go2rtc_binary, + ) as mock_which: + yield mock_which + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + rest_client: AsyncMock, + mock_is_docker_env: Generator[Mock], + mock_get_binary: Generator[Mock], + server: Mock, +) -> None: + """Initialize the go2rtc integration.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + +TEST_DOMAIN = "test" + + +@pytest.fixture +def integration_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Test mock config entry.""" + entry = MockConfigEntry(domain=TEST_DOMAIN) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_test_integration( + hass: HomeAssistant, + integration_config_entry: ConfigEntry, +) -> MockCamera: + """Initialize components.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.CAMERA] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, Platform.CAMERA + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + test_camera = MockCamera() + setup_test_component_platform( + hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True + ) + mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) + + with mock_config_flow(TEST_DOMAIN, ConfigFlow): + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + return test_camera diff --git a/tests/components/go2rtc/fixtures/snapshot.jpg b/tests/components/go2rtc/fixtures/snapshot.jpg new file mode 100644 index 00000000000..d8bf2053caf Binary files /dev/null and b/tests/components/go2rtc/fixtures/snapshot.jpg differ diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 3fca0d27b6b..2abdf724f61 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -1,6 +1,6 @@ """The tests for the go2rtc component.""" -from collections.abc import Callable, Generator +from collections.abc import Callable import logging from typing import NamedTuple from unittest.mock import AsyncMock, Mock, patch @@ -21,41 +21,32 @@ import pytest from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( - DOMAIN as CAMERA_DOMAIN, - Camera, - CameraEntityFeature, StreamType, WebRTCAnswer as HAWebRTCAnswer, WebRTCCandidate as HAWebRTCCandidate, WebRTCError, WebRTCMessage, WebRTCSendMessage, + async_get_image, ) from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN -from homeassistant.components.go2rtc import WebRTCProvider +from homeassistant.components.go2rtc import HomeAssistant, WebRTCProvider from homeassistant.components.go2rtc.const import ( CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, RECOMMENDED_VERSION, ) -from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow -from homeassistant.const import CONF_URL, Platform -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_URL +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - MockModule, - mock_config_flow, - mock_integration, - mock_platform, - setup_test_component_platform, -) +from . import MockCamera -TEST_DOMAIN = "test" +from tests.common import MockConfigEntry, load_fixture_bytes # The go2rtc provider does not inspect the details of the offer and answer, # and is only a pass through. @@ -63,54 +54,6 @@ OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." -class MockCamera(Camera): - """Mock Camera Entity.""" - - _attr_name = "Test" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - - def __init__(self) -> None: - """Initialize the mock entity.""" - super().__init__() - self._stream_source: str | None = "rtsp://stream" - - def set_stream_source(self, stream_source: str | None) -> None: - """Set the stream source.""" - self._stream_source = stream_source - - async def stream_source(self) -> str | None: - """Return the source of the stream. - - This is used by cameras with CameraEntityFeature.STREAM - and StreamType.HLS. - """ - return self._stream_source - - -@pytest.fixture -def integration_config_entry(hass: HomeAssistant) -> ConfigEntry: - """Test mock config entry.""" - entry = MockConfigEntry(domain=TEST_DOMAIN) - entry.add_to_hass(hass) - return entry - - -@pytest.fixture(name="go2rtc_binary") -def go2rtc_binary_fixture() -> str: - """Fixture to provide go2rtc binary name.""" - return "/usr/bin/go2rtc" - - -@pytest.fixture -def mock_get_binary(go2rtc_binary) -> Generator[Mock]: - """Mock _get_binary.""" - with patch( - "homeassistant.components.go2rtc.shutil.which", - return_value=go2rtc_binary, - ) as mock_which: - yield mock_which - - @pytest.fixture(name="has_go2rtc_entry") def has_go2rtc_entry_fixture() -> bool: """Fixture to control if a go2rtc config entry should be created.""" @@ -126,80 +69,6 @@ def mock_go2rtc_entry(hass: HomeAssistant, has_go2rtc_entry: bool) -> None: config_entry.add_to_hass(hass) -@pytest.fixture(name="is_docker_env") -def is_docker_env_fixture() -> bool: - """Fixture to provide is_docker_env return value.""" - return True - - -@pytest.fixture -def mock_is_docker_env(is_docker_env) -> Generator[Mock]: - """Mock is_docker_env.""" - with patch( - "homeassistant.components.go2rtc.is_docker_env", - return_value=is_docker_env, - ) as mock_is_docker_env: - yield mock_is_docker_env - - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - rest_client: AsyncMock, - mock_is_docker_env, - mock_get_binary, - server: Mock, -) -> None: - """Initialize the go2rtc integration.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - -@pytest.fixture -async def init_test_integration( - hass: HomeAssistant, - integration_config_entry: ConfigEntry, -) -> MockCamera: - """Initialize components.""" - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [Platform.CAMERA] - ) - return True - - async def async_unload_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Unload test config entry.""" - await hass.config_entries.async_forward_entry_unload( - config_entry, Platform.CAMERA - ) - return True - - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - async_unload_entry=async_unload_entry_init, - ), - ) - test_camera = MockCamera() - setup_test_component_platform( - hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True - ) - mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) - - with mock_config_flow(TEST_DOMAIN, ConfigFlow): - assert await hass.config_entries.async_setup(integration_config_entry.entry_id) - await hass.async_block_till_done() - - return test_camera - - async def _test_setup_and_signaling( hass: HomeAssistant, issue_registry: ir.IssueRegistry, @@ -218,14 +87,18 @@ async def _test_setup_and_signaling( assert issue_registry.async_get_issue(DOMAIN, "recommended_version") is None config_entries = hass.config_entries.async_entries(DOMAIN) assert len(config_entries) == 1 - assert config_entries[0].state == ConfigEntryState.LOADED + config_entry = config_entries[0] + assert config_entry.state == ConfigEntryState.LOADED after_setup_fn() receive_message_callback = Mock(spec_set=WebRTCSendMessage) - async def test() -> None: + sessions = [] + + async def test(session: str) -> None: + sessions.append(session) await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback + OFFER_SDP, session, receive_message_callback ) ws_client.send.assert_called_once_with( WebRTCOffer( @@ -240,13 +113,14 @@ async def _test_setup_and_signaling( callback(WebRTCAnswer(ANSWER_SDP)) receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) - await test() + await test("sesion_1") rest_client.streams.add.assert_called_once_with( entity_id, [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -258,13 +132,14 @@ async def _test_setup_and_signaling( receive_message_callback.reset_mock() ws_client.reset_mock() - await test() + await test("session_2") rest_client.streams.add.assert_called_once_with( entity_id, [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -276,25 +151,37 @@ async def _test_setup_and_signaling( receive_message_callback.reset_mock() ws_client.reset_mock() - await test() + await test("session_3") rest_client.streams.add.assert_not_called() assert isinstance(camera._webrtc_provider, WebRTCProvider) - # Set stream source to None and provider should be skipped - rest_client.streams.list.return_value = {} - receive_message_callback.reset_mock() - camera.set_stream_source(None) - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - receive_message_callback.assert_called_once_with( - WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") - ) + provider = camera._webrtc_provider + for session in sessions: + assert session in provider._sessions + + with patch.object(provider, "teardown", wraps=provider.teardown) as teardown: + # Set stream source to None and provider should be skipped + rest_client.streams.list.return_value = {} + receive_message_callback.reset_mock() + camera.set_stream_source(None) + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + receive_message_callback.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + ) + teardown.assert_called_once() + # We use one ws_client mock for all sessions + assert ws_client.close.call_count == len(sessions) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert teardown.call_count == 2 @pytest.mark.usefixtures( - "init_test_integration", "mock_get_binary", "mock_is_docker_env", "mock_go2rtc_entry", @@ -757,3 +644,29 @@ async def test_setup_with_recommended_version_repair( "recommended_version": RECOMMENDED_VERSION, "current_version": "1.9.5", } + + +@pytest.mark.usefixtures("init_integration") +async def test_async_get_image( + hass: HomeAssistant, + init_test_integration: MockCamera, + rest_client: AsyncMock, +) -> None: + """Test getting snapshot from go2rtc.""" + camera = init_test_integration + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) + + rest_client.get_jpeg_snapshot.return_value = image_bytes + assert await camera._webrtc_provider.async_get_image(camera) == image_bytes + + image = await async_get_image(hass, camera.entity_id) + assert image.content == image_bytes + + camera.set_stream_source("invalid://not_supported") + + with pytest.raises( + HomeAssistantError, match="Stream source is not supported by go2rtc" + ): + await async_get_image(hass, camera.entity_id) diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 6421f0c526c..0ac829d07b5 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -97,7 +97,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_total_run_time") assert state.state == "1720984" - assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.SECONDS assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_wi_fi_ssid") diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 6ec147da2ab..f499f18bc15 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -1,10 +1,11 @@ """Tests helpers.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.google_generative_ai_conversation.conversation import ( +from homeassistant.components.google_generative_ai_conversation.entity import ( CONF_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.config_entries import ConfigEntry @@ -77,3 +78,22 @@ async def mock_init_component( async def setup_ha(hass: HomeAssistant) -> None: """Set up Home Assistant.""" assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture +def mock_send_message_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(stream): + for value in stream: + yield value + + with patch( + "google.genai.chats.AsyncChat.send_message_stream", + AsyncMock(), + ) as mock_send_message_stream: + mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( + mock_send_message_stream.return_value.pop(0) + ) + + yield mock_send_message_stream diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 2d1a46393fd..92aa6f08d42 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -1,6 +1,5 @@ """Tests for the Google Generative AI Conversation integration conversation platform.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from freezegun import freeze_time @@ -9,7 +8,7 @@ import pytest from homeassistant.components import conversation from homeassistant.components.conversation import UserContent -from homeassistant.components.google_generative_ai_conversation.conversation import ( +from homeassistant.components.google_generative_ai_conversation.entity import ( ERROR_GETTING_RESPONSE, _escape_decode, _format_schema, @@ -41,25 +40,6 @@ def mock_ulid_tools(): yield -@pytest.fixture -def mock_send_message_stream() -> Generator[AsyncMock]: - """Mock stream response.""" - - async def mock_generator(stream): - for value in stream: - yield value - - with patch( - "google.genai.chats.AsyncChat.send_message_stream", - AsyncMock(), - ) as mock_send_message_stream: - mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( - mock_send_message_stream.return_value.pop(0) - ) - - yield mock_send_message_stream - - @pytest.mark.parametrize( ("error"), [ diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py new file mode 100644 index 00000000000..5ea056307b5 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -0,0 +1,413 @@ +"""Tests for the Google Generative AI Conversation TTS entity.""" + +from __future__ import annotations + +from collections.abc import Generator +from http import HTTPStatus +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from google.genai import types +import pytest + +from homeassistant.components import tts +from homeassistant.components.google_generative_ai_conversation.tts import ( + ATTR_MODEL, + DOMAIN, + RECOMMENDED_TTS_MODEL, +) +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_PLATFORM +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core_config import async_process_ha_core_config +from homeassistant.setup import async_setup_component + +from . import API_ERROR_500 + +from tests.common import MockConfigEntry, async_mock_service +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None: + """Mock the TTS cache dir with empty dir.""" + + +@pytest.fixture +async def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Mock media player calls.""" + return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + +@pytest.fixture(autouse=True) +async def setup_internal_url(hass: HomeAssistant) -> None: + """Set up internal url.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"} + ) + + +@pytest.fixture +def mock_genai_client() -> Generator[AsyncMock]: + """Mock genai_client.""" + client = Mock() + client.aio.models.get = AsyncMock() + client.models.generate_content.return_value = types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) + ) + ), + ) + ) + with patch( + "homeassistant.components.google_generative_ai_conversation.Client", + return_value=client, + ) as mock_client: + yield mock_client + + +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + config: dict[str, Any], + request: pytest.FixtureRequest, + mock_genai_client: AsyncMock, +) -> None: + """Set up the test environment.""" + if request.param == "mock_setup": + await mock_setup(hass, config) + if request.param == "mock_config_entry_setup": + await mock_config_entry_setup(hass, config) + else: + raise RuntimeError("Invalid setup fixture") + + await hass.async_block_till_done() + + +@pytest.fixture(name="config") +def config_fixture() -> dict[str, Any]: + """Return config.""" + return { + CONF_API_KEY: "bla", + } + + +async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Mock setup.""" + assert await async_setup_component( + hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config} + ) + + +async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Mock config entry setup.""" + default_config = {tts.CONF_LANG: "en-US"} + config_entry = MockConfigEntry(domain=DOMAIN, data=default_config | config) + + client_mock = Mock() + client_mock.models.get = None + client_mock.models.generate_content.return_value = types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) + ) + ), + ) + ) + config_entry.runtime_data = client_mock + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "zephyr") + model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, RECOMMENDED_TTS_MODEL) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=model_id, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "de-DE", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "it-IT", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_lang_config( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with languages in the config.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_error( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with HTTP response 500.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + tts_entity._genai_client.models.generate_content.side_effect = API_ERROR_500 + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.INTERNAL_SERVER_ERROR + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_without_options( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with HTTP response 200.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="zephyr") + ) + ), + ), + ) diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a71ee370b32..56f7ffaa5b9 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -260,3 +260,16 @@ 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 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f74ed852a49..f424beedc85 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1156,7 +1156,11 @@ def test_deprecated_constants( ("rpi2", "deprecated_os_armv7"), ], ) -async def test_deprecated_installation_issue_aarch64( +@pytest.mark.parametrize( + "arch", + ["armv7"], +) +async def test_deprecated_installation_issue_os_armv7( hass: HomeAssistant, issue_registry: ir.IssueRegistry, freezer: FrozenDateTimeFactory, @@ -1167,18 +1171,15 @@ async def test_deprecated_installation_issue_aarch64( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": "armv7", }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "armv7", - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", return_value={"board": board} @@ -1228,7 +1229,7 @@ async def test_deprecated_installation_issue_aarch64( "armv7", ], ) -async def test_deprecated_installation_issue_32bit_method( +async def test_deprecated_installation_issue_32bit_os( hass: HomeAssistant, issue_registry: ir.IssueRegistry, freezer: FrozenDateTimeFactory, @@ -1238,18 +1239,15 @@ async def test_deprecated_installation_issue_32bit_method( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": arch, }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": arch, - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", @@ -1308,18 +1306,15 @@ async def test_deprecated_installation_issue_32bit_supervised( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant Supervised", "arch": arch, }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Supervised", - "arch": arch, - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", @@ -1365,6 +1360,75 @@ 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"), [ @@ -1382,18 +1446,15 @@ async def test_deprecated_installation_issue_supported_board( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": "aarch64", }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "aarch64", - }, + "homeassistant.components.hassio._is_32_bit", + return_value=False, ), patch( "homeassistant.components.hassio.get_os_info", return_value={"board": board} diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 0231ac6428f..7c8946b7049 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -150,10 +150,10 @@ async def test_sensor( duration = hass.states.get("sensor.test_duration") assert duration.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES assert duration.attributes.get(ATTR_ICON) == icon - assert duration.state == "26" + assert duration.state == "26.1833333333333" assert float(hass.states.get("sensor.test_distance").state) == pytest.approx(13.682) - assert hass.states.get("sensor.test_duration_in_traffic").state == "30" + assert hass.states.get("sensor.test_duration_in_traffic").state == "29.6" assert hass.states.get("sensor.test_origin").state == "22nd St NW" assert ( hass.states.get("sensor.test_origin").attributes.get(ATTR_LATITUDE) @@ -319,8 +319,8 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non valid_response.assert_called_with( transport_mode=TransportMode.TRUCK, - origin=Place(ORIGIN_LATITUDE, ORIGIN_LONGITUDE), - destination=Place(DESTINATION_LATITUDE, DESTINATION_LONGITUDE), + origin=Place(float(ORIGIN_LATITUDE), float(ORIGIN_LONGITUDE)), + destination=Place(float(DESTINATION_LATITUDE), float(DESTINATION_LONGITUDE)), routing_mode=RoutingMode.FAST, arrival_time=None, departure_time=None, @@ -501,13 +501,13 @@ async def test_restore_state(hass: HomeAssistant) -> None: "1234", attributes={ ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ), { "native_value": 1234, - "native_unit_of_measurement": UnitOfTime.MINUTES, + "native_unit_of_measurement": UnitOfTime.SECONDS, "icon": "mdi:car", "last_reset": last_reset, }, @@ -518,13 +518,13 @@ async def test_restore_state(hass: HomeAssistant) -> None: "5678", attributes={ ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ), { "native_value": 5678, - "native_unit_of_measurement": UnitOfTime.MINUTES, + "native_unit_of_measurement": UnitOfTime.SECONDS, "icon": "mdi:car", "last_reset": last_reset, }, @@ -596,12 +596,12 @@ async def test_restore_state(hass: HomeAssistant) -> None: # restore from cache state = hass.states.get("sensor.test_duration") - assert state.state == "1234" + assert state.state == "20.5666666666667" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.MINUTES assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get("sensor.test_duration_in_traffic") - assert state.state == "5678" + assert state.state == "94.6333333333333" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.MINUTES assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -799,10 +799,12 @@ async def test_multiple_sections( await hass.async_block_till_done() duration = hass.states.get("sensor.test_duration") - assert duration.state == "18" + assert duration.state == "18.4833333333333" assert float(hass.states.get("sensor.test_distance").state) == pytest.approx(3.583) - assert hass.states.get("sensor.test_duration_in_traffic").state == "18" + assert ( + hass.states.get("sensor.test_duration_in_traffic").state == "18.4833333333333" + ) assert hass.states.get("sensor.test_origin").state == "Chemin de Halage" assert ( hass.states.get("sensor.test_origin").attributes.get(ATTR_LATITUDE) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index a5e454221d3..0779339cf65 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -648,18 +648,24 @@ async def test_reload_all( "armv7", ], ) -async def test_deprecated_installation_issue_32bit_method( +async def test_deprecated_installation_issue_32bit_core( 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, - }, + 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, + ), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -677,46 +683,28 @@ async def test_deprecated_installation_issue_32bit_method( @pytest.mark.parametrize( "arch", [ - "i386", - "armhf", + "aarch64", + "generic-x86-64", ], ) -async def test_deprecated_installation_issue_32bit( +async def test_deprecated_installation_issue_64bit_core( 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, - }, - ): - 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", - }, + 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, + ), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -727,26 +715,46 @@ async def test_deprecated_installation_issue_method( assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders == { "installation_type": "Core", - "arch": "generic-x86-64", + "arch": arch, } -async def test_deprecated_installation_issue_armv7_container( +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armv7", + "armhf", + ], +) +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 Container", - "arch": "armv7", - }, + 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, + ), ): 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_armv7") + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_container") assert issue.domain == DOMAIN assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == {"arch": arch} diff --git a/tests/components/homee/fixtures/events.json b/tests/components/homee/fixtures/events.json index 351d35ec497..f1d5c961ce9 100644 --- a/tests/components/homee/fixtures/events.json +++ b/tests/components/homee/fixtures/events.json @@ -41,6 +41,48 @@ "options": { "observed_by": [145] } + }, + { + "id": 2, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 3, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 40, + "state": 1, + "last_changed": 1749885830, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "Kitchen Light" + }, + { + "id": 3, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 3, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 40, + "state": 1, + "last_changed": 1749885830, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" } ] } diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json index 50daa59c99f..1c743195a20 100644 --- a/tests/components/homee/fixtures/sensors.json +++ b/tests/components/homee/fixtures/sensors.json @@ -81,27 +81,6 @@ "data": "", "name": "" }, - { - "id": 34, - "node_id": 1, - "instance": 2, - "minimum": 0, - "maximum": 100, - "current_value": 100.0, - "target_value": 100.0, - "last_value": 100.0, - "unit": "%", - "step_value": 1.0, - "editable": 0, - "type": 8, - "state": 1, - "last_changed": 1709982926, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "" - }, { "id": 4, "node_id": 1, diff --git a/tests/components/homee/snapshots/test_event.ambr b/tests/components/homee/snapshots/test_event.ambr index b3f544bcc4e..981b6263984 100644 --- a/tests/components/homee/snapshots/test_event.ambr +++ b/tests/components/homee/snapshots/test_event.ambr @@ -1,4 +1,126 @@ # serializer version: 1 +# name: test_event_snapshot[event.remote_control_kitchen_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.remote_control_kitchen_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Light', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button_state_instance', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.remote_control_kitchen_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + 'friendly_name': 'Remote Control Kitchen Light', + }), + 'context': , + 'entity_id': 'event.remote_control_kitchen_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event_snapshot[event.remote_control_switch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.remote_control_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button_state_instance', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.remote_control_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + 'friendly_name': 'Remote Control Switch 2', + }), + 'context': , + 'entity_id': 'event.remote_control_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_event_snapshot[event.remote_control_up_down_remote-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index b5975af2d54..4e4eb98f28c 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -52,59 +52,6 @@ 'state': '100.0', }) # --- -# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.test_multisensor_battery_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery', - 'platform': 'homee', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_instance', - 'unique_id': '00055511EECC-1-34', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Test MultiSensor Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.test_multisensor_battery_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100.0', - }) -# --- # name: test_sensor_snapshot[sensor.test_multisensor_current_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -490,6 +437,62 @@ 'state': '2000.0', }) # --- +# name: test_sensor_snapshot[sensor.test_multisensor_external_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_external_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_temperature', + 'unique_id': '00055511EECC-1-34', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_external_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor External temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_external_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.6', + }) +# --- # name: test_sensor_snapshot[sensor.test_multisensor_floor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index 4f85b2dd7cc..a3e26abc52a 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -66,6 +66,35 @@ async def test_open_close_stop_cover( assert call[0] == (mock_homee.nodes[0].id, 1, index) +async def test_open_close_reverse_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test opening the cover.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.nodes[0].attributes[0].is_reversed = True + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + assert calls[0][0] == (mock_homee.nodes[0].id, 1, 1) # Open + assert calls[1][0] == (mock_homee.nodes[0].id, 1, 0) # Close + + async def test_set_cover_position( hass: HomeAssistant, mock_homee: MagicMock, @@ -76,30 +105,29 @@ async def test_set_cover_position( await setup_integration(hass, mock_config_entry) - # Slats have a range of -45 to 90. await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 100}, + {ATTR_ENTITY_ID: "cover.test_cover", ATTR_POSITION: 100}, blocking=True, ) await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 0}, + {ATTR_ENTITY_ID: "cover.test_cover", ATTR_POSITION: 0}, blocking=True, ) await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 50}, + {ATTR_ENTITY_ID: "cover.test_cover", ATTR_POSITION: 50}, blocking=True, ) calls = mock_homee.set_value.call_args_list positions = [0, 100, 50] for call in calls: - assert call[0] == (1, 2, positions.pop(0)) + assert call[0] == (3, 2, positions.pop(0)) async def test_close_open_slats( @@ -137,6 +165,42 @@ async def test_close_open_slats( assert call[0] == (mock_homee.nodes[0].id, 2, index) +async def test_close_open_reversed_slats( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test closing and opening slats.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + mock_homee.nodes[0].attributes[1].is_reversed = True + + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("cover.test_slats").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + assert calls[0][0] == (mock_homee.nodes[0].id, 2, 2) # Close + assert calls[1][0] == (mock_homee.nodes[0].id, 2, 1) # Open + + async def test_set_slat_position( hass: HomeAssistant, mock_homee: MagicMock, diff --git a/tests/components/homee/test_event.py b/tests/components/homee/test_event.py index 0ffa7cd8530..176f1e9a053 100644 --- a/tests/components/homee/test_event.py +++ b/tests/components/homee/test_event.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ATTR_EVENT_TYPE @@ -14,38 +15,54 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform -async def test_event_fires( +@pytest.mark.parametrize( + ("entity_id", "attribute_id", "expected_event_types"), + [ + ( + "event.remote_control_up_down_remote", + 1, + [ + "released", + "up", + "down", + "stop", + "up_long", + "down_long", + "stop_long", + "c_button", + "b_button", + "a_button", + ], + ), + ( + "event.remote_control_switch_2", + 3, + ["upper", "lower", "released"], + ), + ], +) +async def test_event_triggers( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry, + entity_id: str, + attribute_id: int, + expected_event_types: list[str], ) -> None: """Test that the correct event fires when the attribute changes.""" - - EVENT_TYPES = [ - "released", - "up", - "down", - "stop", - "up_long", - "down_long", - "stop_long", - "c_button", - "b_button", - "a_button", - ] mock_homee.nodes = [build_mock_node("events.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) # Simulate the event triggers. - attribute = mock_homee.nodes[0].attributes[0] - for i, event_type in enumerate(EVENT_TYPES): + attribute = mock_homee.nodes[0].attributes[attribute_id - 1] + for i, event_type in enumerate(expected_event_types): attribute.current_value = i attribute.add_on_changed_listener.call_args_list[1][0][0](attribute) await hass.async_block_till_done() # Check if the event was fired - state = hass.states.get("event.remote_control_up_down_remote") + state = hass.states.get(entity_id) assert state.attributes[ATTR_EVENT_TYPE] == event_type diff --git a/tests/components/homee/test_lock.py b/tests/components/homee/test_lock.py index 3e6ff3f8ec6..6f41185c4ed 100644 --- a/tests/components/homee/test_lock.py +++ b/tests/components/homee/test_lock.py @@ -111,6 +111,23 @@ async def test_lock_changed_by( assert hass.states.get("lock.test_lock").attributes["changed_by"] == expected +async def test_lock_changed_by_unknown_user( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test lock changed by entries.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + mock_homee.get_user_by_id.return_value = None # Simulate unknown user + attribute = mock_homee.nodes[0].attributes[0] + attribute.changed_by = 2 + attribute.changed_by_id = 1 + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").attributes["changed_by"] == "user-Unknown" + + async def test_lock_snapshot( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 14a9320ffa1..1d4ad4b0f66 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -47,7 +47,7 @@ async def test_up_down_values( assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] - attribute = mock_homee.nodes[0].attributes[28] + attribute = mock_homee.nodes[0].attributes[27] for i in range(1, 5): await async_update_attribute_value(hass, attribute, i) assert ( @@ -77,7 +77,7 @@ async def test_window_position( == WINDOW_MAP[0] ) - attribute = mock_homee.nodes[0].attributes[33] + attribute = mock_homee.nodes[0].attributes[32] for i in range(1, 3): await async_update_attribute_value(hass, attribute, i) assert ( diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index 434f26e0e6f..67dbb55bb12 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -205,13 +205,14 @@ async def test_hmip_heating_group_heat( ha_state = hass.states.get(entity_id) assert ha_state.state == HVACMode.AUTO - # hvac mode "dry" is not available. expect a valueerror. - await hass.services.async_call( - "climate", - "set_hvac_mode", - {"entity_id": entity_id, "hvac_mode": "dry"}, - blocking=True, - ) + # hvac mode "dry" is not available. + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": "dry"}, + blocking=True, + ) assert len(hmip_device.mock_calls) == service_call_counter + 24 # Only fire event from last async_manipulate_test_data available. diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index a8aab422eb9..ae094f7dded 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -16,6 +16,7 @@ from homeassistant.components.homematicip_cloud.const import ( ) from homeassistant.components.homematicip_cloud.errors import HmipcConnectionError from homeassistant.components.homematicip_cloud.hap import ( + AsyncHome, HomematicipAuth, HomematicipHAP, ) @@ -251,3 +252,21 @@ async def test_get_state_after_disconnect( assert hap._ws_connection_closed.is_set() await hap.ws_connected_handler() mock_get_state.assert_called_once() + + +async def test_async_connect( + hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home +) -> None: + """Test async_connect.""" + hass.config.components.add(DOMAIN) + hap = HomematicipHAP(hass, hmip_config_entry) + assert hap + + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + + await hap.async_connect(simple_mock_home) + + simple_mock_home.set_on_connected_handler.assert_called_once() + simple_mock_home.set_on_disconnected_handler.assert_called_once() + simple_mock_home.set_on_reconnect_handler.assert_called_once() + simple_mock_home.enable_events.assert_called_once() diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py index 1a728bfecd4..50d527775bd 100644 --- a/tests/components/homematicip_cloud/test_switch.py +++ b/tests/components/homematicip_cloud/test_switch.py @@ -25,14 +25,14 @@ async def test_hmip_switch( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -40,9 +40,9 @@ async def test_hmip_switch( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -64,14 +64,14 @@ async def test_hmip_switch_input( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -79,9 +79,9 @@ async def test_hmip_switch_input( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -103,14 +103,14 @@ async def test_hmip_switch_measuring( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -118,9 +118,9 @@ async def test_hmip_switch_measuring( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) ha_state = hass.states.get(entity_id) @@ -191,14 +191,14 @@ async def test_hmip_multi_switch( ) assert ha_state.state == STATE_OFF - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -206,9 +206,9 @@ async def test_hmip_multi_switch( await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -242,14 +242,14 @@ async def test_hmip_wired_multi_switch( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -257,9 +257,9 @@ async def test_hmip_wired_multi_switch( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index b8367f87e57..c6098342d25 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.models import ( + Batteries, CombinedModels, Device, Measurement, @@ -64,6 +65,13 @@ def mock_homewizardenergy( if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists() else None ), + batteries=( + Batteries.from_dict( + load_json_object_fixture(f"{device_fixture}/batteries.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/batteries.json", DOMAIN).exists() + else None + ), ) # device() call is used during configuration flow @@ -112,6 +120,13 @@ def mock_homewizardenergy_v2( if get_fixture_path(f"v2/{device_fixture}/system.json", DOMAIN).exists() else None ), + batteries=( + Batteries.from_dict( + load_json_object_fixture(f"{device_fixture}/batteries.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/batteries.json", DOMAIN).exists() + else None + ), ) # device() call is used during configuration flow diff --git a/tests/components/homewizard/fixtures/HWE-P1/batteries.json b/tests/components/homewizard/fixtures/HWE-P1/batteries.json new file mode 100644 index 00000000000..279e49606b3 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1/batteries.json @@ -0,0 +1,7 @@ +{ + "mode": "zero", + "power_w": -404, + "target_power_w": -400, + "max_consumption_w": 1600, + "max_production_w": 800 +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 2545f674bbd..449dfd0c02f 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_diagnostics[HWE-BAT] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '1.00', @@ -93,6 +94,7 @@ # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.06', @@ -184,6 +186,7 @@ # name: test_diagnostics[HWE-KWH3] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.06', @@ -275,6 +278,13 @@ # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ + 'batteries': dict({ + 'max_consumption_w': 1600.0, + 'max_production_w': 800.0, + 'mode': 'zero', + 'power_w': -404.0, + 'target_power_w': -400.0, + }), 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '4.19', @@ -402,6 +412,7 @@ # name: test_diagnostics[HWE-SKT-11] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.03', @@ -497,6 +508,7 @@ # name: test_diagnostics[HWE-SKT-21] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '4.07', @@ -592,6 +604,7 @@ # name: test_diagnostics[HWE-WTR] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '2.03', @@ -683,6 +696,7 @@ # name: test_diagnostics[SDM230] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.06', @@ -774,6 +788,7 @@ # name: test_diagnostics[SDM630] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.06', diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr new file mode 100644 index 00000000000..ecfd80e04da --- /dev/null +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Battery group mode', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.device_battery_group_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'zero', + }) +# --- +# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.device_battery_group_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery group mode', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_group_mode', + 'unique_id': 'HWE-P1_5c2fafabcdef_battery_group_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Wi-Fi P1 Meter', + 'model_id': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index d0a6d92b36f..f5c28735da4 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -61,7 +61,7 @@ async def test_identify_button( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with HomeWizard device$", + match=r"^An error occurred while communicating with your HomeWizard Energy device$", ): await hass.services.async_call( button.DOMAIN, diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 9139ef80d12..be811355e1d 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -10,7 +10,6 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -58,36 +57,6 @@ async def test_load_unload_v2( assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED -async def test_load_unload_v2_as_v1( - hass: HomeAssistant, - mock_homewizardenergy: MagicMock, -) -> None: - """Test loading and unloading of integration with v2 config, but without using it.""" - - # Simulate v2 config but as a P1 Meter - mock_config_entry = MockConfigEntry( - title="Device", - domain=DOMAIN, - data={ - CONF_IP_ADDRESS: "127.0.0.1", - CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", - }, - unique_id="HWE-P1_5c2fafabcdef", - ) - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert len(mock_homewizardenergy.combined.mock_calls) == 1 - - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - async def test_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 67e51cbafe2..ffc31cb3859 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -73,7 +73,7 @@ async def test_number_entities( mock_homewizardenergy.system.side_effect = RequestError with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with HomeWizard device$", + match=r"^An error occurred while communicating with your HomeWizard Energy device$", ): await hass.services.async_call( number.DOMAIN, diff --git a/tests/components/homewizard/test_select.py b/tests/components/homewizard/test_select.py new file mode 100644 index 00000000000..d61f8d167c4 --- /dev/null +++ b/tests/components/homewizard/test_select.py @@ -0,0 +1,294 @@ +"""Test the Select entity for HomeWizard.""" + +from unittest.mock import MagicMock + +from homewizard_energy import UnsupportedError +from homewizard_energy.errors import RequestError, UnauthorizedError +from homewizard_energy.models import Batteries +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homewizard.const import UPDATE_INTERVAL +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), +] + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-WTR", + [ + "select.device_battery_group_mode", + ], + ), + ( + "SDM230", + [ + "select.device_battery_group_mode", + ], + ), + ( + "SDM630", + [ + "select.device_battery_group_mode", + ], + ), + ( + "HWE-KWH1", + [ + "select.device_battery_group_mode", + ], + ), + ( + "HWE-KWH3", + [ + "select.device_battery_group_mode", + ], + ), + ], +) +async def test_entities_not_created_for_device( + hass: HomeAssistant, + entity_ids: list[str], +) -> None: + """Ensures entities for a specific device are not created.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id"), + [ + ("HWE-P1", "select.device_battery_group_mode"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_entity_snapshots( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test that select entity state and registry entries match snapshots.""" + assert (state := hass.states.get(entity_id)) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "option", "expected_mode"), + [ + ( + "HWE-P1", + "select.device_battery_group_mode", + "standby", + Batteries.Mode.STANDBY, + ), + ( + "HWE-P1", + "select.device_battery_group_mode", + "to_full", + Batteries.Mode.TO_FULL, + ), + ("HWE-P1", "select.device_battery_group_mode", "zero", Batteries.Mode.ZERO), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_set_option( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + entity_id: str, + option: str, + expected_mode: Batteries.Mode, +) -> None: + """Test that selecting an option calls the correct API method.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: option, + }, + blocking=True, + ) + mock_homewizardenergy.batteries.assert_called_with(mode=expected_mode) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "option"), + [ + ("HWE-P1", "select.device_battery_group_mode", "zero"), + ("HWE-P1", "select.device_battery_group_mode", "standby"), + ("HWE-P1", "select.device_battery_group_mode", "to_full"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_request_error( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + entity_id: str, + option: str, +) -> None: + """Test that RequestError is handled and raises HomeAssistantError.""" + mock_homewizardenergy.batteries.side_effect = RequestError + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with your HomeWizard Energy device$", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: option, + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "option"), + [ + ("HWE-P1", "select.device_battery_group_mode", "to_full"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_unauthorized_error( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + entity_id: str, + option: str, +) -> None: + """Test that UnauthorizedError is handled and raises HomeAssistantError.""" + mock_homewizardenergy.batteries.side_effect = UnauthorizedError + with pytest.raises( + HomeAssistantError, + match=r"^The local API is unauthorized\. Restore API access by following the instructions in the repair issue$", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: option, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("device_fixture", ["HWE-P1"]) +@pytest.mark.parametrize("exception", [RequestError, UnsupportedError]) +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("select.device_battery_group_mode", "combined"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_unreachable( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, + entity_id: str, + method: str, +) -> None: + """Test that unreachable devices are marked as unavailable.""" + mocked_method = getattr(mock_homewizardenergy, method) + mocked_method.side_effect = exception + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id"), + [ + ("HWE-P1", "select.device_battery_group_mode"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_multiple_state_changes( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + entity_id: str, +) -> None: + """Test changing select state multiple times in sequence.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "zero", + }, + blocking=True, + ) + mock_homewizardenergy.batteries.assert_called_with(mode=Batteries.Mode.ZERO) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "to_full", + }, + blocking=True, + ) + mock_homewizardenergy.batteries.assert_called_with(mode=Batteries.Mode.TO_FULL) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "standby", + }, + blocking=True, + ) + mock_homewizardenergy.batteries.assert_called_with(mode=Batteries.Mode.STANDBY) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-P1", + [ + "select.device_battery_group_mode", + ], + ), + ], +) +async def test_disabled_by_default_selects( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default selects.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index ae9b7653b6d..9eba571273d 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -149,7 +149,7 @@ async def test_switch_entities( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with HomeWizard device$", + match=r"^An error occurred while communicating with your HomeWizard Energy device$", ): await hass.services.async_call( switch.DOMAIN, @@ -160,7 +160,7 @@ async def test_switch_entities( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with HomeWizard device$", + match=r"^An error occurred while communicating with your HomeWizard Energy device$", ): await hass.services.async_call( switch.DOMAIN, diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 0581c7bac2a..bddd66a7e81 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -16,6 +16,7 @@ from aiohttp.hdrs import ( from aiohttp.test_utils import TestClient import pytest +from homeassistant.components.http import StaticPathConfig from homeassistant.components.http.cors import setup_cors from homeassistant.core import HomeAssistant from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS, HomeAssistantView @@ -157,7 +158,9 @@ async def test_cors_on_static_files( assert await async_setup_component( hass, "frontend", {"http": {"cors_allowed_origins": ["http://www.example.com"]}} ) - hass.http.register_static_path("/something", str(Path(__file__).parent)) + await hass.http.async_register_static_paths( + [StaticPathConfig("/something", str(Path(__file__).parent))] + ) client = await hass_client() resp = await client.options( diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 2937e673946..7858bbc123d 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -530,17 +530,14 @@ async def test_register_static_paths( """Test registering a static path with old api.""" assert await async_setup_component(hass, "frontend", {}) path = str(Path(__file__).parent) - hass.http.register_static_path("/something", path) - client = await hass_client() - resp = await client.get("/something/__init__.py") - assert resp.status == HTTPStatus.OK - assert ( + match_error = ( "Detected code that calls hass.http.register_static_path " - "which is deprecated because it does blocking I/O in the " - "event loop, instead call " + "which does blocking I/O in the event loop, instead call " "`await hass.http.async_register_static_paths" - ) in caplog.text + ) + with pytest.raises(RuntimeError, match=match_error): + hass.http.register_static_path("/something", path) async def test_ssl_issue_if_no_urls_configured( diff --git a/tests/components/kaleidescape/test_init.py b/tests/components/kaleidescape/test_init.py index 01769b9fc57..ed1a9981906 100644 --- a/tests/components/kaleidescape/test_init.py +++ b/tests/components/kaleidescape/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock import pytest -from homeassistant.components.kaleidescape.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -29,7 +28,6 @@ async def test_unload_config_entry( await hass.async_block_till_done() assert mock_device.disconnect.call_count == 1 - assert mock_config_entry.entry_id not in hass.data[DOMAIN] async def test_config_entry_not_ready( diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 7ddcdf38ed6..c8e23786e68 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -87,19 +87,16 @@ async def test_options(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 # fake router - hass.data.setdefault(keenetic.DOMAIN, {}) - hass.data[keenetic.DOMAIN][entry.entry_id] = { - keenetic.ROUTER: Mock( - client=Mock( - get_interfaces=Mock( - return_value=[ - InterfaceInfo.from_dict({"id": name, "type": "bridge"}) - for name in MOCK_OPTIONS[const.CONF_INTERFACES] - ] - ) + entry.runtime_data = Mock( + client=Mock( + get_interfaces=Mock( + return_value=[ + InterfaceInfo.from_dict({"id": name, "type": "bridge"}) + for name in MOCK_OPTIONS[const.CONF_INTERFACES] + ] ) ) - } + ) result = await hass.config_entries.options.async_init(entry.entry_id) diff --git a/tests/components/keyboard/test_init.py b/tests/components/keyboard/test_init.py index f590c9dd1a4..69355efd761 100644 --- a/tests/components/keyboard/test_init.py +++ b/tests/components/keyboard/test_init.py @@ -13,9 +13,7 @@ async def test_repair_issue_is_created( issue_registry: ir.IssueRegistry, ) -> None: """Test repair issue is created.""" - from homeassistant.components.keyboard import ( # pylint:disable=import-outside-toplevel - DOMAIN, - ) + from homeassistant.components.keyboard import DOMAIN # noqa: PLC0415 assert await async_setup_component( hass, diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index a54641a4234..541a9f781fd 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -4,7 +4,7 @@ import pytest from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.kodi import DOMAIN +from homeassistant.components.kodi.const import DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py index acd33f82a27..96cdc99144b 100644 --- a/tests/components/kostal_plenticore/test_helper.py +++ b/tests/components/kostal_plenticore/test_helper.py @@ -67,7 +67,7 @@ async def test_plenticore_async_setup_g1( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + plenticore = mock_config_entry.runtime_data assert plenticore.device_info == DeviceInfo( configuration_url="http://192.168.1.2", @@ -119,7 +119,7 @@ async def test_plenticore_async_setup_g2( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + plenticore = mock_config_entry.runtime_data assert plenticore.device_info == DeviceInfo( configuration_url="http://192.168.1.2", diff --git a/tests/components/lacrosse_view/test_diagnostics.py b/tests/components/lacrosse_view/test_diagnostics.py index 4306173c6b3..0796d3f27f5 100644 --- a/tests/components/lacrosse_view/test_diagnostics.py +++ b/tests/components/lacrosse_view/test_diagnostics.py @@ -5,7 +5,7 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.lacrosse_view import DOMAIN +from homeassistant.components.lacrosse_view.const import DOMAIN from homeassistant.core import HomeAssistant from . import MOCK_ENTRY_DATA, TEST_SENSOR diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 0533dd2abee..3691ee1c7ac 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -35,8 +35,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] - entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index e0dc1e5f35f..f0860f47b01 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import patch from lacrosse_view import Sensor import pytest -from homeassistant.components.lacrosse_view import DOMAIN +from homeassistant.components.lacrosse_view.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -46,7 +46,6 @@ async def test_entities_added(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -103,7 +102,6 @@ async def test_field_not_supported( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -144,7 +142,6 @@ async def test_field_types( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -172,7 +169,6 @@ async def test_no_field(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -200,7 +196,6 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -228,7 +223,6 @@ async def test_no_readings(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 58843d63f9a..d66908c1b1a 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -843,7 +843,7 @@ async def test_sky_effect(hass: HomeAssistant) -> None: SERVICE_EFFECT_SKY, { ATTR_ENTITY_ID: entity_id, - ATTR_PALETTE: [], + ATTR_PALETTE: None, ATTR_SKY_TYPE: "Clouds", ATTR_CLOUD_SATURATION_MAX: 180, ATTR_CLOUD_SATURATION_MIN: 50, @@ -854,7 +854,7 @@ async def test_sky_effect(hass: HomeAssistant) -> None: bulb.power_level = 65535 bulb.effect = { "effect": "SKY", - "palette": [], + "palette": None, "sky_type": 2, "cloud_saturation_min": 50, "cloud_saturation_max": 180, diff --git a/tests/components/lirc/test_init.py b/tests/components/lirc/test_init.py index 6a0747143df..7cc430d8dd0 100644 --- a/tests/components/lirc/test_init.py +++ b/tests/components/lirc/test_init.py @@ -13,9 +13,7 @@ async def test_repair_issue_is_created( issue_registry: ir.IssueRegistry, ) -> None: """Test repair issue is created.""" - from homeassistant.components.lirc import ( # pylint: disable=import-outside-toplevel - DOMAIN, - ) + from homeassistant.components.lirc import DOMAIN # noqa: PLC0415 assert await async_setup_component( hass, diff --git a/tests/components/matrix/test_login.py b/tests/components/matrix/test_login.py index ad9bf660402..0d72b914740 100644 --- a/tests/components/matrix/test_login.py +++ b/tests/components/matrix/test_login.py @@ -1,6 +1,7 @@ """Test MatrixBot._login.""" -from pydantic.dataclasses import dataclass +from dataclasses import dataclass + import pytest from homeassistant.components.matrix import MatrixBot @@ -17,7 +18,7 @@ class LoginTestParameters: access_token: dict[str, str] expected_login_state: bool expected_caplog_messages: set[str] - expected_expection: type(Exception) | None = None + expected_expection: type[Exception] | None = None good_password_missing_token = LoginTestParameters( diff --git a/tests/components/matter/fixtures/nodes/solar_power.json b/tests/components/matter/fixtures/nodes/solar_power.json index 4b7c4af5b43..1147ff202ca 100644 --- a/tests/components/matter/fixtures/nodes/solar_power.json +++ b/tests/components/matter/fixtures/nodes/solar_power.json @@ -243,7 +243,14 @@ "1/29/1": [3, 29, 47, 144, 145, 156], "1/29/2": [], "1/29/3": [], - "1/29/4": [], + "1/29/4": [ + { + "0": null, + "1": 15, + "2": 2, + "3": "Solar" + } + ], "1/29/65532": 0, "1/29/65533": 2, "1/29/65528": [], diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 4e63735a2d7..14169c84e15 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1360,7 +1360,7 @@ 'state': '100', }) # --- -# name: test_sensors[eve_contact_sensor][sensor.eve_door_voltage-entry] +# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1375,7 +1375,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.eve_door_voltage', + 'entity_id': 'sensor.eve_door_battery_voltage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1393,26 +1393,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Voltage', + 'original_name': 'Battery voltage', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'battery_voltage', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', 'unit_of_measurement': , }) # --- -# name: test_sensors[eve_contact_sensor][sensor.eve_door_voltage-state] +# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'Eve Door Voltage', + 'friendly_name': 'Eve Door Battery voltage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.eve_door_voltage', + 'entity_id': 'sensor.eve_door_battery_voltage', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1932,6 +1932,65 @@ 'state': '100', }) # --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_thermo_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Thermo Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_thermo_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.05', + }) +# --- # name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2037,65 +2096,6 @@ 'state': '10', }) # --- -# name: test_sensors[eve_thermo][sensor.eve_thermo_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.eve_thermo_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[eve_thermo][sensor.eve_thermo_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Eve Thermo Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.eve_thermo_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.05', - }) -# --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2149,6 +2149,65 @@ 'state': '100', }) # --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_weather_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Weather Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_weather_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.956', + }) +# --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2314,65 +2373,6 @@ 'state': '16.03', }) # --- -# name: test_sensors[eve_weather_sensor][sensor.eve_weather_voltage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.eve_weather_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[eve_weather_sensor][sensor.eve_weather_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Eve Weather Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.eve_weather_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.956', - }) -# --- # name: test_sensors[extractor_hood][sensor.mock_extractor_hood_activated_carbon_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5304,7 +5304,7 @@ 'state': 'CR123A', }) # --- -# name: test_sensors[smoke_detector][sensor.smoke_sensor_voltage-entry] +# name: test_sensors[smoke_detector][sensor.smoke_sensor_battery_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5319,7 +5319,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.smoke_sensor_voltage', + 'entity_id': 'sensor.smoke_sensor_battery_voltage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5337,26 +5337,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Voltage', + 'original_name': 'Battery voltage', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'battery_voltage', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', 'unit_of_measurement': , }) # --- -# name: test_sensors[smoke_detector][sensor.smoke_sensor_voltage-state] +# name: test_sensors[smoke_detector][sensor.smoke_sensor_battery_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'Smoke sensor Voltage', + 'friendly_name': 'Smoke sensor Battery voltage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.smoke_sensor_voltage', + 'entity_id': 'sensor.smoke_sensor_battery_voltage', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index e15e3f9f53e..e70101bf804 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -156,7 +156,7 @@ async def test_battery_sensor_voltage( matter_node: MatterNode, ) -> None: """Test battery voltage sensor.""" - entity_id = "sensor.eve_door_voltage" + entity_id = "sensor.eve_door_battery_voltage" state = hass.states.get(entity_id) assert state assert state.state == "3.558" diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index 8b56ee6a6de..40603344325 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -179,7 +179,7 @@ async def test_thermostat_set_invalid_hvac_mode( hass: HomeAssistant, cube: MaxCube, thermostat: MaxThermostat ) -> None: """Set hvac mode to heat.""" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 045fb99e181..00666e71d05 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -58,7 +58,6 @@ def create_llm_context() -> llm.LLMContext: return llm.LLMContext( platform="test_platform", context=Context(), - user_prompt="test_text", language="*", assistant="conversation", device_id=None, diff --git a/tests/components/meater/__init__.py b/tests/components/meater/__init__.py index ef96dafe88c..48d576ce79b 100644 --- a/tests/components/meater/__init__.py +++ b/tests/components/meater/__init__.py @@ -1 +1,13 @@ """Tests for the Meater integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/meater/conftest.py b/tests/components/meater/conftest.py new file mode 100644 index 00000000000..ccaa48437f3 --- /dev/null +++ b/tests/components/meater/conftest.py @@ -0,0 +1,80 @@ +"""Meater tests configuration.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, Mock, patch + +from meater.MeaterApi import MeaterCook, MeaterProbe +import pytest + +from homeassistant.components.meater.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import PROBE_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.meater.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_meater_client(mock_probe: Mock) -> Generator[AsyncMock]: + """Mock a Meater client.""" + with ( + patch( + "homeassistant.components.meater.coordinator.MeaterApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.meater.config_flow.MeaterApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_all_devices.return_value = [mock_probe] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Meater", + data={CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + unique_id="user@host.com", + ) + + +@pytest.fixture +def mock_cook() -> Mock: + """Mock a cook.""" + mock = Mock(spec=MeaterCook) + mock.id = "123123" + mock.name = "Whole chicken" + mock.state = "Started" + mock.target_temperature = 25.0 + mock.peak_temperature = 27.0 + mock.time_remaining = 32 + mock.time_elapsed = 32 + return mock + + +@pytest.fixture +def mock_probe(mock_cook: Mock) -> Mock: + """Mock a probe.""" + mock = Mock(spec=MeaterProbe) + mock.id = PROBE_ID + mock.internal_temperature = 26.0 + mock.ambient_temperature = 28.0 + mock.cook = mock_cook + mock.time_updated = datetime.fromisoformat("2025-06-16T13:53:51+00:00") + return mock diff --git a/tests/components/meater/const.py b/tests/components/meater/const.py new file mode 100644 index 00000000000..52ba9ac3feb --- /dev/null +++ b/tests/components/meater/const.py @@ -0,0 +1,3 @@ +"""Constants for the Meater tests.""" + +PROBE_ID = "40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58" diff --git a/tests/components/meater/snapshots/test_diagnostics.ambr b/tests/components/meater/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ced779eb114 --- /dev/null +++ b/tests/components/meater/snapshots/test_diagnostics.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58': dict({ + 'ambient_temperature': 28.0, + 'cook': dict({ + 'id': '123123', + 'name': 'Whole chicken', + 'peak_temperature': 27.0, + 'state': 'Started', + 'target_temperature': 25.0, + 'time_elapsed': 32, + 'time_remaining': 32, + }), + 'id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + 'internal_temperature': 26.0, + 'time_updated': '2025-06-16T13:53:51+00:00', + }), + }) +# --- diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr new file mode 100644 index 00000000000..582fd68efb1 --- /dev/null +++ b/tests/components/meater/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'meater', + '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Apption Labs', + 'model': 'Meater Probe', + 'model_id': None, + 'name': 'Meater Probe 40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/meater/snapshots/test_sensor.ambr b/tests/components/meater/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..aaec1db296a --- /dev/null +++ b/tests/components/meater/snapshots/test_sensor.ambr @@ -0,0 +1,435 @@ +# serializer version: 1 +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ambient', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-ambient', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_name', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Whole chicken', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_peak_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_peak_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_started', + 'configured', + 'started', + 'ready_for_resting', + 'resting', + 'slightly_underdone', + 'finished', + 'slightly_overdone', + 'overcooked', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_state', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'options': list([ + 'not_started', + 'configured', + 'started', + 'ready_for_resting', + 'resting', + 'slightly_underdone', + 'finished', + 'slightly_overdone', + 'overcooked', + ]), + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'started', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_target_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_target_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_elapsed', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_elapsed', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-20T23:59:28+00:00', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_remaining', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_remaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:32+00:00', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'internal', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-internal', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- diff --git a/tests/components/meater/test_config_flow.py b/tests/components/meater/test_config_flow.py index 9049cf4ac9a..c6704f2f3f7 100644 --- a/tests/components/meater/test_config_flow.py +++ b/tests/components/meater/test_config_flow.py @@ -1,12 +1,12 @@ """Define tests for the Meater config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from meater import AuthenticationError, ServiceUnavailableError import pytest -from homeassistant import config_entries from homeassistant.components.meater import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -14,132 +14,114 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -@pytest.fixture -def mock_client(): - """Define a fixture for authentication coroutine.""" - return AsyncMock(return_value=None) - - -@pytest.fixture -def mock_meater(mock_client): - """Mock the meater library.""" - with patch("homeassistant.components.meater.MeaterApi.authenticate") as mock_: - mock_.side_effect = mock_client - yield mock_ - - -async def test_duplicate_error(hass: HomeAssistant) -> None: - """Test that errors are shown when duplicates are added.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass( - hass - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.parametrize("mock_client", [AsyncMock(side_effect=Exception)]) -async def test_unknown_auth_error(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "unknown_auth_error"} - - -@pytest.mark.parametrize("mock_client", [AsyncMock(side_effect=AuthenticationError)]) -async def test_invalid_credentials(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "invalid_auth"} - - -@pytest.mark.parametrize( - "mock_client", [AsyncMock(side_effect=ServiceUnavailableError)] -) -async def test_service_unavailable(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "service_unavailable_error"} - - -async def test_user_flow(hass: HomeAssistant, mock_meater) -> None: +async def test_user_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_meater_client: AsyncMock +) -> None: """Test that the user flow works.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.meater.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123", } + assert result["result"].unique_id == "user@host.com" assert len(mock_setup_entry.mock_calls) == 1 - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.data == { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "password123", - } - -async def test_reauth_flow(hass: HomeAssistant, mock_meater) -> None: - """Test that the reauth flow works.""" - data = { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "password123", - } - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id="user@host.com", - data=data, +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthenticationError, "invalid_auth"), + (ServiceUnavailableError, "service_unavailable_error"), + (Exception, "unknown_auth_error"), + ], +) +async def test_user_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_meater_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test that an invalid API/App Key throws an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_meater_client.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_meater_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_meater_client: AsyncMock, +) -> None: + """Test that errors are shown when duplicates are added.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_meater_client: AsyncMock, +) -> None: + """Test that the reauth flow works.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result["errors"] is None + assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"password": "passwordabc"}, + {CONF_PASSWORD: "passwordabc"}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.data == { + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { CONF_USERNAME: "user@host.com", CONF_PASSWORD: "passwordabc", } diff --git a/tests/components/meater/test_diagnostics.py b/tests/components/meater/test_diagnostics.py new file mode 100644 index 00000000000..9d78828a92f --- /dev/null +++ b/tests/components/meater/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test Meater diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/meater/test_init.py b/tests/components/meater/test_init.py new file mode 100644 index 00000000000..52f6b29d488 --- /dev/null +++ b/tests/components/meater/test_init.py @@ -0,0 +1,28 @@ +"""Tests for the Meater integration.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.meater.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import PROBE_ID + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, PROBE_ID)}) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/meater/test_sensor.py b/tests/components/meater/test_sensor.py new file mode 100644 index 00000000000..8ddd5fbb590 --- /dev/null +++ b/tests/components/meater/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Meater sensors.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.freeze_time("2023-10-21") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor entities.""" + with patch("homeassistant.components.meater.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 090ea9f27e2..2e270eb3b2e 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -152,7 +152,9 @@ def test_support_properties(hass: HomeAssistant, property_suffix: str) -> None: entity4 = MediaPlayerEntity() entity4.hass = hass entity4.platform = MockEntityPlatform(hass) - entity4._attr_supported_features = all_features - feature + entity4._attr_supported_features = media_player.MediaPlayerEntityFeature( + all_features - feature + ) assert getattr(entity1, f"support_{property_suffix}") is False assert getattr(entity2, f"support_{property_suffix}") is True @@ -652,27 +654,3 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) - - -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockMediaPlayerEntity(MediaPlayerEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockMediaPlayerEntity() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert entity.supported_features_compat is MediaPlayerEntityFeature(1) - assert "MockMediaPlayerEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "MediaPlayerEntityFeature.PAUSE" in caplog.text - caplog.clear() - assert entity.supported_features_compat is MediaPlayerEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index fd0b95f2b13..568fb7ea39d 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -405,13 +405,6 @@ async def test_turn_on_and_off_optimistic_with_power_command( "heat", None, ), - ( - help_custom_config( - climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["off", "dry"]},) - ), - None, - "off", - ), ( help_custom_config( climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["off", "cool"]},) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index af9975de1ea..f789d7f3be1 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -683,11 +683,9 @@ async def test_receiving_message_with_non_utf8_topic_gets_logged( # Local import to avoid processing MQTT modules when running a testcase # which does not use MQTT. - # pylint: disable-next=import-outside-toplevel - from paho.mqtt.client import MQTTMessage + from paho.mqtt.client import MQTTMessage # noqa: PLC0415 - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.mqtt.models import MqttData + from homeassistant.components.mqtt.models import MqttData # noqa: PLC0415 msg = MQTTMessage(topic=b"tasmota/discovery/18FE34E0B760\xcc\x02") msg.payload = b"Payload" @@ -1001,10 +999,9 @@ async def test_dump_service( async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() - writes = mopen.return_value.write.mock_calls - assert len(writes) == 2 - assert writes[0][1][0] == "bla/1,test1\n" - assert writes[1][1][0] == "bla/2,test2\n" + writes = mopen.return_value.writelines.mock_calls + assert len(writes) == 1 + assert writes[0][1][0] == ["bla/1,test1\n", "bla/2,test2\n"] async def test_mqtt_ws_remove_discovered_device( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index ea1b7e186e2..997c014cd13 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -898,42 +898,12 @@ async def test_invalid_unit_of_measurement( "The unit of measurement `ppm` is not valid together with device class `energy`" in caplog.text ) - # A repair issue was logged + # A repair issue was logged for the failing YAML config assert len(events) == 1 - assert events[0].data["issue_id"] == "sensor.test" - # Assert the sensor works - async_fire_mqtt_message(hass, "test-topic", "100") - await hass.async_block_till_done() + assert events[0].data["domain"] == mqtt.DOMAIN + # Assert the sensor is not created state = hass.states.get("sensor.test") - assert state is not None - assert state.state == "100" - - caplog.clear() - - discovery_payload = { - "name": "bla", - "state_topic": "test-topic2", - "device_class": "temperature", - "unit_of_measurement": "C", - } - # Now discover an other invalid sensor - async_fire_mqtt_message( - hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) - ) - await hass.async_block_till_done() - assert ( - "The unit of measurement `C` is not valid together with device class `temperature`" - in caplog.text - ) - # Assert the sensor works - async_fire_mqtt_message(hass, "test-topic2", "21") - await hass.async_block_till_done() - state = hass.states.get("sensor.bla") - assert state is not None - assert state.state == "21" - - # No new issue was registered for the discovered entity - assert len(events) == 1 + assert state is None @pytest.mark.parametrize( diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index fe148c2529d..76a9a52f2de 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -520,7 +520,7 @@ async def test_thermostat_invalid_hvac_mode( assert thermostat.state == HVACMode.OFF assert thermostat.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_hvac_mode(hass, HVACMode.DRY) assert thermostat.state == HVACMode.OFF @@ -1396,7 +1396,7 @@ async def test_thermostat_unexpected_hvac_status( assert ATTR_FAN_MODE not in thermostat.attributes assert ATTR_FAN_MODES not in thermostat.attributes - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_hvac_mode(hass, HVACMode.DRY) assert thermostat.state == HVACMode.OFF diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index 47b65772a24..9357163f72a 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -55,8 +55,7 @@ async def fixture_mock_connection(mock_connection_construct): @pytest.fixture(name="coils") async def fixture_coils(mock_connection: MockConnection): """Return a dict with coil data.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.nibe_heatpump import HeatPump + from homeassistant.components.nibe_heatpump import HeatPump # noqa: PLC0415 get_coils_original = HeatPump.get_coils get_coil_by_address_original = HeatPump.get_coil_by_address diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 91245503eb3..a9620b5ddb3 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -297,7 +297,6 @@ async def test_set_temperature_unsupported_cooling( [ (Model.S320, "s1", "climate.climate_system_s1"), (Model.F1155, "s2", "climate.climate_system_s2"), - (Model.F730, "s1", "climate.climate_system_s1"), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 2d3656536a9..48909552e08 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -498,3 +498,236 @@ async def test_flow_reauth_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "account_mismatch" + + +@pytest.mark.parametrize( + ("entry_data", "user_input", "step_id"), + [ + ( + {CONF_USERNAME: None, CONF_TOKEN: None}, + {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + "reconfigure", + ), + ( + {CONF_USERNAME: "username", CONF_TOKEN: "oldtoken"}, + {CONF_TOKEN: "newtoken"}, + "reconfigure_user", + ), + ], +) +async def test_flow_reconfigure( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + entry_data: dict[str, str | None], + user_input: dict[str, str], + step_id: str, +) -> None: + """Test reconfigure flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + **entry_data, + }, + ) + mock_aiontfy.generate_token.return_value = AccountTokenResponse( + token="newtoken", last_access=datetime.now() + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == step_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_USERNAME] == "username" + assert config_entry.data[CONF_TOKEN] == "newtoken" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("entry_data", "step_id"), + [ + ({CONF_USERNAME: None, CONF_TOKEN: None}, "reconfigure"), + ({CONF_USERNAME: "username", CONF_TOKEN: "oldtoken"}, "reconfigure_user"), + ], +) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reconfigure_token( + hass: HomeAssistant, + entry_data: dict[str, Any], + step_id: str, +) -> None: + """Test reconfigure flow with access token.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + **entry_data, + }, + ) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == step_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "access_token"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_USERNAME] == "username" + assert config_entry.data[CONF_TOKEN] == "access_token" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + "invalid_auth", + ), + (NtfyException, "cannot_connect"), + (TypeError, "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: None, + CONF_TOKEN: None, + }, + ) + mock_aiontfy.generate_token.return_value = AccountTokenResponse( + token="newtoken", last_access=datetime.now() + ) + mock_aiontfy.account.side_effect = exception + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.account.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_USERNAME] == "username" + assert config_entry.data[CONF_TOKEN] == "newtoken" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow already configured.""" + other_config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + }, + ) + other_config_entry.add_to_hass(hass) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(hass.config_entries.async_entries()) == 2 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reconfigure_account_mismatch( + hass: HomeAssistant, +) -> None: + """Test reconfigure flow account mismatch.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "wrong_username", + CONF_TOKEN: "oldtoken", + }, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "newtoken"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "account_mismatch" diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 8e54018a14d..e83c2a3495f 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -284,7 +284,6 @@ async def test_function_call( llm.LLMContext( platform="ollama", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, @@ -369,7 +368,6 @@ async def test_function_exception( llm.LLMContext( platform="ollama", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 9cf27b4f147..ad5bbffaed3 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -27,7 +27,6 @@ from homeassistant.components.openai_conversation.const import ( DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, - RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TOP_P, ) from homeassistant.const import CONF_LLM_HASS_API @@ -77,10 +76,10 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_options( +async def test_options_recommended( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options form.""" + """Test the options flow with recommended settings.""" options_flow = await hass.config_entries.options.async_init( mock_config_entry.entry_id ) @@ -88,14 +87,12 @@ async def test_options( options_flow["flow_id"], { "prompt": "Speak like a pirate", - "max_tokens": 200, + "recommended": True, }, ) await hass.async_block_till_done() assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"]["prompt"] == "Speak like a pirate" - assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL async def test_options_unsupported_model( @@ -105,18 +102,32 @@ async def test_options_unsupported_model( options_flow = await hass.config_entries.options.async_init( mock_config_entry.entry_id ) - result = await hass.config_entries.options.async_configure( + assert options_flow["type"] == FlowResultType.FORM + assert options_flow["step_id"] == "init" + + # Configure initial step + options_flow = await hass.config_entries.options.async_configure( options_flow["flow_id"], { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", - CONF_CHAT_MODEL: "o1-mini", CONF_LLM_HASS_API: ["assist"], }, ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"chat_model": "model_not_supported"} + assert options_flow["type"] == FlowResultType.FORM + assert options_flow["step_id"] == "advanced" + + # Configure advanced step + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_CHAT_MODEL: "o1-mini", + }, + ) + await hass.async_block_till_done() + assert options_flow["type"] is FlowResultType.FORM + assert options_flow["errors"] == {"chat_model": "model_not_supported"} @pytest.mark.parametrize( @@ -165,70 +176,322 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non @pytest.mark.parametrize( ("current_options", "new_options", "expected_options"), [ - ( - { - CONF_RECOMMENDED: True, - CONF_PROMPT: "bla", - }, - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 0.3, - }, - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 0.3, - CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, - CONF_WEB_SEARCH: False, - CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", - CONF_WEB_SEARCH_USER_LOCATION: False, - }, - ), - ( - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 0.3, - CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, - CONF_WEB_SEARCH: False, - CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", - CONF_WEB_SEARCH_USER_LOCATION: False, - }, - { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: ["assist"], - CONF_PROMPT: "", - }, - { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: ["assist"], - CONF_PROMPT: "", - }, - ), - ( + ( # Test converting single llm api format to list { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: "assist", CONF_PROMPT: "", }, + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, + ), + ( # options with no model-specific settings + {}, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "gpt-4.5-preview", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "gpt-4.5-preview", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + ), + ( # options for reasoning models + {}, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: 10000, + }, + { + CONF_REASONING_EFFORT: "high", + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: 10000, + CONF_REASONING_EFFORT: "high", + }, + ), + ( # options for web search without user location + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "bla", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + # Test that current options are showed as suggested values + ( # Case 1: web search + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + ( # Case 2: reasoning model + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pro", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "high", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pro", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + {CONF_REASONING_EFFORT: "high"}, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pro", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "high", + }, + ), + # Test that old options are removed after reconfiguration + ( # Case 1: web search to recommended + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, ), + ( # Case 2: reasoning to recommended + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "high", + }, + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "Speak like a pirate", + }, + ), + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "Speak like a pirate", + }, + ), + ( # Case 3: web search to reasoning + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o3-mini", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_REASONING_EFFORT: "low", + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o3-mini", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "low", + }, + ), + ( # Case 4: reasoning to web search + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o3-mini", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "low", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), ], ) async def test_options_switching( @@ -241,22 +504,31 @@ async def test_options_switching( ) -> None: """Test the options form.""" hass.config_entries.async_update_entry(mock_config_entry, options=current_options) - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - **current_options, - CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], - }, + options = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert options["step_id"] == "init" + + for step_options in new_options: + assert options["type"] == FlowResultType.FORM + + # Test that current options are showed as suggested values: + for key in options["data_schema"].schema: + if ( + isinstance(key.description, dict) + and "suggested_value" in key.description + and key in current_options + ): + current_option = current_options[key] + if key == CONF_LLM_HASS_API and isinstance(current_option, str): + current_option = [current_option] + assert key.description["suggested_value"] == current_option + + # Configure current step + options = await hass.config_entries.options.async_configure( + options["flow_id"], + step_options, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - new_options, - ) - await hass.async_block_till_done() + await hass.async_block_till_done() + assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"] == expected_options @@ -265,9 +537,35 @@ async def test_options_web_search_user_location( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: """Test fetching user location.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + options = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert options["type"] == FlowResultType.FORM + assert options["step_id"] == "init" + + # Configure initial step + options = await hass.config_entries.options.async_configure( + options["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, ) + assert options["type"] == FlowResultType.FORM + assert options["step_id"] == "advanced" + + # Configure advanced step + options = await hass.config_entries.options.async_configure( + options["flow_id"], + { + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + ) + await hass.async_block_till_done() + assert options["type"] == FlowResultType.FORM + assert options["step_id"] == "model" + hass.config.country = "US" hass.config.time_zone = "America/Los_Angeles" hass.states.async_set( @@ -302,16 +600,10 @@ async def test_options_web_search_user_location( ], ) + # Configure model step options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], + options["flow_id"], { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 1.0, - CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -330,7 +622,6 @@ async def test_options_web_search_user_location( CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -339,25 +630,3 @@ async def test_options_web_search_user_location( CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", } - - -async def test_options_web_search_unsupported_model( - hass: HomeAssistant, mock_config_entry, mock_init_component -) -> None: - """Test the options form giving error about web search not being available.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - result = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_CHAT_MODEL: "o1-pro", - CONF_LLM_HASS_API: ["assist"], - CONF_WEB_SEARCH: True, - }, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"web_search": "web_search_not_supported"} diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py index dc0f06d2a56..7a3545620ac 100644 --- a/tests/components/panel_custom/test_init.py +++ b/tests/components/panel_custom/test_init.py @@ -1,7 +1,5 @@ """The tests for the panel_custom component.""" -from unittest.mock import Mock, patch - from homeassistant import setup from homeassistant.components import frontend, panel_custom from homeassistant.core import HomeAssistant @@ -22,14 +20,13 @@ async def test_webcomponent_custom_path_not_found(hass: HomeAssistant) -> None: } } - with patch("os.path.isfile", Mock(return_value=False)): - result = await setup.async_setup_component(hass, "panel_custom", config) - assert not result + result = await setup.async_setup_component(hass, "panel_custom", config) + assert not result - panels = hass.data.get(frontend.DATA_PANELS, []) + panels = hass.data.get(frontend.DATA_PANELS, []) - assert panels - assert "nice_url" not in panels + assert panels + assert "nice_url" not in panels async def test_js_webcomponent(hass: HomeAssistant) -> None: diff --git a/tests/components/paperless_ngx/const.py b/tests/components/paperless_ngx/const.py index addfd54a001..36f62b507dd 100644 --- a/tests/components/paperless_ngx/const.py +++ b/tests/components/paperless_ngx/const.py @@ -1,15 +1,17 @@ """Constants for the Paperless NGX integration tests.""" -from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL USER_INPUT_ONE = { CONF_URL: "https://192.168.69.16:8000", CONF_API_KEY: "12345678", + CONF_VERIFY_SSL: True, } USER_INPUT_TWO = { CONF_URL: "https://paperless.example.de", CONF_API_KEY: "87654321", + CONF_VERIFY_SSL: True, } USER_INPUT_REAUTH = {CONF_API_KEY: "192837465"} diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index c84322e1c14..8a6dceb1e47 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -17,7 +17,6 @@ from homeassistant.components.plex.const import ( PLEX_SERVER_CONFIG, PLEX_URI_SCHEME, SERVICE_REFRESH_LIBRARY, - SERVICE_SCAN_CLIENTS, ) from homeassistant.components.plex.services import process_plex_payload from homeassistant.const import CONF_URL @@ -107,15 +106,6 @@ async def test_refresh_library( assert refresh.call_count == 1 -async def test_scan_clients(hass: HomeAssistant, mock_plex_server) -> None: - """Test scan_for_clients service call.""" - await hass.services.async_call( - DOMAIN, - SERVICE_SCAN_CLIENTS, - blocking=True, - ) - - async def test_lookup_media_for_other_integrations( hass: HomeAssistant, entry, diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 7a481285be0..3787cbf7150 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -242,7 +242,10 @@ async def test_adam_climate_entity_climate_changes( "c50f167537524366a5af7aa3942feb1e", HVACMode.OFF ) - with pytest.raises(ServiceValidationError, match="valid modes are"): + with pytest.raises( + ServiceValidationError, + match="HVAC mode dry is not valid. Valid HVAC modes are: auto, heat", + ): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a2155ba00eb..0ca5612f8fd 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -62,6 +62,108 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +def _init_host_mock(host_mock: MagicMock) -> None: + host_mock.get_host_data = AsyncMock(return_value=None) + host_mock.get_states = AsyncMock(return_value=None) + host_mock.get_state = AsyncMock() + host_mock.check_new_firmware = AsyncMock(return_value=False) + host_mock.subscribe = AsyncMock() + host_mock.unsubscribe = AsyncMock(return_value=True) + host_mock.logout = AsyncMock(return_value=True) + host_mock.reboot = AsyncMock() + host_mock.set_ptz_command = AsyncMock() + host_mock.get_motion_state_all_ch = AsyncMock(return_value=False) + host_mock.get_stream_source = AsyncMock() + host_mock.get_snapshot = AsyncMock() + host_mock.get_encoding = AsyncMock(return_value="h264") + host_mock.ONVIF_event_callback = AsyncMock() + host_mock.is_nvr = True + host_mock.is_hub = False + host_mock.mac_address = TEST_MAC + host_mock.uid = TEST_UID + host_mock.onvif_enabled = True + host_mock.rtmp_enabled = True + host_mock.rtsp_enabled = True + host_mock.nvr_name = TEST_NVR_NAME + host_mock.port = TEST_PORT + host_mock.use_https = TEST_USE_HTTPS + host_mock.is_admin = True + host_mock.user_level = "admin" + host_mock.protocol = "rtsp" + host_mock.channels = [0] + host_mock.stream_channels = [0] + host_mock.new_devices = False + host_mock.sw_version_update_required = False + host_mock.hardware_version = "IPC_00000" + host_mock.sw_version = "v1.0.0.0.0.0000" + host_mock.sw_upload_progress.return_value = 100 + host_mock.manufacturer = "Reolink" + host_mock.model = TEST_HOST_MODEL + host_mock.supported.return_value = True + host_mock.item_number.return_value = TEST_ITEM_NUMBER + host_mock.camera_model.return_value = TEST_CAM_MODEL + host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_hardware_version.return_value = "IPC_00001" + host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" + host_mock.camera_sw_version_update_required.return_value = False + host_mock.camera_uid.return_value = TEST_UID_CAM + host_mock.camera_online.return_value = True + host_mock.channel_for_uid.return_value = 0 + host_mock.firmware_update_available.return_value = False + host_mock.session_active = True + host_mock.timeout = 60 + host_mock.renewtimer.return_value = 600 + host_mock.wifi_connection = False + host_mock.wifi_signal = None + host_mock.whiteled_mode_list.return_value = [] + host_mock.zoom_range.return_value = { + "zoom": {"pos": {"min": 0, "max": 100}}, + "focus": {"pos": {"min": 0, "max": 100}}, + } + host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} + host_mock.checked_api_versions = {"GetEvents": 1} + host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) + + # enums + host_mock.whiteled_mode.return_value = 1 + host_mock.whiteled_mode_list.return_value = ["off", "auto"] + host_mock.doorbell_led.return_value = "Off" + host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] + host_mock.auto_track_method.return_value = 3 + host_mock.daynight_state.return_value = "Black&White" + host_mock.hub_alarm_tone_id.return_value = 1 + host_mock.hub_visitor_tone_id.return_value = 1 + host_mock.recording_packing_time_list = ["30 Minutes", "60 Minutes"] + host_mock.recording_packing_time = "60 Minutes" + + # Baichuan + host_mock.baichuan_only = False + # Disable tcp push by default for tests + host_mock.baichuan.port = TEST_BC_PORT + host_mock.baichuan.events_active = False + host_mock.baichuan.subscribe_events = AsyncMock() + host_mock.baichuan.unsubscribe_events = AsyncMock() + host_mock.baichuan.check_subscribe_events = AsyncMock() + host_mock.baichuan.get_privacy_mode = AsyncMock() + host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM + host_mock.baichuan.privacy_mode.return_value = False + host_mock.baichuan.day_night_state.return_value = "day" + host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.active_scene = "off" + host_mock.baichuan.scene_names = ["off", "home"] + host_mock.baichuan.abilities = { + 0: {"chnID": 0, "aitype": 34615}, + "Host": {"pushAlarm": 7}, + } + host_mock.baichuan.smart_location_list.return_value = [0] + host_mock.baichuan.smart_ai_type_list.return_value = ["people"] + host_mock.baichuan.smart_ai_index.return_value = 1 + host_mock.baichuan.smart_ai_name.return_value = "zone1" + + @pytest.fixture(scope="module") def reolink_connect_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" @@ -71,96 +173,8 @@ def reolink_connect_class() -> Generator[MagicMock]: ) as host_mock_class, ): host_mock = host_mock_class.return_value - host_mock.get_host_data.return_value = None - host_mock.get_states.return_value = None - host_mock.supported.return_value = True - host_mock.check_new_firmware.return_value = False - host_mock.unsubscribe.return_value = True - host_mock.logout.return_value = True - host_mock.is_nvr = True - host_mock.is_hub = False - host_mock.mac_address = TEST_MAC - host_mock.uid = TEST_UID - host_mock.onvif_enabled = True - host_mock.rtmp_enabled = True - host_mock.rtsp_enabled = True - host_mock.nvr_name = TEST_NVR_NAME - host_mock.port = TEST_PORT - host_mock.use_https = TEST_USE_HTTPS - host_mock.is_admin = True - host_mock.user_level = "admin" - host_mock.protocol = "rtsp" - host_mock.channels = [0] - host_mock.stream_channels = [0] - host_mock.new_devices = False - host_mock.sw_version_update_required = False - host_mock.hardware_version = "IPC_00000" - host_mock.sw_version = "v1.0.0.0.0.0000" - host_mock.sw_upload_progress.return_value = 100 - host_mock.manufacturer = "Reolink" - host_mock.model = TEST_HOST_MODEL - host_mock.item_number = TEST_ITEM_NUMBER - host_mock.camera_model.return_value = TEST_CAM_MODEL - host_mock.camera_name.return_value = TEST_NVR_NAME - host_mock.camera_hardware_version.return_value = "IPC_00001" - host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" - host_mock.camera_sw_version_update_required.return_value = False - host_mock.camera_uid.return_value = TEST_UID_CAM - host_mock.camera_online.return_value = True - host_mock.channel_for_uid.return_value = 0 - host_mock.get_encoding.return_value = "h264" - host_mock.firmware_update_available.return_value = False - host_mock.session_active = True - host_mock.timeout = 60 - host_mock.renewtimer.return_value = 600 - host_mock.wifi_connection = False - host_mock.wifi_signal = None - host_mock.whiteled_mode_list.return_value = [] - host_mock.zoom_range.return_value = { - "zoom": {"pos": {"min": 0, "max": 100}}, - "focus": {"pos": {"min": 0, "max": 100}}, - } - host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} - host_mock.checked_api_versions = {"GetEvents": 1} - host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} - host_mock.get_raw_host_data.return_value = ( - "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" - ) - - reolink_connect.chime_list = [] - - # enums - host_mock.whiteled_mode.return_value = 1 - host_mock.whiteled_mode_list.return_value = ["off", "auto"] - host_mock.doorbell_led.return_value = "Off" - host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] - host_mock.auto_track_method.return_value = 3 - host_mock.daynight_state.return_value = "Black&White" - host_mock.hub_alarm_tone_id.return_value = 1 - host_mock.hub_visitor_tone_id.return_value = 1 - host_mock.recording_packing_time_list = ["30 Minutes", "60 Minutes"] - host_mock.recording_packing_time = "60 Minutes" - - # Baichuan host_mock.baichuan = create_autospec(Baichuan) - # Disable tcp push by default for tests - host_mock.baichuan.port = TEST_BC_PORT - host_mock.baichuan.events_active = False - host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM - host_mock.baichuan.privacy_mode.return_value = False - host_mock.baichuan.day_night_state.return_value = "day" - host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") - host_mock.baichuan.active_scene = "off" - host_mock.baichuan.scene_names = ["off", "home"] - host_mock.baichuan.abilities = { - 0: {"chnID": 0, "aitype": 34615}, - "Host": {"pushAlarm": 7}, - } - host_mock.baichuan.smart_location_list.return_value = [0] - host_mock.baichuan.smart_ai_type_list.return_value = ["people"] - host_mock.baichuan.smart_ai_index.return_value = 1 - host_mock.baichuan.smart_ai_name.return_value = "zone1" - + _init_host_mock(host_mock) yield host_mock_class @@ -172,6 +186,18 @@ def reolink_connect( return reolink_connect_class.return_value +@pytest.fixture +def reolink_host() -> Generator[MagicMock]: + """Mock reolink Host class.""" + with patch( + "homeassistant.components.reolink.host.Host", autospec=False + ) as host_mock_class: + host_mock = host_mock_class.return_value + host_mock.baichuan = MagicMock() + _init_host_mock(host_mock) + yield host_mock + + @pytest.fixture def reolink_platforms() -> Generator[None]: """Mock reolink entry setup.""" @@ -224,3 +250,26 @@ def test_chime(reolink_connect: MagicMock) -> None: reolink_connect.chime_list = [TEST_CHIME] reolink_connect.chime.return_value = TEST_CHIME return TEST_CHIME + + +@pytest.fixture +def reolink_chime(reolink_host: MagicMock) -> None: + """Mock a reolink chime.""" + TEST_CHIME = Chime( + host=reolink_host, + dev_id=12345678, + channel=0, + ) + TEST_CHIME.name = "Test chime" + TEST_CHIME.volume = 3 + TEST_CHIME.connect_state = 2 + TEST_CHIME.led_state = True + TEST_CHIME.event_info = { + "md": {"switch": 0, "musicId": 0}, + "people": {"switch": 0, "musicId": 1}, + "visitor": {"switch": 1, "musicId": 2}, + } + + reolink_host.chime_list = [TEST_CHIME] + reolink_host.chime.return_value = TEST_CHIME + return TEST_CHIME diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 5eb80d16356..a6d7f14a149 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -10,6 +10,8 @@ 'pushAlarm': 7, }), }), + 'Baichuan only': False, + 'Baichuan port': 5678, 'Chimes': dict({ '12345678': dict({ 'channel': 0, @@ -62,10 +64,18 @@ 0, ]), 'cmd list': dict({ + '208': dict({ + '0': 1, + 'null': 1, + }), '296': dict({ '0': 1, 'null': 1, }), + '299': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, @@ -138,6 +148,10 @@ '0': 1, 'null': 1, }), + 'GetMask': dict({ + '0': 1, + 'null': 1, + }), 'GetMdAlarm': dict({ '0': 1, 'null': 1, diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 99c9efba002..e6275a2108e 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -21,11 +21,11 @@ async def test_motion_sensor( hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test binary sensor entity with motion sensor.""" - reolink_connect.model = TEST_DUO_MODEL - reolink_connect.motion_detected.return_value = True + reolink_host.model = TEST_DUO_MODEL + reolink_host.motion_detected.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -34,7 +34,7 @@ async def test_motion_sensor( entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion_lens_0" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.motion_detected.return_value = False + reolink_host.motion_detected.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -42,8 +42,8 @@ async def test_motion_sensor( assert hass.states.get(entity_id).state == STATE_OFF # test ONVIF webhook callback - reolink_connect.motion_detected.return_value = True - reolink_connect.ONVIF_event_callback.return_value = [0] + reolink_host.motion_detected.return_value = True + reolink_host.ONVIF_event_callback.return_value = [0] webhook_id = config_entry.runtime_data.host.webhook_id client = await hass_client_no_auth() await client.post(f"/api/webhook/{webhook_id}", data="test_data") @@ -56,11 +56,11 @@ async def test_smart_ai_sensor( hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test smart ai binary sensor entity.""" - reolink_connect.model = TEST_HOST_MODEL - reolink_connect.baichuan.smart_ai_state.return_value = True + reolink_host.model = TEST_HOST_MODEL + reolink_host.baichuan.smart_ai_state.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -69,7 +69,7 @@ async def test_smart_ai_sensor( entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_crossline_zone1_person" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.baichuan.smart_ai_state.return_value = False + reolink_host.baichuan.smart_ai_state.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -80,7 +80,7 @@ async def test_smart_ai_sensor( async def test_tcp_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test tcp callback using motion sensor.""" @@ -95,11 +95,11 @@ async def test_tcp_callback( callback_mock = callback_mock_class() - reolink_connect.model = TEST_HOST_MODEL - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) - reolink_connect.baichuan.register_callback = callback_mock.register_callback - reolink_connect.motion_detected.return_value = True + reolink_host.model = TEST_HOST_MODEL + reolink_host.baichuan.events_active = True + reolink_host.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.register_callback = callback_mock.register_callback + reolink_host.motion_detected.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -110,7 +110,7 @@ async def test_tcp_callback( assert hass.states.get(entity_id).state == STATE_ON # simulate a TCP push callback - reolink_connect.motion_detected.return_value = False + reolink_host.motion_detected.return_value = False assert callback_mock.callback_func is not None callback_mock.callback_func() diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index 126fbb6b29a..ee51d0f0b99 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -21,7 +21,7 @@ from tests.common import MockConfigEntry async def test_button( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test button entity with ptz up.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -37,9 +37,9 @@ async def test_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_ptz_command.assert_called_once() + reolink_host.set_ptz_command.assert_called_once() - reolink_connect.set_ptz_command.side_effect = ReolinkError("Test error") + reolink_host.set_ptz_command.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( BUTTON_DOMAIN, @@ -48,13 +48,11 @@ async def test_button( blocking=True, ) - reolink_connect.set_ptz_command.reset_mock(side_effect=True) - async def test_ptz_move_service( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test ptz_move entity service using PTZ button entity.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -70,9 +68,9 @@ async def test_ptz_move_service( {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 5}, blocking=True, ) - reolink_connect.set_ptz_command.assert_called_with(0, command="Up", speed=5) + reolink_host.set_ptz_command.assert_called_with(0, command="Up", speed=5) - reolink_connect.set_ptz_command.side_effect = ReolinkError("Test error") + reolink_host.set_ptz_command.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -81,14 +79,12 @@ async def test_ptz_move_service( blocking=True, ) - reolink_connect.set_ptz_command.reset_mock(side_effect=True) - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_host_button( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host button entity with reboot.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -104,9 +100,9 @@ async def test_host_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.reboot.assert_called_once() + reolink_host.reboot.assert_called_once() - reolink_connect.reboot.side_effect = ReolinkError("Test error") + reolink_host.reboot.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( BUTTON_DOMAIN, @@ -114,5 +110,3 @@ async def test_host_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - reolink_connect.reboot.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 4f18f769e02..4ab43de225f 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -25,7 +25,7 @@ async def test_camera( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test camera entity with fluent.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): @@ -37,28 +37,26 @@ async def test_camera( assert hass.states.get(entity_id).state == CameraState.IDLE # check getting a image from the camera - reolink_connect.get_snapshot.return_value = b"image" + reolink_host.get_snapshot.return_value = b"image" assert (await async_get_image(hass, entity_id)).content == b"image" - reolink_connect.get_snapshot.side_effect = ReolinkError("Test error") + reolink_host.get_snapshot.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await async_get_image(hass, entity_id) # check getting the stream source assert await async_get_stream_source(hass, entity_id) is not None - reolink_connect.get_snapshot.reset_mock(side_effect=True) - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_no_stream_source( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test camera entity with no stream source.""" - reolink_connect.model = TEST_DUO_MODEL - reolink_connect.get_stream_source.return_value = None + reolink_host.model = TEST_DUO_MODEL + reolink_host.get_stream_source.return_value = None with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index d45163d3cf0..b347bae9ec0 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -15,8 +15,8 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index c777e4064f0..f997a1ac08a 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -115,9 +115,11 @@ async def test_webhook_callback( assert hass.states.get(entity_id).state == STATE_OFF # test webhook callback success all channels + reolink_connect.get_motion_state_all_ch.return_value = True reolink_connect.motion_detected.return_value = True reolink_connect.ONVIF_event_callback.return_value = None await client.post(f"/api/webhook/{webhook_id}") + await hass.async_block_till_done() signal_all.assert_called_once() assert hass.states.get(entity_id).state == STATE_ON @@ -129,6 +131,7 @@ async def test_webhook_callback( signal_all.reset_mock() reolink_connect.get_motion_state_all_ch.return_value = False await client.post(f"/api/webhook/{webhook_id}") + await hass.async_block_till_done() signal_all.assert_not_called() assert hass.states.get(entity_id).state == STATE_ON @@ -137,6 +140,7 @@ async def test_webhook_callback( reolink_connect.motion_detected.return_value = False reolink_connect.ONVIF_event_callback.return_value = [0] await client.post(f"/api/webhook/{webhook_id}", data="test_data") + await hass.async_block_till_done() signal_ch.assert_called_once() assert hass.states.get(entity_id).state == STATE_OFF @@ -144,6 +148,7 @@ async def test_webhook_callback( signal_ch.reset_mock() reolink_connect.ONVIF_event_callback.side_effect = Exception("Test error") await client.post(f"/api/webhook/{webhook_id}", data="test_data") + await hass.async_block_till_done() signal_ch.assert_not_called() # test failure to read date from webhook post diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 86c4ed861a1..ed71314e961 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -69,7 +69,7 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") +pytestmark = pytest.mark.usefixtures("reolink_host", "reolink_platforms") CHIME_MODEL = "Reolink Chime" @@ -116,15 +116,14 @@ async def test_wait(*args, **key_args) -> None: ) async def test_failures_parametrized( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, attr: str, value: Any, expected: ConfigEntryState, ) -> None: """Test outcomes when changing errors.""" - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + setattr(reolink_host, attr, value) assert await hass.config_entries.async_setup(config_entry.entry_id) is ( expected is ConfigEntryState.LOADED ) @@ -132,17 +131,15 @@ async def test_failures_parametrized( assert config_entry.state == expected - setattr(reolink_connect, attr, original) - async def test_firmware_error_twice( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test when the firmware update fails 2 times.""" - reolink_connect.check_new_firmware.side_effect = ReolinkError("Test error") + reolink_host.check_new_firmware.side_effect = ReolinkError("Test error") with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -158,13 +155,11 @@ async def test_firmware_error_twice( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - reolink_connect.check_new_firmware.reset_mock(side_effect=True) - async def test_credential_error_three( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry, ) -> None: @@ -174,7 +169,7 @@ async def test_credential_error_three( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.get_states.side_effect = CredentialsInvalidError("Test error") + reolink_host.get_states.side_effect = CredentialsInvalidError("Test error") issue_id = f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}" for _ in range(NUM_CRED_ERRORS): @@ -185,31 +180,26 @@ async def test_credential_error_three( assert (HOMEASSISTANT_DOMAIN, issue_id) in issue_registry.issues - reolink_connect.get_states.reset_mock(side_effect=True) - async def test_entry_reloading( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" - reolink_connect.is_nvr = False - reolink_connect.logout.reset_mock() + reolink_host.is_nvr = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert reolink_connect.logout.call_count == 0 + assert reolink_host.logout.call_count == 0 assert config_entry.title == "test_reolink_name" hass.config_entries.async_update_entry(config_entry, title="New Name") await hass.async_block_till_done() - assert reolink_connect.logout.call_count == 1 + assert reolink_host.logout.call_count == 1 assert config_entry.title == "New Name" - reolink_connect.is_nvr = True - @pytest.mark.parametrize( ("attr", "value", "expected_models"), @@ -241,7 +231,7 @@ async def test_removing_disconnected_cams( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, attr: str | None, @@ -249,7 +239,7 @@ async def test_removing_disconnected_cams( expected_models: list[str], ) -> None: """Test device and entity registry are cleaned up when camera is removed.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] assert await async_setup_component(hass, "config", {}) client = await hass_ws_client(hass) # setup CH 0 and NVR switch entities/device @@ -265,8 +255,7 @@ async def test_removing_disconnected_cams( # Try to remove the device after 'disconnecting' a camera. if attr is not None: - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + setattr(reolink_host, attr, value) expected_success = TEST_CAM_MODEL not in expected_models for device in device_entries: if device.model == TEST_CAM_MODEL: @@ -279,9 +268,6 @@ async def test_removing_disconnected_cams( device_models = [device.model for device in device_entries] assert sorted(device_models) == sorted(expected_models) - if attr is not None: - setattr(reolink_connect, attr, original) - @pytest.mark.parametrize( ("attr", "value", "expected_models"), @@ -307,8 +293,8 @@ async def test_removing_chime( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, attr: str | None, @@ -316,7 +302,7 @@ async def test_removing_chime( expected_models: list[str], ) -> None: """Test removing a chime.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] assert await async_setup_component(hass, "config", {}) client = await hass_ws_client(hass) # setup CH 0 and NVR switch entities/device @@ -336,11 +322,11 @@ async def test_removing_chime( async def test_remove_chime(*args, **key_args): """Remove chime.""" - test_chime.connect_state = -1 + reolink_chime.connect_state = -1 - test_chime.remove = test_remove_chime + reolink_chime.remove = test_remove_chime elif attr is not None: - setattr(test_chime, attr, value) + setattr(reolink_chime, attr, value) # Try to remove the device after 'disconnecting' a chime. expected_success = CHIME_MODEL not in expected_models @@ -444,7 +430,7 @@ async def test_removing_chime( async def test_migrate_entity_ids( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, original_id: str, @@ -464,8 +450,8 @@ async def test_migrate_entity_ids( return support_ch_uid return True - reolink_connect.channels = [0] - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.supported = mock_supported dev_entry = device_registry.async_get_or_create( identifiers={(DOMAIN, original_dev_id)}, @@ -513,7 +499,7 @@ async def test_migrate_entity_ids( async def test_migrate_with_already_existing_device( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: @@ -529,8 +515,8 @@ async def test_migrate_with_already_existing_device( return True return True - reolink_connect.channels = [0] - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.supported = mock_supported device_registry.async_get_or_create( identifiers={(DOMAIN, new_dev_id)}, @@ -562,7 +548,7 @@ async def test_migrate_with_already_existing_device( async def test_migrate_with_already_existing_entity( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: @@ -579,8 +565,8 @@ async def test_migrate_with_already_existing_entity( return True return True - reolink_connect.channels = [0] - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.supported = mock_supported dev_entry = device_registry.async_get_or_create( identifiers={(DOMAIN, dev_id)}, @@ -623,13 +609,13 @@ async def test_migrate_with_already_existing_entity( async def test_cleanup_mac_connection( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test cleanup of the MAC of a IPC which was set to the MAC of the host.""" - reolink_connect.channels = [0] - reolink_connect.baichuan.mac_address.return_value = None + reolink_host.channels = [0] + reolink_host.baichuan.mac_address.return_value = None entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" dev_id = f"{TEST_UID}_{TEST_UID_CAM}" domain = Platform.SWITCH @@ -666,19 +652,17 @@ async def test_cleanup_mac_connection( assert device assert device.connections == set() - reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM - async def test_cleanup_combined_with_NVR( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test cleanup of the device registry if IPC camera device was combined with the NVR device.""" - reolink_connect.channels = [0] - reolink_connect.baichuan.mac_address.return_value = None + reolink_host.channels = [0] + reolink_host.baichuan.mac_address.return_value = None entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" dev_id = f"{TEST_UID}_{TEST_UID_CAM}" domain = Platform.SWITCH @@ -726,18 +710,16 @@ async def test_cleanup_combined_with_NVR( ("OTHER_INTEGRATION", "SOME_ID"), } - reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM - async def test_cleanup_hub_and_direct_connection( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test cleanup of the device registry if IPC camera device was connected directly and through the hub/NVR.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" dev_id = f"{TEST_UID}_{TEST_UID_CAM}" domain = Platform.SWITCH @@ -801,11 +783,11 @@ async def test_no_repair_issue( async def test_https_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when https local url is used.""" - reolink_connect.get_states = test_wait + reolink_host.get_states = test_wait await async_process_ha_core_config( hass, {"country": "GB", "internal_url": "https://test_homeassistant_address"} ) @@ -828,11 +810,11 @@ async def test_https_repair_issue( async def test_ssl_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when global ssl certificate is used.""" - reolink_connect.get_states = test_wait + reolink_host.get_states = test_wait assert await async_setup_component(hass, "webhook", {}) hass.config.api.use_ssl = True @@ -859,32 +841,30 @@ async def test_ssl_repair_issue( async def test_port_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, protocol: str, issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" - reolink_connect.set_net_port.side_effect = ReolinkError("Test error") - reolink_connect.onvif_enabled = False - reolink_connect.rtsp_enabled = False - reolink_connect.rtmp_enabled = False - reolink_connect.protocol = protocol + reolink_host.set_net_port.side_effect = ReolinkError("Test error") + reolink_host.onvif_enabled = False + reolink_host.rtsp_enabled = False + reolink_host.rtmp_enabled = False + reolink_host.protocol = protocol assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert (DOMAIN, "enable_port") in issue_registry.issues - reolink_connect.set_net_port.reset_mock(side_effect=True) - async def test_webhook_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when the webhook url is unreachable.""" - reolink_connect.get_states = test_wait + reolink_host.get_states = test_wait with ( patch("homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0), patch( @@ -903,25 +883,24 @@ async def test_webhook_repair_issue( async def test_firmware_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test firmware issue is raised when too old firmware is used.""" - reolink_connect.camera_sw_version_update_required.return_value = True + reolink_host.camera_sw_version_update_required.return_value = True assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert (DOMAIN, "firmware_update_host") in issue_registry.issues - reolink_connect.camera_sw_version_update_required.return_value = False async def test_password_too_long_repair_issue( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test password too long issue is raised.""" - reolink_connect.valid_password.return_value = False + reolink_host.valid_password.return_value = False config_entry = MockConfigEntry( domain=DOMAIN, unique_id=format_mac(TEST_MAC), @@ -946,13 +925,12 @@ async def test_password_too_long_repair_issue( DOMAIN, f"password_too_long_{config_entry.entry_id}", ) in issue_registry.issues - reolink_connect.valid_password.return_value = True async def test_new_device_discovered( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test the entry is reloaded when a new camera or chime is detected.""" @@ -960,26 +938,24 @@ async def test_new_device_discovered( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.logout.reset_mock() - - assert reolink_connect.logout.call_count == 0 - reolink_connect.new_devices = True + assert reolink_host.logout.call_count == 0 + reolink_host.new_devices = True freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert reolink_connect.logout.call_count == 1 + assert reolink_host.logout.call_count == 1 async def test_port_changed( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test config_entry port update when it has changed during initial login.""" assert config_entry.data[CONF_PORT] == TEST_PORT - reolink_connect.port = 4567 + reolink_host.port = 4567 assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -989,12 +965,12 @@ async def test_port_changed( async def test_baichuan_port_changed( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test config_entry baichuan port update when it has changed during initial login.""" assert config_entry.data[CONF_BC_PORT] == TEST_BC_PORT - reolink_connect.baichuan.port = 8901 + reolink_host.baichuan.port = 8901 assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -1005,14 +981,12 @@ async def test_baichuan_port_changed( async def test_privacy_mode_on( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test successful setup even when privacy mode is turned on.""" - reolink_connect.baichuan.privacy_mode.return_value = True - reolink_connect.get_states = AsyncMock( - side_effect=LoginPrivacyModeError("Test error") - ) + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.get_states = AsyncMock(side_effect=LoginPrivacyModeError("Test error")) with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1020,40 +994,36 @@ async def test_privacy_mode_on( assert config_entry.state == ConfigEntryState.LOADED - reolink_connect.baichuan.privacy_mode.return_value = False - async def test_LoginPrivacyModeError( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test normal update when get_states returns a LoginPrivacyModeError.""" - reolink_connect.baichuan.privacy_mode.return_value = False - reolink_connect.get_states = AsyncMock( - side_effect=LoginPrivacyModeError("Test error") - ) + reolink_host.baichuan.privacy_mode.return_value = False + reolink_host.get_states = AsyncMock(side_effect=LoginPrivacyModeError("Test error")) with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.baichuan.check_subscribe_events.reset_mock() - assert reolink_connect.baichuan.check_subscribe_events.call_count == 0 + reolink_host.baichuan.check_subscribe_events.reset_mock() + assert reolink_host.baichuan.check_subscribe_events.call_count == 0 freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert reolink_connect.baichuan.check_subscribe_events.call_count >= 1 + assert reolink_host.baichuan.check_subscribe_events.call_count >= 1 async def test_privacy_mode_change_callback( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test privacy mode changed callback.""" @@ -1068,13 +1038,12 @@ async def test_privacy_mode_change_callback( callback_mock = callback_mock_class() - reolink_connect.model = TEST_HOST_MODEL - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) - reolink_connect.baichuan.register_callback = callback_mock.register_callback - reolink_connect.baichuan.privacy_mode.return_value = True - reolink_connect.audio_record.return_value = True - reolink_connect.get_states = AsyncMock() + reolink_host.model = TEST_HOST_MODEL + reolink_host.baichuan.events_active = True + reolink_host.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.register_callback = callback_mock.register_callback + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.audio_record.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1085,29 +1054,29 @@ async def test_privacy_mode_change_callback( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # simulate a TCP push callback signaling a privacy mode change - reolink_connect.baichuan.privacy_mode.return_value = False + reolink_host.baichuan.privacy_mode.return_value = False assert callback_mock.callback_func is not None callback_mock.callback_func() # check that a coordinator update was scheduled. - reolink_connect.get_states.reset_mock() - assert reolink_connect.get_states.call_count == 0 + reolink_host.get_states.reset_mock() + assert reolink_host.get_states.call_count == 0 freezer.tick(5) async_fire_time_changed(hass) await hass.async_block_till_done() - assert reolink_connect.get_states.call_count >= 1 + assert reolink_host.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_ON # test cleanup during unloading, first reset to privacy mode ON - reolink_connect.baichuan.privacy_mode.return_value = True + reolink_host.baichuan.privacy_mode.return_value = True callback_mock.callback_func() freezer.tick(5) async_fire_time_changed(hass) await hass.async_block_till_done() # now fire the callback again, but unload before refresh took place - reolink_connect.baichuan.privacy_mode.return_value = False + reolink_host.baichuan.privacy_mode.return_value = False callback_mock.callback_func() await hass.async_block_till_done() @@ -1120,7 +1089,7 @@ async def test_camera_wake_callback( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test camera wake callback.""" @@ -1135,13 +1104,12 @@ async def test_camera_wake_callback( callback_mock = callback_mock_class() - reolink_connect.model = TEST_HOST_MODEL - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) - reolink_connect.baichuan.register_callback = callback_mock.register_callback - reolink_connect.sleeping.return_value = True - reolink_connect.audio_record.return_value = True - reolink_connect.get_states = AsyncMock() + reolink_host.model = TEST_HOST_MODEL + reolink_host.baichuan.events_active = True + reolink_host.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.register_callback = callback_mock.register_callback + reolink_host.sleeping.return_value = True + reolink_host.audio_record.return_value = True with ( patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]), @@ -1157,12 +1125,12 @@ async def test_camera_wake_callback( entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.sleeping.return_value = False - reolink_connect.get_states.reset_mock() - assert reolink_connect.get_states.call_count == 0 + reolink_host.sleeping.return_value = False + reolink_host.get_states.reset_mock() + assert reolink_host.get_states.call_count == 0 # simulate a TCP push callback signaling the battery camera woke up - reolink_connect.audio_record.return_value = False + reolink_host.audio_record.return_value = False assert callback_mock.callback_func is not None with ( patch( @@ -1182,13 +1150,26 @@ async def test_camera_wake_callback( await hass.async_block_till_done() # check that a coordinator update was scheduled. - assert reolink_connect.get_states.call_count >= 1 + assert reolink_host.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_OFF +async def test_baichaun_only( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test initializing a baichuan only device.""" + reolink_connect.baichuan_only = True + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + async def test_remove( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test removing of the reolink integration.""" diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 126d445ca01..59f0c6c195d 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -141,6 +141,7 @@ async def test_browsing( entry_id = config_entry.entry_id reolink_connect.supported.return_value = 1 reolink_connect.model = "Reolink TrackMix PoE" + reolink_connect.is_nvr = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True @@ -333,7 +334,14 @@ async def test_browsing_rec_playback_unsupported( config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera which does not support playback of recordings.""" - reolink_connect.supported.return_value = 0 + + def test_supported(ch, key): + """Test supported function.""" + if key == "replay": + return False + return True + + reolink_connect.supported = test_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -347,6 +355,8 @@ async def test_browsing_rec_playback_unsupported( assert browse.identifier is None assert browse.children == [] + reolink_connect.supported = lambda ch, key: True # Reset supported function + async def test_browsing_errors( hass: HomeAssistant, @@ -354,8 +364,6 @@ async def test_browsing_errors( config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera errors.""" - reolink_connect.supported.return_value = 1 - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -373,8 +381,6 @@ async def test_browsing_not_loaded( config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera integration which is not loaded.""" - reolink_connect.supported.return_value = 1 - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/russound_rio/const.py b/tests/components/russound_rio/const.py index 8269e825e33..e801d6786ad 100644 --- a/tests/components/russound_rio/const.py +++ b/tests/components/russound_rio/const.py @@ -17,6 +17,5 @@ MOCK_RECONFIGURATION_CONFIG = { CONF_PORT: 9622, } -DEVICE_NAME = "mca_c5" NAME_ZONE_1 = "backyard" -ENTITY_ID_ZONE_1 = f"{MP_DOMAIN}.{DEVICE_NAME}_{NAME_ZONE_1}" +ENTITY_ID_ZONE_1 = f"{MP_DOMAIN}.{NAME_ZONE_1}" diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index d0c18a9b1e7..04e1057565d 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -207,7 +207,7 @@ async def test_invalid_source_service( with pytest.raises( HomeAssistantError, - match="Error executing async_select_source on entity media_player.mca_c5_backyard", + match="Error executing async_select_source on entity media_player.backyard", ): await hass.services.async_call( MP_DOMAIN, diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index ac70226a20a..4eccb075b67 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -260,6 +260,33 @@ 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, @@ -272,6 +299,17 @@ 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", + }, } @@ -287,6 +325,17 @@ 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": [], + }, } diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 283de897d8d..703df09bb61 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -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_PLUS_2PM +from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -38,6 +38,7 @@ 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 @@ -606,3 +607,49 @@ 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 diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index e95d4cfaeb2..8f021c2d58a 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, @@ -40,6 +41,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import ( + MOCK_MAC, init_integration, mock_polling_rpc_update, mock_rest_update, @@ -1585,3 +1587,45 @@ async def test_rpc_switch_no_returned_energy_sensor( await init_integration(hass, 3) assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None + + +async def test_block_friendly_name_sleeping_sensor( + hass: HomeAssistant, + mock_block_device: Mock, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test friendly name for restored sleeping sensor.""" + entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) + device = register_device(device_registry, entry) + + entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sensor_0-temp", + suggested_object_id="test_name_temperature", + original_name="Test name temperature", + disabled_by=None, + config_entry=entry, + device_id=device.id, + ) + + # Old name, the word "temperature" starts with a lower case letter + assert entity.original_name == "Test name temperature" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + + # New name, the word "temperature" starts with a capital letter + assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature" + + # Make device online + monkeypatch.setattr(mock_block_device, "initialized", True) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(entity.entity_id)) + assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature" diff --git a/tests/components/smarla/snapshots/test_number.ambr b/tests/components/smarla/snapshots/test_number.ambr index 3232795c277..50312e09920 100644 --- a/tests/components/smarla/snapshots/test_number.ambr +++ b/tests/components/smarla/snapshots/test_number.ambr @@ -53,6 +53,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '1.0', }) # --- diff --git a/tests/components/smarla/test_number.py b/tests/components/smarla/test_number.py index 642b39f33fb..3589829e56c 100644 --- a/tests/components/smarla/test_number.py +++ b/tests/components/smarla/test_number.py @@ -93,11 +93,11 @@ async def test_number_state_update( entity_id = entity_info["entity_id"] - assert hass.states.get(entity_id).state == "1" + assert hass.states.get(entity_id).state == "1.0" mock_number_property.get.return_value = 100 await update_property_listeners(mock_number_property) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "100" + assert hass.states.get(entity_id).state == "100.0" diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json index 350a0ee14bb..dbb4519ca61 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json @@ -105,12 +105,14 @@ "icemaker": { "custom.disabledCapabilities": { "disabledCapabilities": { - "value": null + "value": [], + "timestamp": "2024-12-19T19:47:51.861Z" } }, "switch": { "switch": { - "value": null + "value": "on", + "timestamp": "2025-06-16T07:20:04.493Z" } } }, @@ -134,13 +136,13 @@ "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { "disabledCapabilities": { "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], - "timestamp": "2024-12-01T18:22:20.155Z" + "timestamp": "2024-12-19T19:47:55.421Z" } }, "samsungce.temperatureSetting": { @@ -229,19 +231,19 @@ "contactSensor": { "contact": { "value": "closed", - "timestamp": "2025-03-30T18:36:45.151Z" + "timestamp": "2025-06-16T15:59:26.313Z" } }, "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { "disabledCapabilities": { "value": ["custom.fridgeMode", "samsungce.temperatureSetting"], - "timestamp": "2024-12-01T18:22:22.081Z" + "timestamp": "2024-12-19T19:47:56.956Z" } }, "samsungce.temperatureSetting": { @@ -257,37 +259,37 @@ "value": null }, "temperature": { - "value": 6, - "unit": "C", - "timestamp": "2025-03-30T17:41:42.863Z" + "value": 36, + "unit": "F", + "timestamp": "2025-06-07T07:52:37.532Z" } }, "custom.thermostatSetpointControl": { "minimumSetpoint": { - "value": 1, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 34, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "maximumSetpoint": { - "value": 7, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 44, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" } }, "thermostatCoolingSetpoint": { "coolingSetpointRange": { "value": { - "minimum": 1, - "maximum": 7, + "minimum": 34, + "maximum": 44, "step": 1 }, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "coolingSetpoint": { - "value": 6, - "unit": "C", - "timestamp": "2025-03-30T17:33:48.530Z" + "value": 36, + "unit": "F", + "timestamp": "2025-06-07T07:48:40.490Z" } } }, @@ -306,13 +308,13 @@ "contactSensor": { "contact": { "value": "closed", - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-06-16T15:01:16.141Z" } }, "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { @@ -322,7 +324,7 @@ "samsungce.temperatureSetting", "samsungce.freezerConvertMode" ], - "timestamp": "2024-12-01T18:22:22.081Z" + "timestamp": "2024-12-19T19:47:56.956Z" } }, "samsungce.temperatureSetting": { @@ -338,26 +340,27 @@ "value": null }, "temperature": { - "value": -17, - "unit": "C", - "timestamp": "2025-03-30T17:35:48.599Z" + "value": -8, + "unit": "F", + "timestamp": "2025-06-07T07:50:37.311Z" } }, "custom.thermostatSetpointControl": { "minimumSetpoint": { - "value": -23, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": -8, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "maximumSetpoint": { - "value": -15, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 5, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" } }, "samsungce.freezerConvertMode": { "supportedFreezerConvertModes": { - "value": null + "value": [], + "timestamp": "2025-05-25T02:26:23.578Z" }, "freezerConvertMode": { "value": null @@ -366,17 +369,17 @@ "thermostatCoolingSetpoint": { "coolingSetpointRange": { "value": { - "minimum": -23, - "maximum": -15, + "minimum": -8, + "maximum": 5, "step": 1 }, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "coolingSetpoint": { - "value": -17, - "unit": "C", - "timestamp": "2025-03-30T17:32:34.710Z" + "value": -8, + "unit": "F", + "timestamp": "2025-06-07T07:48:42.385Z" } } }, @@ -411,7 +414,8 @@ }, "samsungce.deviceIdentification": { "micomAssayCode": { - "value": null + "value": "00176141", + "timestamp": "2025-06-13T04:49:15.194Z" }, "modelName": { "value": null @@ -423,23 +427,26 @@ "value": null }, "modelClassificationCode": { - "value": null + "value": "0000083C031813294103010041030000", + "timestamp": "2025-06-13T04:49:15.194Z" }, "description": { - "value": null + "value": "TP1X_REF_21K", + "timestamp": "2025-06-13T04:49:15.194Z" }, "releaseYear": { - "value": null + "value": 24, + "timestamp": "2025-06-13T04:49:14.072Z" }, "binaryId": { "value": "TP1X_REF_21K", - "timestamp": "2025-03-23T21:53:15.900Z" + "timestamp": "2025-06-16T07:20:04.493Z" } }, "samsungce.quickControl": { "version": { "value": "1.0", - "timestamp": "2025-02-12T21:52:01.494Z" + "timestamp": "2025-05-25T02:26:25.302Z" } }, "custom.fridgeMode": { @@ -461,66 +468,65 @@ "value": null }, "mnfv": { - "value": "A-RFWW-TP1-22-REV1_20241030", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "A-RFWW-TP1-24-T4-COM_20250216", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnhw": { "value": "Realtek", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "di": { - "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "5ff1ef72-56ce-6559-4bd3-be42c31f3395", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnsl": { "value": "http://www.samsung.com", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "dmv": { - "value": "1.2.1", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "n": { "value": "Samsung-Refrigerator", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnmo": { - "value": "TP1X_REF_21K|00156941|00050126001611304100000030010000", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "TP1X_REF_21K|00176141|0000083C031813294103010041030000", + "timestamp": "2025-04-12T15:30:22.827Z" }, "vid": { "value": "DA-REF-NORMAL-01011", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnmn": { "value": "Samsung Electronics", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnml": { "value": "http://www.samsung.com", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnpv": { - "value": "DAWIT 2.0", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "SYSTEM 2.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnos": { - "value": "TizenRT 3.1", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "TizenRT 4.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "pi": { - "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "5ff1ef72-56ce-6559-4bd3-be42c31f3395", + "timestamp": "2025-04-12T15:30:22.827Z" }, "icv": { "value": "core.1.1.0", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" } }, "samsungce.fridgeVacationMode": { "vacationMode": { - "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": null } }, "custom.disabledCapabilities": { @@ -530,56 +536,56 @@ "thermostatCoolingSetpoint", "custom.fridgeMode", "custom.deodorFilter", - "custom.waterFilter", "custom.dustFilter", "samsungce.viewInside", "samsungce.fridgeWelcomeLighting", - "samsungce.sabbathMode" + "sec.smartthingsHub", + "samsungce.fridgeVacationMode" ], - "timestamp": "2025-02-12T21:52:01.494Z" + "timestamp": "2025-03-31T03:05:25.793Z" } }, "samsungce.driverVersion": { "versionNumber": { - "value": 24090102, - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 25040101, + "timestamp": "2025-06-13T04:49:16.828Z" } }, "sec.diagnosticsInformation": { "logType": { "value": ["errCode", "dump"], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "endpoint": { "value": "SSM", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "minVersion": { "value": "3.0", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "signinPermission": { "value": null }, "setupId": { - "value": "RB0", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": "RRD", + "timestamp": "2025-05-25T02:26:23.664Z" }, "protocolType": { "value": "ble_ocf", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "tsId": { "value": "DA01", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "mnId": { "value": "0AJT", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "dumpType": { "value": "file", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" } }, "temperatureMeasurement": { @@ -598,11 +604,11 @@ "value": { "state": "disabled" }, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.815Z" }, "reportStatePeriod": { "value": "enabled", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.815Z" } }, "thermostatCoolingSetpoint": { @@ -616,8 +622,6 @@ "custom.disabledComponents": { "disabledComponents": { "value": [ - "icemaker", - "icemaker-02", "icemaker-03", "pantry-01", "pantry-02", @@ -626,7 +630,7 @@ "cvroom", "onedoor" ], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "demandResponseLoadControl": { @@ -637,31 +641,33 @@ "duration": 0, "override": false }, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.225Z" } }, "samsungce.sabbathMode": { "supportedActions": { - "value": null + "value": ["on", "off"], + "timestamp": "2025-05-25T02:26:23.696Z" }, "status": { - "value": null + "value": "off", + "timestamp": "2025-05-25T02:26:23.696Z" } }, "powerConsumptionReport": { "powerConsumption": { "value": { - "energy": 66571, - "deltaEnergy": 19, - "power": 61, - "powerEnergy": 18.91178222020467, + "energy": 229226, + "deltaEnergy": 10, + "power": 17, + "powerEnergy": 14.351180554098551, "persistedEnergy": 0, "energySaved": 0, "persistedSavedEnergy": 0, - "start": "2025-03-30T18:21:37Z", - "end": "2025-03-30T18:38:18Z" + "start": "2025-06-16T16:30:09Z", + "end": "2025-06-16T16:45:48Z" }, - "timestamp": "2025-03-30T18:38:18.219Z" + "timestamp": "2025-06-16T16:45:48.369Z" } }, "refresh": {}, @@ -673,44 +679,63 @@ "sec.wifiConfiguration": { "autoReconnection": { "value": true, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "minVersion": { "value": "1.0", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "supportedWiFiFreq": { "value": ["2.4G"], - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "supportedAuthType": { "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "protocolType": { - "value": ["helper_hotspot"], - "timestamp": "2024-12-01T18:22:19.331Z" + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-05-25T02:26:25.567Z" } }, "samsungce.selfCheck": { "result": { "value": "passed", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "supportedActions": { "value": ["start"], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "progress": { "value": null }, "errors": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "status": { "value": "ready", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "250216", + "description": "WiFi Module" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "24120326, 24030400, 24061400, FFFFFFFF", + "description": "Micom" + } + ], + "timestamp": "2025-05-25T02:26:23.664Z" } }, "custom.dustFilter": { @@ -735,15 +760,16 @@ }, "refrigeration": { "defrost": { - "value": null + "value": "off", + "timestamp": "2025-05-25T02:26:22.999Z" }, "rapidCooling": { "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.827Z" }, "rapidFreezing": { "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T06:58:12.005Z" } }, "custom.deodorFilter": { @@ -769,88 +795,134 @@ "samsungce.powerCool": { "activated": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.827Z" } }, "custom.energyType": { "energyType": { "value": "2.0", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingSupport": { "value": true, - "timestamp": "2025-03-06T23:10:37.429Z" + "timestamp": "2025-05-23T06:02:34.025Z" }, "drMaxDuration": { "value": 99999999, "unit": "min", - "timestamp": "2024-12-01T18:22:20.756Z" + "timestamp": "2024-12-19T19:47:54.446Z" }, "energySavingLevel": { "value": 1, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingInfo": { "value": null }, "supportedEnergySavingLevels": { - "value": [1, 2], - "timestamp": "2024-12-01T18:22:19.337Z" + "value": [1], + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingOperation": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.225Z" }, "notificationTemplateID": { "value": null }, "energySavingOperationSupport": { "value": true, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "samsungce.softwareUpdate": { "targetModule": { "value": {}, - "timestamp": "2024-12-01T18:55:10.062Z" + "timestamp": "2025-05-25T02:26:23.686Z" }, "otnDUID": { - "value": "MTCB2ZD4B6BT4", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": "XTCB2ZD4CVZDG", + "timestamp": "2025-05-25T02:26:23.664Z" }, "lastUpdatedDate": { "value": null }, "availableModules": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "newVersionAvailable": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "operatingState": { "value": "none", - "timestamp": "2024-12-01T18:28:40.492Z" + "timestamp": "2025-05-25T02:26:23.686Z" }, "progress": { "value": 0, "unit": "%", - "timestamp": "2024-12-01T18:43:42.645Z" + "timestamp": "2025-05-25T02:26:23.686Z" } }, "samsungce.powerFreeze": { "activated": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T06:58:12.005Z" + } + }, + "sec.smartthingsHub": { + "threadHardwareAvailability": { + "value": null + }, + "availability": { + "value": null + }, + "deviceId": { + "value": null + }, + "zigbeeHardwareAvailability": { + "value": null + }, + "version": { + "value": null + }, + "threadRequiresExternalHardware": { + "value": null + }, + "zigbeeRequiresExternalHardware": { + "value": null + }, + "eui": { + "value": null + }, + "lastOnboardingResult": { + "value": null + }, + "zwaveHardwareAvailability": { + "value": null + }, + "zwaveRequiresExternalHardware": { + "value": null + }, + "state": { + "value": null + }, + "onboardingProgress": { + "value": null + }, + "lastOnboardingErrorCode": { + "value": null } }, "custom.waterFilter": { "waterFilterUsageStep": { - "value": null + "value": 1, + "timestamp": "2025-05-25T02:26:23.401Z" }, "waterFilterResetType": { - "value": null + "value": ["replaceable"], + "timestamp": "2025-05-25T02:26:23.401Z" }, "waterFilterCapacity": { "value": null @@ -859,10 +931,12 @@ "value": null }, "waterFilterUsage": { - "value": null + "value": 97, + "timestamp": "2025-06-16T13:02:17.608Z" }, "waterFilterStatus": { - "value": null + "value": "normal", + "timestamp": "2025-05-25T02:26:23.401Z" } } }, @@ -872,10 +946,18 @@ "value": null }, "fridgeMode": { - "value": null + "value": "CV_TTYPE_RF9000A_FRUIT_VEGGIES", + "timestamp": "2025-05-25T02:26:23.578Z" }, "supportedFridgeModes": { - "value": null + "value": [ + "CV_TTYPE_RF9000A_FREEZE", + "CV_TTYPE_RF9000A_SOFTFREEZE", + "CV_TTYPE_RF9000A_MEAT_FISH", + "CV_TTYPE_RF9000A_FRUIT_VEGGIES", + "CV_TTYPE_RF9000A_BEVERAGE" + ], + "timestamp": "2025-05-25T02:26:23.578Z" } }, "contactSensor": { @@ -908,12 +990,14 @@ "icemaker-02": { "custom.disabledCapabilities": { "disabledCapabilities": { - "value": null + "value": [], + "timestamp": "2024-12-19T19:47:51.861Z" } }, "switch": { "switch": { - "value": null + "value": "on", + "timestamp": "2025-06-16T14:00:28.428Z" } } }, diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json index 9be5db0bda9..2cde305ca3d 100644 --- a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json @@ -128,6 +128,10 @@ "id": "samsungce.quickControl", "version": 1 }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, { "id": "sec.diagnosticsInformation", "version": 1 @@ -135,6 +139,11 @@ { "id": "sec.wifiConfiguration", "version": 1 + }, + { + "id": "sec.smartthingsHub", + "version": 1, + "ephemeral": true } ], "categories": [ @@ -142,7 +151,8 @@ "name": "Refrigerator", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "freezer", @@ -190,7 +200,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "cooler", @@ -234,7 +245,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "cvroom", @@ -266,7 +278,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "onedoor", @@ -314,7 +327,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker", @@ -334,7 +348,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker-02", @@ -354,7 +369,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker-03", @@ -374,7 +390,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "scale-10", @@ -402,7 +419,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "scale-11", @@ -422,7 +440,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "pantry-01", @@ -454,7 +473,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "pantry-02", @@ -486,7 +506,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false } ], "createTime": "2024-12-01T18:22:14.880Z", diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr index ad8e0ff276b..a49aad2f897 100644 --- a/tests/components/smartthings/snapshots/test_button.ambr +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -239,3 +239,51 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ref_normal_01011][button.frigo_reset_water_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.frigo_reset_water_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Reset water filter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_water_filter', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_custom.waterFilter_resetWaterFilter', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][button.frigo_reset_water_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Reset water filter', + }), + 'context': , + 'entity_id': 'button.frigo_reset_water_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr index e02b2ecc9b4..b9af2605f1d 100644 --- a/tests/components/smartthings/snapshots/test_number.ambr +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -298,8 +298,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': -15, - 'min': -23, + 'max': -15.0, + 'min': -23.0, 'mode': , 'step': 1, }), @@ -337,8 +337,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Frigo Freezer temperature', - 'max': -15, - 'min': -23, + 'max': -15.0, + 'min': -23.0, 'mode': , 'step': 1, 'unit_of_measurement': , @@ -348,7 +348,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17', + 'state': '-22.0', }) # --- # name: test_all_entities[da_ref_normal_01011][number.frigo_fridge_temperature-entry] @@ -357,8 +357,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 7, - 'min': 1, + 'max': 7.0, + 'min': 1.0, 'mode': , 'step': 1, }), @@ -396,8 +396,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Frigo Fridge temperature', - 'max': 7, - 'min': 1, + 'max': 7.0, + 'min': 1.0, 'mode': , 'step': 1, 'unit_of_measurement': , @@ -407,7 +407,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '2.0', }) # --- # name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index e85ec4620e9..40180b88bca 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5569,7 +5569,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '66.571', + 'state': '229.226', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-entry] @@ -5625,7 +5625,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.019', + 'state': '0.01', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-entry] @@ -5737,7 +5737,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17', + 'state': '-22.2222222222222', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_fridge_temperature-entry] @@ -5793,7 +5793,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '2.22222222222222', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-entry] @@ -5841,8 +5841,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Frigo Power', - 'power_consumption_end': '2025-03-30T18:38:18Z', - 'power_consumption_start': '2025-03-30T18:21:37Z', + 'power_consumption_end': '2025-06-16T16:45:48Z', + 'power_consumption_start': '2025-06-16T16:30:09Z', 'state_class': , 'unit_of_measurement': , }), @@ -5851,7 +5851,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '61', + 'state': '17', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-entry] @@ -5907,7 +5907,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0189117822202047', + 'state': '0.0143511805540986', }) # --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 1323230e7ea..d0ea3dbcdad 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -47,7 +47,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-entry] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,7 +60,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_cubed_ice', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -72,7 +72,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice maker', + 'original_name': 'Cubed ice', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -82,13 +82,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-state] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Refrigerator Ice maker', + 'friendly_name': 'Refrigerator Cubed ice', }), 'context': , - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_cubed_ice', 'last_changed': , 'last_reported': , 'last_updated': , @@ -239,7 +239,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-entry] +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_cubed_ice-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -252,7 +252,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_cubed_ice', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -264,7 +264,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice maker', + 'original_name': 'Cubed ice', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -274,13 +274,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-state] +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_cubed_ice-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Refrigerator Ice maker', + 'friendly_name': 'Refrigerator Cubed ice', }), 'context': , - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_cubed_ice', 'last_changed': , 'last_reported': , 'last_updated': , @@ -383,6 +383,102 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_cubed_ice-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.frigo_cubed_ice', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cubed ice', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_cubed_ice-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Cubed ice', + }), + 'context': , + 'entity_id': 'switch.frigo_cubed_ice', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.frigo_ice_bites', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ice Bites', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker_2', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker-02_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Ice Bites', + }), + 'context': , + 'entity_id': 'switch.frigo_ice_bites', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_ref_normal_01011][switch.frigo_power_cool-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -479,6 +575,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_sabbath_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.frigo_sabbath_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sabbath mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sabbath_mode', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_samsungce.sabbathMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_sabbath_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Sabbath mode', + }), + 'context': , + 'entity_id': 'switch.frigo_sabbath_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/sms/test_init.py b/tests/components/sms/test_init.py index 68c57ba7f55..05448ce0f57 100644 --- a/tests/components/sms/test_init.py +++ b/tests/components/sms/test_init.py @@ -22,7 +22,7 @@ async def test_repair_issue_is_created( issue_registry: ir.IssueRegistry, ) -> None: """Test repair issue is created.""" - from homeassistant.components.sms import ( # pylint: disable=import-outside-toplevel + from homeassistant.components.sms import ( # noqa: PLC0415 DEPRECATED_ISSUE_ID, DOMAIN, ) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 4994d36f1bf..d121d5a4a12 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -85,6 +85,15 @@ class SonosMockService: self.subscribe = AsyncMock(return_value=SonosMockSubscribe(ip_address)) +class SonosMockRenderingService(SonosMockService): + """Mock rendering service.""" + + def __init__(self, return_value: dict[str, str], ip_address="192.168.42.2") -> None: + """Initialize the instance.""" + super().__init__("RenderingControl", ip_address) + self.GetVolume = Mock(return_value=30) + + class SonosMockAlarmClock(SonosMockService): """Mock a Sonos AlarmClock Service used in callbacks.""" @@ -239,7 +248,7 @@ class SoCoMockFactory: mock_soco.avTransport.GetPositionInfo = Mock( return_value=self.current_track_info ) - mock_soco.renderingControl = SonosMockService("RenderingControl", ip_address) + mock_soco.renderingControl = SonosMockRenderingService(ip_address) mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology", ip_address) mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address) mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index c6be606eb20..1bc8baff752 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -3,15 +3,15 @@ import asyncio from datetime import timedelta import logging -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config_entries from homeassistant.components import sonos -from homeassistant.components.sonos import SonosDiscoveryManager from homeassistant.components.sonos.const import ( - DATA_SONOS_DISCOVERY_MANAGER, + DISCOVERY_INTERVAL, SONOS_SPEAKER_ACTIVITY, ) from homeassistant.components.sonos.exception import SonosUpdateError @@ -87,76 +87,73 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N async def test_async_poll_manual_hosts_warnings( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + soco_factory: SoCoMockFactory, + freezer: FrozenDateTimeFactory, ) -> None: """Test that host warnings are not logged repeatedly.""" - await async_setup_component( - hass, - sonos.DOMAIN, - {"sonos": {"media_player": {"interface_addr": "127.0.0.1"}}}, - ) - await hass.async_block_till_done() - manager: SonosDiscoveryManager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] - manager.hosts.add("10.10.10.10") + + soco = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Bedroom") with ( caplog.at_level(logging.DEBUG), - patch.object(manager, "_async_handle_discovery_message"), - patch( - "homeassistant.components.sonos.async_call_later" - ) as mock_async_call_later, - patch("homeassistant.components.sonos.async_dispatcher_send"), - patch( - "homeassistant.components.sonos.sync_get_visible_zones", - side_effect=[ - OSError(), - OSError(), - [], - [], - OSError(), - ], - ), + patch.object( + type(soco), "visible_zones", new_callable=PropertyMock + ) as mock_visible_zones, ): # First call fails, it should be logged as a WARNING message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 1 + await _setup_hass(hass) + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["WARNING"] # Second call fails again, it should be logged as a DEBUG message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "DEBUG" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 2 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["DEBUG"] - # Third call succeeds, it should log an info message + # Third call succeeds, logs message indicating reconnect + mock_visible_zones.return_value = {soco} + mock_visible_zones.side_effect = None caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Connection reestablished to Sonos device" in record.message - assert mock_async_call_later.call_count == 3 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert [ + rec.levelname + for rec in caplog.records + if "Connection reestablished to Sonos device" in rec.message + ] == ["WARNING"] - # Fourth call succeeds again, no need to log + # Fourth call succeeds, it should log nothing caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 0 - assert mock_async_call_later.call_count == 4 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert "Connection reestablished to Sonos device" not in caplog.text - # Fifth call fail again again, should be logged as a WARNING message + # Fifth call fails again again, should be logged as a WARNING message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 5 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["WARNING"] class _MockSoCoOsError(MockSoCo): diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index da894ff4548..8f83ce2f814 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -5,12 +5,15 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, SERVICE_JOIN -from homeassistant.components.sonos.const import DATA_SONOS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from tests.common import MockConfigEntry -async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) -> None: + +async def test_media_player_join( + hass: HomeAssistant, async_autosetup_sonos, config_entry: MockConfigEntry +) -> None: """Test join service.""" valid_entity_id = "media_player.zone_a" mocked_entity_id = "media_player.mocked" @@ -29,7 +32,10 @@ async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) -> mock_entity_id_mappings = {mocked_entity_id: mocked_speaker} with ( - patch.dict(hass.data[DATA_SONOS].entity_id_mappings, mock_entity_id_mappings), + patch.dict( + config_entry.runtime_data.entity_id_mappings, + mock_entity_id_mappings, + ), patch( "homeassistant.components.sonos.speaker.SonosSpeaker.join_multi" ) as mock_join_multi, @@ -41,5 +47,7 @@ async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) -> blocking=True, ) - found_speaker = hass.data[DATA_SONOS].entity_id_mappings[valid_entity_id] - mock_join_multi.assert_called_with(hass, found_speaker, [mocked_speaker]) + found_speaker = config_entry.runtime_data.entity_id_mappings[valid_entity_id] + mock_join_multi.assert_called_with( + hass, config_entry, found_speaker, [mocked_speaker] + ) diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index 40d126c64f2..468b848dfb5 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -9,13 +9,18 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PLAY, ) from homeassistant.components.sonos import DOMAIN -from homeassistant.components.sonos.const import DATA_SONOS, SCAN_INTERVAL +from homeassistant.components.sonos.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from .conftest import MockSoCo, SonosMockEvent -from tests.common import async_fire_time_changed, load_fixture, load_json_value_fixture +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + load_json_value_fixture, +) async def test_fallback_to_polling( @@ -33,7 +38,7 @@ async def test_fallback_to_polling( await hass.async_block_till_done() await fire_zgs_event() - speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + speaker = list(config_entry.runtime_data.discovered.values())[0] assert speaker.soco is soco assert speaker._subscriptions assert not speaker.subscriptions_failed @@ -56,7 +61,7 @@ async def test_fallback_to_polling( async def test_subscription_creation_fails( - hass: HomeAssistant, async_setup_sonos + hass: HomeAssistant, async_setup_sonos, config_entry: MockConfigEntry ) -> None: """Test that subscription creation failures are handled.""" with patch( @@ -66,7 +71,7 @@ async def test_subscription_creation_fails( await async_setup_sonos() await hass.async_block_till_done(wait_background_tasks=True) - speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + speaker = list(config_entry.runtime_data.discovered.values())[0] assert not speaker._subscriptions with patch.object(speaker, "_resub_cooldown_expires_at", None): diff --git a/tests/components/sonos/test_statistics.py b/tests/components/sonos/test_statistics.py index 4f28ec31412..84f8fca138e 100644 --- a/tests/components/sonos/test_statistics.py +++ b/tests/components/sonos/test_statistics.py @@ -1,14 +1,19 @@ """Tests for the Sonos statistics.""" -from homeassistant.components.sonos.const import DATA_SONOS from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + async def test_statistics_duplicate( - hass: HomeAssistant, async_autosetup_sonos, soco, device_properties_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + device_properties_event, + config_entry: MockConfigEntry, ) -> None: """Test Sonos statistics.""" - speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + speaker = list(config_entry.runtime_data.discovered.values())[0] subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 62a0c02b979..0287ccc5dfa 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -1,6 +1,6 @@ """Config flow tests for the Telegram Bot integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from telegram import ChatFullInfo, User from telegram.constants import AccentColor @@ -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_HTML, + ATTR_PARSER: PARSER_PLAIN_TEXT, }, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][ATTR_PARSER] == PARSER_HTML + assert result["data"][ATTR_PARSER] is None async def test_reconfigure_flow_broadcast( @@ -305,10 +305,19 @@ async def test_reauth_flow( # test: valid - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), + with ( + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_me", + return_value=User(123456, "Testbot", True), + ), + patch( + "homeassistant.components.telegram_bot.webhooks.PushBot", + ) as mock_pushbot, ): + mock_pushbot.return_value.start_application = AsyncMock() + mock_pushbot.return_value.register_webhook = AsyncMock() + mock_pushbot.return_value.shutdown = AsyncMock() + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "new mock api key"}, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index d276d72c8a6..fd313867561 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1,12 +1,14 @@ """Tests for the telegram_bot component.""" import base64 +from datetime import datetime import io from typing import Any from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest -from telegram import Update +from telegram import Chat, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update +from telegram.constants import ChatType, ParseMode from telegram.error import ( InvalidToken, NetworkError, @@ -16,27 +18,37 @@ from telegram.error import ( ) from homeassistant.components.telegram_bot import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + async_setup_entry, +) +from homeassistant.components.telegram_bot.const import ( ATTR_AUTHENTICATION, ATTR_CALLBACK_QUERY_ID, ATTR_CAPTION, ATTR_CHAT_ID, + ATTR_DISABLE_NOTIF, + ATTR_DISABLE_WEB_PREV, ATTR_FILE, + ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_MESSAGE, + ATTR_MESSAGE_TAG, ATTR_MESSAGE_THREAD_ID, ATTR_MESSAGEID, ATTR_OPTIONS, + ATTR_PARSER, ATTR_PASSWORD, ATTR_QUESTION, + ATTR_REPLY_TO_MSGID, + ATTR_SHOW_ALERT, ATTR_STICKER_ID, ATTR_TARGET, + ATTR_TIMEOUT, ATTR_URL, ATTR_USERNAME, ATTR_VERIFY_SSL, CONF_CONFIG_ENTRY_ID, - CONF_PLATFORM, DOMAIN, PLATFORM_BROADCAST, SERVICE_ANSWER_CALLBACK_QUERY, @@ -54,12 +66,12 @@ from homeassistant.components.telegram_bot import ( SERVICE_SEND_STICKER, SERVICE_SEND_VIDEO, SERVICE_SEND_VOICE, - async_setup_entry, ) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, + CONF_PLATFORM, HTTP_BASIC_AUTHENTICATION, HTTP_BEARER_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, @@ -95,6 +107,26 @@ async def test_polling_platform_init(hass: HomeAssistant, polling_platform) -> N SERVICE_SEND_MESSAGE, {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, ), + ( + SERVICE_SEND_MESSAGE, + { + ATTR_KEYBOARD: ["/command1, /command2", "/command3"], + ATTR_MESSAGE: "test_message", + ATTR_PARSER: ParseMode.HTML, + ATTR_TIMEOUT: 15, + ATTR_DISABLE_NOTIF: True, + ATTR_DISABLE_WEB_PREV: True, + ATTR_MESSAGE_TAG: "mock_tag", + ATTR_REPLY_TO_MSGID: 12345, + }, + ), + ( + SERVICE_SEND_MESSAGE, + { + ATTR_KEYBOARD: [], + ATTR_MESSAGE: "test_message", + }, + ), ( SERVICE_SEND_STICKER, { @@ -144,6 +176,95 @@ async def test_send_message( assert (response["chats"][0]["message_id"]) == 12345 +@pytest.mark.parametrize( + ("input", "expected"), + [ + ( + { + ATTR_MESSAGE: "test_message", + ATTR_KEYBOARD_INLINE: "command1:/cmd1,/cmd2,mock_link:https://mock_link", + }, + InlineKeyboardMarkup( + # 1 row with 3 buttons + [ + [ + InlineKeyboardButton(callback_data="/cmd1", text="command1"), + InlineKeyboardButton(callback_data="/cmd2", text="CMD2"), + InlineKeyboardButton(url="https://mock_link", text="mock_link"), + ] + ] + ), + ), + ( + { + ATTR_MESSAGE: "test_message", + ATTR_KEYBOARD_INLINE: [ + [["command1", "/cmd1"]], + [["mock_link", "https://mock_link"]], + ], + }, + InlineKeyboardMarkup( + # 2 rows each with 1 button + [ + [InlineKeyboardButton(callback_data="/cmd1", text="command1")], + [InlineKeyboardButton(url="https://mock_link", text="mock_link")], + ] + ), + ), + ], +) +async def test_send_message_with_inline_keyboard( + hass: HomeAssistant, + webhook_platform, + input: dict[str, Any], + expected: InlineKeyboardMarkup, +) -> None: + """Test the send_message service. + + Tests any service that does not require files to be sent. + """ + context = Context() + events = async_capture_events(hass, "telegram_sent") + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.send_message", + AsyncMock( + return_value=Message( + message_id=12345, + date=datetime.now(), + chat=Chat(id=123456, type=ChatType.PRIVATE), + ) + ), + ) as mock_send_message: + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + input, + blocking=True, + context=context, + return_response=True, + ) + await hass.async_block_till_done() + + mock_send_message.assert_called_once_with( + 12345678, + "test_message", + parse_mode=ParseMode.MARKDOWN, + disable_web_page_preview=None, + disable_notification=False, + reply_to_message_id=None, + reply_markup=expected, + read_timeout=None, + message_thread_id=None, + ) + + assert len(events) == 1 + assert events[0].context == context + + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + + @patch( "builtins.open", mock_open( @@ -752,20 +873,27 @@ async def test_answer_callback_query( await hass.async_block_till_done() with patch( - "homeassistant.components.telegram_bot.bot.TelegramNotificationService.answer_callback_query" + "homeassistant.components.telegram_bot.bot.Bot.answer_callback_query" ) as mock: await hass.services.async_call( DOMAIN, SERVICE_ANSWER_CALLBACK_QUERY, { ATTR_MESSAGE: "mock message", - ATTR_CALLBACK_QUERY_ID: 12345, + ATTR_CALLBACK_QUERY_ID: 123456, + ATTR_SHOW_ALERT: True, }, blocking=True, ) await hass.async_block_till_done() mock.assert_called_once() + mock.assert_called_with( + 123456, + text="mock message", + show_alert=True, + read_timeout=None, + ) async def test_leave_chat( @@ -779,20 +907,23 @@ async def test_leave_chat( await hass.async_block_till_done() with patch( - "homeassistant.components.telegram_bot.bot.TelegramNotificationService.leave_chat", + "homeassistant.components.telegram_bot.bot.Bot.leave_chat", AsyncMock(return_value=True), ) as mock: await hass.services.async_call( DOMAIN, SERVICE_LEAVE_CHAT, { - ATTR_CHAT_ID: 12345, + ATTR_CHAT_ID: 123456, }, blocking=True, ) await hass.async_block_till_done() mock.assert_called_once() + mock.assert_called_with( + 123456, + ) async def test_send_video( @@ -974,3 +1105,39 @@ async def test_send_video( await hass.async_block_till_done() assert mock_get.call_count > 0 assert response["chats"][0]["message_id"] == 12345 + + +async def test_set_message_reaction( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, +) -> None: + """Test set message reaction.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.set_message_reaction", + AsyncMock(return_value=True), + ) as mock: + await hass.services.async_call( + DOMAIN, + "set_message_reaction", + { + ATTR_CHAT_ID: 123456, + ATTR_MESSAGEID: 54321, + "reaction": "👍", + "is_big": True, + }, + blocking=True, + ) + + await hass.async_block_till_done() + mock.assert_called_once_with( + 123456, + 54321, + reaction="👍", + is_big=True, + read_timeout=None, + ) diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 312c04b670c..fd45c3b008b 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -16,6 +16,22 @@ from homeassistant.components.blueprint import ( DomainBlueprints, ) from homeassistant.components.template import DOMAIN, SERVICE_RELOAD +from homeassistant.components.template.config import ( + DOMAIN_ALARM_CONTROL_PANEL, + DOMAIN_BINARY_SENSOR, + DOMAIN_COVER, + DOMAIN_FAN, + DOMAIN_IMAGE, + DOMAIN_LIGHT, + DOMAIN_LOCK, + DOMAIN_NUMBER, + DOMAIN_SELECT, + DOMAIN_SENSOR, + DOMAIN_SWITCH, + DOMAIN_VACUUM, + DOMAIN_WEATHER, +) +from homeassistant.const import STATE_ON from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -459,3 +475,51 @@ async def test_no_blueprint(hass: HomeAssistant) -> None: template.helpers.blueprint_in_template(hass, "binary_sensor.test_entity") is None ) + + +@pytest.mark.parametrize( + ("domain", "set_state", "expected"), + [ + (DOMAIN_ALARM_CONTROL_PANEL, STATE_ON, "armed_home"), + (DOMAIN_BINARY_SENSOR, STATE_ON, STATE_ON), + (DOMAIN_COVER, STATE_ON, "open"), + (DOMAIN_FAN, STATE_ON, STATE_ON), + (DOMAIN_IMAGE, "test.jpg", "2025-06-13T00:00:00+00:00"), + (DOMAIN_LIGHT, STATE_ON, STATE_ON), + (DOMAIN_LOCK, STATE_ON, "locked"), + (DOMAIN_NUMBER, "1", "1.0"), + (DOMAIN_SELECT, "option1", "option1"), + (DOMAIN_SENSOR, "foo", "foo"), + (DOMAIN_SWITCH, STATE_ON, STATE_ON), + (DOMAIN_VACUUM, "cleaning", "cleaning"), + (DOMAIN_WEATHER, "sunny", "sunny"), + ], +) +@pytest.mark.freeze_time("2025-06-13 00:00:00+00:00") +async def test_variables_for_entity( + hass: HomeAssistant, domain: str, set_state: str, expected: str +) -> None: + """Test regular template entities via blueprint with variables defined.""" + hass.states.async_set("sensor.test_state", set_state) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": f"test_{domain}_with_variables.yaml", + "input": {"sensor": "sensor.test_state"}, + }, + "name": "Test", + }, + ] + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.test") + assert state is not None + assert state.state == expected diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index 31239dbaf92..77d316ce89d 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -11,7 +11,10 @@ from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.template import DOMAIN from homeassistant.components.template.button import DEFAULT_NAME +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_ICON, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, @@ -247,6 +250,49 @@ async def test_name_template(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + ("field", "attribute", "test_template", "expected"), + [ + (CONF_ICON, ATTR_ICON, "mdi:test{{ 1 + 1 }}", "mdi:test2"), + (CONF_PICTURE, ATTR_ENTITY_PICTURE, "test{{ 1 + 1 }}.jpg", "test2.jpg"), + ], +) +async def test_templated_optional_config( + hass: HomeAssistant, + field: str, + attribute: str, + test_template: str, + expected: str, +) -> None: + """Test optional config templates.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": {"service": "script.press"}, + field: test_template, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify( + hass, + STATE_UNKNOWN, + { + attribute: expected, + }, + "button.template_button", + ) + + async def test_unique_id(hass: HomeAssistant) -> None: """Test: unique id is ok.""" with assert_setup_component(1, "template"): diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 5201541e2e0..a15ae1e46c0 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -21,10 +21,13 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE, ) from homeassistant.components.template import DOMAIN +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, ATTR_ICON, CONF_ENTITY_ID, + CONF_ICON, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, ) @@ -58,6 +61,20 @@ _VALUE_INPUT_NUMBER_CONFIG = { } } +TEST_STATE_ENTITY_ID = "number.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} +TEST_REQUIRED = {"state": "0", "step": "1", "set_value": []} + async def async_setup_modern_format( hass: HomeAssistant, count: int, number_config: dict[str, Any] @@ -77,6 +94,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, number_config: dict[str, Any] +) -> None: + """Do setup of number integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "number": number_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_number( hass: HomeAssistant, @@ -89,6 +124,10 @@ async def setup_number( await async_setup_modern_format( hass, count, {"name": _TEST_OBJECT_ID, **number_config} ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": _TEST_OBJECT_ID, **number_config} + ) async def test_setup_config_entry( @@ -446,119 +485,49 @@ def _verify( assert attributes.get(CONF_UNIT_OF_MEASUREMENT) == expected_unit_of_measurement -async def test_icon_template(hass: HomeAssistant) -> None: - """Test template numbers with icon templates.""" - with assert_setup_component(1, "input_number"): - assert await setup.async_setup_component( - hass, - "input_number", - {"input_number": _VALUE_INPUT_NUMBER_CONFIG}, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("number_config", "attribute", "expected"), + [ + ( { - "template": { - "unique_id": "b", - "number": { - "state": f"{{{{ states('{_VALUE_INPUT_NUMBER}') }}}}", - "step": 1, - "min": 0, - "max": 100, - "set_value": { - "service": "input_number.set_value", - "data_template": { - "entity_id": _VALUE_INPUT_NUMBER, - "value": "{{ value }}", - }, - }, - "icon": "{% if ((states.input_number.value.state or 0) | int) > 50 %}mdi:greater{% else %}mdi:less{% endif %}", - }, - } + CONF_ICON: "{% if states.number.test_state.state == '1' %}mdi:check{% endif %}", + **TEST_REQUIRED, }, - ) - - hass.states.async_set(_VALUE_INPUT_NUMBER, 49) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 49 - assert state.attributes[ATTR_ICON] == "mdi:less" - - await hass.services.async_call( - INPUT_NUMBER_DOMAIN, - INPUT_NUMBER_SERVICE_SET_VALUE, - {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 51}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 51 - assert state.attributes[ATTR_ICON] == "mdi:greater" - - -async def test_icon_template_with_trigger(hass: HomeAssistant) -> None: - """Test template numbers with icon templates.""" - with assert_setup_component(1, "input_number"): - assert await setup.async_setup_component( - hass, - "input_number", - {"input_number": _VALUE_INPUT_NUMBER_CONFIG}, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", + ATTR_ICON, + "mdi:check", + ), + ( { - "template": { - "trigger": {"platform": "state", "entity_id": _VALUE_INPUT_NUMBER}, - "unique_id": "b", - "number": { - "state": "{{ trigger.to_state.state }}", - "step": 1, - "min": 0, - "max": 100, - "set_value": { - "service": "input_number.set_value", - "data_template": { - "entity_id": _VALUE_INPUT_NUMBER, - "value": "{{ value }}", - }, - }, - "icon": "{% if ((trigger.to_state.state or 0) | int) > 50 %}mdi:greater{% else %}mdi:less{% endif %}", - }, - } + CONF_PICTURE: "{% if states.number.test_state.state == '1' %}check.jpg{% endif %}", + **TEST_REQUIRED, }, - ) + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_number") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(_TEST_NUMBER) + assert state.attributes.get(attribute) == initial_expected_state - hass.states.async_set(_VALUE_INPUT_NUMBER, 49) - - await hass.async_block_till_done() - await hass.async_start() + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "1") await hass.async_block_till_done() state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 49 - assert state.attributes[ATTR_ICON] == "mdi:less" - await hass.services.async_call( - INPUT_NUMBER_DOMAIN, - INPUT_NUMBER_SERVICE_SET_VALUE, - {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 51}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 51 - assert state.attributes[ATTR_ICON] == "mdi:greater" + assert state.attributes[attribute] == expected async def test_device_id( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index b2bc56af44a..5e29993f0f6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -21,7 +21,15 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, ) from homeassistant.components.template import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.components.template.const import CONF_PICTURE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + ATTR_ICON, + CONF_ENTITY_ID, + CONF_ICON, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -34,6 +42,24 @@ _TEST_OBJECT_ID = "template_select" _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" +TEST_STATE_ENTITY_ID = "select.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} + +TEST_OPTIONS = { + "state": "test", + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], +} async def async_setup_modern_format( @@ -54,6 +80,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, select_config: dict[str, Any] +) -> None: + """Do setup of select integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "select": select_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_select( hass: HomeAssistant, @@ -66,6 +110,10 @@ async def setup_select( await async_setup_modern_format( hass, count, {"name": _TEST_OBJECT_ID, **select_config} ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": _TEST_OBJECT_ID, **select_config} + ) async def test_setup_config_entry( @@ -395,138 +443,49 @@ def _verify( assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options -async def test_template_icon_with_entities(hass: HomeAssistant) -> None: - """Test templates with values from other entities.""" - with assert_setup_component(1, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("select_config", "attribute", "expected"), + [ + ( { - "input_select": { - "option": { - "options": ["a", "b"], - "initial": "a", - "name": "Option", - }, - } + **TEST_OPTIONS, + CONF_ICON: "{% if states.select.test_state.state == 'yes' %}mdi:check{% endif %}", }, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", + ATTR_ICON, + "mdi:check", + ), + ( { - "template": { - "unique_id": "b", - "select": { - "state": f"{{{{ states('{_OPTION_INPUT_SELECT}') }}}}", - "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", - "select_option": { - "service": "input_select.select_option", - "data": { - "entity_id": _OPTION_INPUT_SELECT, - "option": "{{ option }}", - }, - }, - "optimistic": True, - "unique_id": "a", - "icon": f"{{% if (states('{_OPTION_INPUT_SELECT}') == 'a') %}}mdi:greater{{% else %}}mdi:less{{% endif %}}", - }, - } + **TEST_OPTIONS, + CONF_PICTURE: "{% if states.select.test_state.state == 'yes' %}check.jpg{% endif %}", }, - ) + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_select") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(_TEST_SELECT) + assert state.attributes.get(attribute) == initial_expected_state - await hass.async_block_till_done() - await hass.async_start() + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "yes") await hass.async_block_till_done() state = hass.states.get(_TEST_SELECT) - assert state.state == "a" - assert state.attributes[ATTR_ICON] == "mdi:greater" - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state.state == "b" - assert state.attributes[ATTR_ICON] == "mdi:less" - - -async def test_template_icon_with_trigger(hass: HomeAssistant) -> None: - """Test trigger based template select.""" - with assert_setup_component(1, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", - { - "input_select": { - "option": { - "options": ["a", "b"], - "initial": "a", - "name": "Option", - }, - } - }, - ) - - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "trigger": {"platform": "state", "entity_id": _OPTION_INPUT_SELECT}, - "select": { - "unique_id": "b", - "state": "{{ trigger.to_state.state }}", - "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", - "select_option": { - "service": "input_select.select_option", - "data": { - "entity_id": _OPTION_INPUT_SELECT, - "option": "{{ option }}", - }, - }, - "optimistic": True, - "icon": "{% if (trigger.to_state.state or '') == 'a' %}mdi:greater{% else %}mdi:less{% endif %}", - }, - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state is not None - assert state.state == "b" - assert state.attributes[ATTR_ICON] == "mdi:less" - - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "a"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state.state == "a" - assert state.attributes[ATTR_ICON] == "mdi:greater" + assert state.attributes[attribute] == expected async def test_device_id( diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 5db6a000ccc..443b0aa6e77 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -5,6 +5,8 @@ from typing import Any import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import template +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.components.weather import ( ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, @@ -21,12 +23,21 @@ from homeassistant.components.weather import ( SERVICE_GET_FORECASTS, Forecast, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_PICTURE, + ATTR_ICON, + CONF_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from .conftest import ConfigurationStyle + from tests.common import ( assert_setup_component, async_mock_restore_state_shutdown_restart, @@ -35,6 +46,80 @@ from tests.common import ( ATTR_FORECAST = "forecast" +TEST_OBJECT_ID = "template_weather" +TEST_WEATHER = f"weather.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "weather.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} +TEST_REQUIRED = { + "condition_template": "cloudy", + "temperature_template": "{{ 20 }}", + "humidity_template": "{{ 25 }}", +} + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, weather_config: dict[str, Any] +) -> None: + """Do setup of weather integration via new format.""" + config = {"template": {"weather": weather_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, weather_config: dict[str, Any] +) -> None: + """Do setup of weather integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "weather": weather_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_weather( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + weather_config: dict[str, Any], +) -> None: + """Do setup of weather integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **weather_config} + ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **weather_config} + ) + @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( @@ -990,3 +1075,48 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: assert state is not None assert state.state == "sunny" assert state.attributes.get(v_attr) == value + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("weather_config", "attribute", "expected"), + [ + ( + { + CONF_ICON: "{% if states.weather.test_state.state == 'sunny' %}mdi:check{% endif %}", + **TEST_REQUIRED, + }, + ATTR_ICON, + "mdi:check", + ), + ( + { + CONF_PICTURE: "{% if states.weather.test_state.state == 'sunny' %}check.jpg{% endif %}", + **TEST_REQUIRED, + }, + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_weather") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(TEST_WEATHER) + assert state.attributes.get(attribute) == initial_expected_state + + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "sunny") + await hass.async_block_till_done() + + state = hass.states.get(TEST_WEATHER) + + assert state.attributes[attribute] == expected diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index fae79c795c2..6f700f7e939 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -401,7 +401,8 @@ async def test_climate_noscope( entity_id = "climate.test_climate" with pytest.raises( - ServiceValidationError, match="Climate mode off is not supported" + ServiceValidationError, + match="HVAC mode off is not valid. Valid HVAC modes are: heat_cool", ): await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index 6cb8c60ac0c..4a8142a2d85 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -1,16 +1,23 @@ """Test the Tesla Fleet config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from urllib.parse import parse_qs, urlparse import pytest +from tesla_fleet_api.exceptions import ( + InvalidResponse, + PreconditionFailed, + TeslaFleetError, +) from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.components.tesla_fleet.config_flow import OAuth2FlowHandler from homeassistant.components.tesla_fleet.const import ( AUTHORIZE_URL, + CONF_DOMAIN, DOMAIN, SCOPES, TOKEN_URL, @@ -64,15 +71,30 @@ async def create_credential(hass: HomeAssistant) -> None: ) +@pytest.fixture +def mock_private_key(): + """Mock private key for testing.""" + private_key = Mock() + public_key = Mock() + private_key.public_key.return_value = public_key + public_key.public_bytes.side_effect = [ + b"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n-----END PUBLIC KEY-----", + bytes.fromhex( + "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + ), + ] + return private_key + + @pytest.mark.usefixtures("current_request_with_host") -async def test_full_flow_user_cred( +async def test_full_flow_with_domain_registration( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, access_token: str, + mock_private_key, ) -> None: - """Check full flow.""" - + """Test full flow with domain registration.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -95,7 +117,7 @@ async def test_full_flow_user_cred( assert parsed_query["redirect_uri"][0] == REDIRECT assert parsed_query["state"][0] == state assert parsed_query["scope"][0] == " ".join(SCOPES) - assert "code_challenge" not in parsed_query # Ensure not a PKCE flow + assert "code_challenge" not in parsed_query client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") @@ -112,21 +134,416 @@ async def test_full_flow_user_cred( "expires_in": 60, }, ) - with patch( - "homeassistant.components.tesla_fleet.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + patch( + "homeassistant.components.tesla_fleet.async_setup_entry", return_value=True + ), + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + mock_api.partner.register.return_value = { + "response": { + "public_key": "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + } + } + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + # Enter domain - this should automatically register and go to registration_complete + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + + # Complete flow - provide user input to complete registration + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == UNIQUE_ID - assert "result" in result assert result["result"].unique_id == UNIQUE_ID - assert "token" in result["result"].data - assert result["result"].data["token"]["access_token"] == access_token - assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_input_invalid_domain( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain input with invalid domain.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + # Enter invalid domain + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "invalid-domain"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + assert result["errors"] == {CONF_DOMAIN: "invalid_domain"} + + # Enter valid domain - this should automatically register and go to registration_complete + mock_api.public_uncompressed_point = "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + mock_api.partner.register.return_value = { + "response": { + "public_key": "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + } + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (InvalidResponse, "invalid_response"), + (TeslaFleetError("Custom error"), "unknown_error"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_errors( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, + side_effect, + expected_error, +) -> None: + """Test domain registration with errors that stay on domain_registration step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.side_effect = side_effect + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + with patch( + "homeassistant.helpers.translation.async_get_translations", return_value={} + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": expected_error} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_precondition_failed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with PreconditionFailed redirects to domain_input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.side_effect = PreconditionFailed + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should go to domain_registration and then fail back to domain_input + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + assert result["errors"] == {CONF_DOMAIN: "precondition_failed"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_public_key_not_found( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with missing public key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.return_value = {"response": {}} + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": "public_key_not_found"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_public_key_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with public key mismatch.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "expected_key" + mock_api.partner.register.return_value = { + "response": {"public_key": "different_key"} + } + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": "public_key_mismatch"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_registration_complete_no_domain( + hass: HomeAssistant, +) -> None: + """Test registration complete step without domain.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = None + + result = await flow_instance.async_step_registration_complete({}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + +async def test_registration_complete_with_domain_and_user_input( + hass: HomeAssistant, +) -> None: + """Test registration complete step with domain and user input.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = "example.com" + flow_instance.uid = UNIQUE_ID + flow_instance.data = {"token": {"access_token": "test"}} + + result = await flow_instance.async_step_registration_complete({"complete": True}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == UNIQUE_ID + + +async def test_registration_complete_with_domain_no_user_input( + hass: HomeAssistant, +) -> None: + """Test registration complete step with domain but no user input.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = "example.com" + + result = await flow_instance.async_step_registration_complete(None) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + assert ( + result["description_placeholders"]["virtual_key_url"] + == "https://www.tesla.com/_ak/example.com" + ) @pytest.mark.usefixtures("current_request_with_host") @@ -225,3 +642,89 @@ async def test_reauth_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_account_mismatch" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_unique_id_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, +) -> None: + """Test duplicate unique ID aborts flow.""" + # Create existing entry + existing_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + version=1, + data={}, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + # Complete OAuth - should abort due to duplicate unique_id + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_confirm_form(hass: HomeAssistant) -> None: + """Test reauth confirm form display.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + version=1, + data={}, + ) + old_entry.add_to_hass(hass) + + result = await old_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"name": "Tesla Fleet"} + + +@pytest.mark.parametrize( + ("domain", "expected_valid"), + [ + ("example.com", True), + ("test.example.com", True), + ("sub.domain.example.org", True), + ("https://example.com", False), + ("invalid-domain", False), + ("", False), + ("example", False), + ("example.", False), + (".example.com", False), + ("exam ple.com", False), + ], +) +def test_is_valid_domain(domain: str, expected_valid: bool) -> None: + """Test domain validation.""" + + assert OAuth2FlowHandler()._is_valid_domain(domain) == expected_valid diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py index 1ad5ea1ca3d..affdadd75c2 100644 --- a/tests/components/toon/test_config_flow.py +++ b/tests/components/toon/test_config_flow.py @@ -27,13 +27,12 @@ async def setup_component(hass: HomeAssistant) -> None: {"external_url": "https://example.com"}, ) - with patch("os.path.isfile", return_value=False): - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"}}, - ) - await hass.async_block_till_done() + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"}}, + ) + await hass.async_block_till_done() async def test_abort_if_no_configuration(hass: HomeAssistant) -> None: diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index adcca24886b..6d5b498b922 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -161,7 +161,7 @@ async def test_set_hvac_mode( ) therm_module.set_state.assert_called_with(True) - msg = "Tried to set unsupported mode: dry" + msg = "HVAC mode dry is not valid. Valid HVAC modes are: heat, off" with pytest.raises(ServiceValidationError, match=msg): await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index fb90262a084..eb864cadd87 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -146,8 +146,12 @@ async def test_enter_and_exit( assert len(entity_registry.entities) == 1 -async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None: - """Test when additional attributes are present.""" +async def test_enter_with_attrs_as_query( + hass: HomeAssistant, + client, + webhook_id, +) -> None: + """Test when additional attributes are present URL query.""" url = f"/api/webhook/{webhook_id}" data = { "timestamp": 123456789, @@ -197,6 +201,45 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None assert state.attributes["altitude"] == 123 +async def test_enter_with_attrs_as_payload( + hass: HomeAssistant, client, webhook_id +) -> None: + """Test when additional attributes are present in JSON payload.""" + url = f"/api/webhook/{webhook_id}" + data = { + "location": { + "coords": { + "heading": "105.32", + "latitude": "1.0", + "longitude": "1.1", + "accuracy": 10.5, + "altitude": 102.0, + "speed": 100.0, + }, + "extras": {}, + "manual": True, + "is_moving": False, + "_": "&id=123&lat=1.0&lon=1.1×tamp=2013-09-17T07:32:51Z&", + "odometer": 0, + "activity": {"type": "still"}, + "timestamp": "2013-09-17T07:32:51Z", + "battery": {"level": 0.1, "is_charging": False}, + }, + "device_id": "123", + } + + req = await client.post(url, json=data) + await hass.async_block_till_done() + assert req.status == HTTPStatus.OK + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device_id']}") + assert state.state == STATE_NOT_HOME + assert state.attributes["gps_accuracy"] == 10.5 + assert state.attributes["battery_level"] == 10.0 + assert state.attributes["speed"] == 100.0 + assert state.attributes["bearing"] == 105.32 + assert state.attributes["altitude"] == 102.0 + + async def test_two_devices(hass: HomeAssistant, client, webhook_id) -> None: """Test updating two different devices.""" url = f"/api/webhook/{webhook_id}" diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index b4fab54e98d..b3e5d17c728 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -31,7 +31,6 @@ from .common import async_start from tests.common import ( MockConfigEntry, MockEntity, - MockEntityPlatform, MockModule, help_test_all, import_and_test_deprecated_constant_enum, @@ -263,44 +262,6 @@ async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> N assert "test" in strings -async def test_supported_features_compat(hass: HomeAssistant) -> None: - """Test StateVacuumEntity using deprecated feature constants features.""" - - features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.PAUSE - ) - - class _LegacyConstantsStateVacuum(StateVacuumEntity): - _attr_supported_features = int(features) - _attr_fan_speed_list = ["silent", "normal", "pet hair"] - - entity = _LegacyConstantsStateVacuum() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert isinstance(entity.supported_features, int) - assert entity.supported_features == int(features) - assert entity.supported_features_compat is ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.PAUSE - ) - assert entity.state_attributes == { - "battery_level": None, - "battery_icon": "mdi:battery-unknown", - "fan_speed": None, - } - assert entity.capability_attributes == { - "fan_speed_list": ["silent", "normal", "pet hair"] - } - assert entity._deprecated_supported_features_reported - - async def test_vacuum_not_log_deprecated_state_warning( hass: HomeAssistant, mock_vacuum_entity: MockVacuum, diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index d347777f7e8..83e39d2f602 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -216,6 +216,31 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None await hass.async_block_till_done() +async def setup_integration_no_eco_mode( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class setup.""" + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=HTTPStatus.OK, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response_no_power_boost, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, + status_code=HTTPStatus.OK, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async def setup_integration_select( hass: HomeAssistant, entry: MockConfigEntry, response ) -> None: diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index b4b5a199243..6d6a5cd1417 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -13,6 +13,7 @@ from . import ( authorisation_response, setup_integration, setup_integration_connection_error, + setup_integration_no_eco_mode, setup_integration_read_only, test_response, ) @@ -138,3 +139,15 @@ async def test_wallbox_refresh_failed_read_only( assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_setup_load_entry_no_eco_mode( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox Unload.""" + + await setup_integration_no_eco_mode(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 2c36c713546..6157da04256 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -300,7 +300,7 @@ async def test_service_hvac_mode_turn_on( ( SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVACMode.DRY}, - ValueError, + ServiceValidationError, ), ( SERVICE_SET_FAN_MODE, diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 800870f4604..dec5d6cbebd 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -1365,3 +1365,110 @@ async def test_announce( # Stop the satellite await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_tts_timeout( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity state goes back to IDLE on a timeout.""" + events = [ + Info(satellite=SATELLITE_INFO.satellite).event(), + RunPipeline(start_stage=PipelineStage.TTS, end_stage=PipelineStage.TTS).event(), + ] + + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + + response_finished = asyncio.Event() + + def tts_response_finished(self): + response_finished.set() + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), + patch( + "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite.tts_response_finished", + tts_response_finished, + ), + patch( + "homeassistant.components.wyoming.assist_satellite._TTS_TIMEOUT_EXTRA", + 0, + ), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][entry.entry_id].device + assert device is not None + + satellite_entry = next( + ( + maybe_entry + for maybe_entry in er.async_entries_for_device( + entity_registry, device.device_id + ) + if maybe_entry.domain == assist_satellite.DOMAIN + ), + None, + ) + assert satellite_entry is not None + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Reset so we can check the pipeline is automatically restarted below + run_pipeline_called.clear() + + assert pipeline_event_callback is not None + assert pipeline_kwargs.get("device_id") == device.device_id + + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"token": mock_tts_result_stream.token}}, + ) + ) + async with asyncio.timeout(1): + # tts_response_finished should be called on timeout + await response_finished.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/xiaomi_miio/snapshots/test_fan.ambr b/tests/components/xiaomi_miio/snapshots/test_fan.ambr new file mode 100644 index 00000000000..0a0ad2e6d31 --- /dev/null +++ b/tests/components/xiaomi_miio/snapshots/test_fan.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_fan_status[dmaker.fan.p18][fan.test_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'Normal', + 'Nature', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.test_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'xiaomi_miio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'generic_fan', + 'unique_id': '123456', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_status[dmaker.fan.p18][fan.test_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': None, + 'friendly_name': 'test_fan', + 'oscillating': None, + 'percentage': None, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': list([ + 'Normal', + 'Nature', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.test_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_fan_status[dmaker.fan.p5][fan.test_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'Normal', + 'Nature', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.test_fan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'xiaomi_miio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'generic_fan', + 'unique_id': '123456', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_status[dmaker.fan.p5][fan.test_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': None, + 'friendly_name': 'test_fan', + 'oscillating': False, + 'percentage': None, + 'percentage_step': 1.0, + 'preset_mode': 'Nature', + 'preset_modes': list([ + 'Normal', + 'Nature', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.test_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/xiaomi_miio/test_fan.py b/tests/components/xiaomi_miio/test_fan.py new file mode 100644 index 00000000000..93aa3673187 --- /dev/null +++ b/tests/components/xiaomi_miio/test_fan.py @@ -0,0 +1,130 @@ +"""The tests for the xiaomi_miio fan component.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, Mock, patch + +from miio.integrations.fan.dmaker.fan import FanStatusP5 +from miio.integrations.fan.dmaker.fan_miot import FanStatusMiot +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.xiaomi_miio import MODEL_TO_CLASS_MAP +from homeassistant.components.xiaomi_miio.const import CONF_FLOW_TYPE, DOMAIN +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_TOKEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import TEST_MAC + +from tests.common import MockConfigEntry, snapshot_platform + +_MODEL_INFORMATION = { + "dmaker.fan.p5": { + "patch_class": "homeassistant.components.xiaomi_miio.FanP5", + "mock_status": FanStatusP5( + { + "roll_angle": 60, + "beep_sound": False, + "child_lock": False, + "time_off": 0, + "power": False, + "light": True, + "mode": "nature", + "roll_enable": False, + "speed": 64, + } + ), + }, + "dmaker.fan.p18": { + "patch_class": "homeassistant.components.xiaomi_miio.FanMiot", + "mock_status": FanStatusMiot( + { + "swing_mode_angle": 90, + "buzzer": False, + "child_lock": False, + "power_off_time": 0, + "power": False, + "light": True, + "mode": 0, + "swing_mode": False, + "fan_speed": 100, + } + ), + }, +} + + +@pytest.fixture( + name="model_code", + params=_MODEL_INFORMATION.keys(), +) +def get_model_code(request: pytest.FixtureRequest) -> str: + """Parametrize model code.""" + return request.param + + +@pytest.fixture(autouse=True) +def setup_device(model_code: str) -> Generator[MagicMock]: + """Initialize test xiaomi_miio for fan entity.""" + + model_information = _MODEL_INFORMATION[model_code] + + mock_fan = MagicMock() + mock_fan.status = Mock(return_value=model_information["mock_status"]) + + with ( + patch( + "homeassistant.components.xiaomi_miio.get_platforms", + return_value=[Platform.FAN], + ), + patch(model_information["patch_class"]) as mock_fan_cls, + patch.dict( + MODEL_TO_CLASS_MAP, + {model_code: mock_fan_cls} if model_code in MODEL_TO_CLASS_MAP else {}, + ), + ): + mock_fan_cls.return_value = mock_fan + yield mock_fan + + +async def setup_component( + hass: HomeAssistant, model_code: str, entry_title: str +) -> MockConfigEntry: + """Set up fan component.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123456", + title=entry_title, + data={ + CONF_FLOW_TYPE: CONF_DEVICE, + CONF_HOST: "192.168.1.100", + CONF_TOKEN: "12345678901234567890123456789012", + CONF_MODEL: model_code, + CONF_MAC: TEST_MAC, + }, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +async def test_fan_status( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model_code: str, + snapshot: SnapshotAssertion, +) -> None: + """Test fan status.""" + + config_entry = await setup_component(hass, model_code, "test_fan") + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 7b94db51d04..3425c1eb2b6 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -522,20 +522,28 @@ async def test_set_hvac_mode( state = hass.states.get(entity_id) assert state.state == HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, - blocking=True, - ) - state = hass.states.get(entity_id) if sys_mode is not None: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + state = hass.states.get(entity_id) assert state.state == hvac_mode assert thrm_cluster.write_attributes.call_count == 1 assert thrm_cluster.write_attributes.call_args[0][0] == { "system_mode": sys_mode } else: + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + state = hass.states.get(entity_id) assert thrm_cluster.write_attributes.call_count == 0 assert state.state == HVACMode.OFF diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 64bc981de11..578eeab5ec7 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -21,7 +21,6 @@ ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" VOLTAGE_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_3" CURRENT_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_4" SWITCH_ENTITY = "switch.smart_plug_with_two_usb_ports" -LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" NOTIFICATION_MOTION_BINARY_SENSOR = "binary_sensor.multisensor_6_motion_detection" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index e0485ced091..138bcd63ede 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -199,6 +199,12 @@ def climate_heatit_z_trm3_no_value_state_fixture() -> dict[str, Any]: return load_json_object_fixture("climate_heatit_z_trm3_no_value_state.json", DOMAIN) +@pytest.fixture(name="ring_keypad_state", scope="package") +def ring_keypad_state_fixture() -> dict[str, Any]: + """Load the Ring keypad state fixture data.""" + return load_json_object_fixture("ring_keypad_state.json", DOMAIN) + + @pytest.fixture(name="nortek_thermostat_state", scope="package") def nortek_thermostat_state_fixture() -> dict[str, Any]: """Load the nortek thermostat node state fixture data.""" @@ -295,6 +301,12 @@ def shelly_europe_ltd_qnsh_001p10_state_fixture() -> dict[str, Any]: return load_json_object_fixture("shelly_europe_ltd_qnsh_001p10_state.json", DOMAIN) +@pytest.fixture(name="touchwand_glass9_state", scope="package") +def touchwand_glass9_state_fixture() -> dict[str, Any]: + """Load the Touchwand Glass 9 shutter node state fixture data.""" + return load_json_object_fixture("touchwand_glass9_state.json", DOMAIN) + + @pytest.fixture(name="merten_507801_state", scope="package") def merten_507801_state_fixture() -> dict[str, Any]: """Load the Merten 507801 Shutter node state fixture data.""" @@ -876,6 +888,14 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: return Event("node removed", event_data) +@pytest.fixture(name="ring_keypad") +def ring_keypad_fixture(client: MagicMock, ring_keypad_state: NodeDataType) -> Node: + """Mock a Ring keypad node.""" + node = Node(client, copy.deepcopy(ring_keypad_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="integration") async def integration_fixture( hass: HomeAssistant, @@ -1026,6 +1046,14 @@ def shelly_qnsh_001P10_cover_shutter_fixture( return node +@pytest.fixture(name="touchwand_glass9") +def touchwand_glass9_fixture(client, touchwand_glass9_state) -> Node: + """Mock a Touchwand glass9 node.""" + node = Node(client, copy.deepcopy(touchwand_glass9_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="merten_507801") def merten_507801_cover_fixture(client, merten_507801_state) -> Node: """Mock a Merten 507801 Shutter node.""" diff --git a/tests/components/zwave_js/fixtures/cover_mco_home_glass_9_shutter_state.json b/tests/components/zwave_js/fixtures/cover_mco_home_glass_9_shutter_state.json new file mode 100644 index 00000000000..13b5d0495f9 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_mco_home_glass_9_shutter_state.json @@ -0,0 +1,4988 @@ +{ + "home_assistant": { + "installation_type": "Home Assistant Container", + "version": "2024.7.4", + "dev": false, + "hassio": false, + "virtualenv": false, + "python_version": "3.12.4", + "docker": true, + "arch": "armv7l", + "timezone": "Asia/Jerusalem", + "os_name": "Linux", + "os_version": "5.4.142-g5227ff0e2a5c-dirty", + "run_as_root": true + }, + "custom_components": { + "oref_alert": { + "documentation": "https://github.com/amitfin/oref_alert", + "version": "v2.11.3", + "requirements": ["haversine==2.8.1", "shapely==2.0.4"] + }, + "scheduler": { + "documentation": "https://github.com/nielsfaber/scheduler-component", + "version": "v0.0.0", + "requirements": [] + }, + "hebcal": { + "documentation": "https://github.com/rt400/Jewish-Sabbaths-Holidays", + "version": "2.4.0", + "requirements": [] + }, + "hacs": { + "documentation": "https://hacs.xyz/docs/configuration/start", + "version": "1.34.0", + "requirements": ["aiogithubapi>=22.10.1"] + } + }, + "integration_manifest": { + "domain": "zwave_js", + "name": "Z-Wave", + "codeowners": ["home-assistant/z-wave"], + "config_flow": true, + "dependencies": ["http", "repairs", "usb", "websocket_api"], + "documentation": "https://www.home-assistant.io/integrations/zwave_js", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["zwave_js_server"], + "quality_scale": "platinum", + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.57.0"], + "usb": [ + { + "vid": "0658", + "pid": "0200", + "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"] + }, + { + "vid": "10C4", + "pid": "8A2A", + "description": "*z-wave*", + "known_devices": ["Nortek HUSBZB-1"] + } + ], + "zeroconf": ["_zwave-js-server._tcp.local."], + "is_built_in": true + }, + "setup_times": { + "null": { + "setup": 0.06139277799957199 + }, + "01J4GRKFXZDKNDWCNE0ZWKH65M": { + "config_entry_setup": 0.22992777000035858, + "config_entry_platform_setup": 0.12791325299986056, + "wait_base_component": -0.009490847998677054 + } + }, + "data": { + "versionInfo": { + "driverVersion": "13.0.2", + "serverVersion": "1.37.0", + "minSchemaVersion": 0, + "maxSchemaVersion": 37 + }, + "entities": [ + { + "domain": "sensor", + "entity_id": "sensor.gp9_air_temperature", + "original_name": "Air temperature", + "original_device_class": "temperature", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "\u00b0C", + "value_id": "46-49-1-Air temperature", + "primary_value": { + "command_class": 49, + "command_class_name": "Multilevel Sensor", + "endpoint": 1, + "property": "Air temperature", + "property_name": "Air temperature", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_kwh", + "original_name": "Electric Consumption [kWh]", + "original_device_class": "energy", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "kWh", + "value_id": "46-50-8-value-65537", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "value", + "property_name": "value", + "property_key": 65537, + "property_key_name": "Electric_kWh_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_w", + "original_name": "Electric Consumption [W]", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-8-value-66049", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "value", + "property_name": "value", + "property_key": 66049, + "property_key_name": "Electric_W_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_v", + "original_name": "Electric Consumption [V]", + "original_device_class": "voltage", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "V", + "value_id": "46-50-8-value-66561", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "value", + "property_name": "value", + "property_key": 66561, + "property_key_name": "Electric_V_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_a", + "original_name": "Electric Consumption [A]", + "original_device_class": "current", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "A", + "value_id": "46-50-8-value-66817", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "value", + "property_name": "value", + "property_key": 66817, + "property_key_name": "Electric_A_Consumed" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_reset_accumulated_values", + "original_name": "Reset accumulated values", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-50-8-reset", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "reset", + "property_name": "reset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmtype", + "original_name": "alarmType", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-alarmType", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "alarmType", + "property_name": "alarmType", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmlevel", + "original_name": "alarmLevel", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-alarmLevel", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "alarmLevel", + "property_name": "alarmLevel", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_power_management_over_current_status", + "original_name": "Power Management Over-current status", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_idle_power_management_over_current_status", + "original_name": "Idle Power Management Over-current status", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_kwh_9", + "original_name": "Electric Consumption [kWh] (9)", + "original_device_class": "energy", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "kWh", + "value_id": "46-50-9-value-65537", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "value", + "property_name": "value", + "property_key": 65537, + "property_key_name": "Electric_kWh_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_w_9", + "original_name": "Electric Consumption [W] (9)", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-9-value-66049", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "value", + "property_name": "value", + "property_key": 66049, + "property_key_name": "Electric_W_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_v_9", + "original_name": "Electric Consumption [V] (9)", + "original_device_class": "voltage", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "V", + "value_id": "46-50-9-value-66561", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "value", + "property_name": "value", + "property_key": 66561, + "property_key_name": "Electric_V_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_a_9", + "original_name": "Electric Consumption [A] (9)", + "original_device_class": "current", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "A", + "value_id": "46-50-9-value-66817", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "value", + "property_name": "value", + "property_key": 66817, + "property_key_name": "Electric_A_Consumed" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_reset_accumulated_values_9", + "original_name": "Reset accumulated values (9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-50-9-reset", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "reset", + "property_name": "reset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmtype_9", + "original_name": "alarmType (9)", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-alarmType", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "alarmType", + "property_name": "alarmType", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmlevel_9", + "original_name": "alarmLevel (9)", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-alarmLevel", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "alarmLevel", + "property_name": "alarmLevel", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_power_management_over_current_status_9", + "original_name": "Power Management Over-current status (9)", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_idle_power_management_over_current_status_9", + "original_name": "Idle Power Management Over-current status (9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_kwh_10", + "original_name": "Electric Consumption [kWh] (10)", + "original_device_class": "energy", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "kWh", + "value_id": "46-50-10-value-65537", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 65537, + "property_key_name": "Electric_kWh_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_w_10", + "original_name": "Electric Consumption [W] (10)", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-10-value-66049", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 66049, + "property_key_name": "Electric_W_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_v_10", + "original_name": "Electric Consumption [V] (10)", + "original_device_class": "voltage", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "V", + "value_id": "46-50-10-value-66561", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 66561, + "property_key_name": "Electric_V_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_a_10", + "original_name": "Electric Consumption [A] (10)", + "original_device_class": "current", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "A", + "value_id": "46-50-10-value-66817", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 66817, + "property_key_name": "Electric_A_Consumed" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_reset_accumulated_values_10", + "original_name": "Reset accumulated values (10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-50-10-reset", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "reset", + "property_name": "reset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmtype_10", + "original_name": "alarmType (10)", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-alarmType", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "alarmType", + "property_name": "alarmType", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmlevel_10", + "original_name": "alarmLevel (10)", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-alarmLevel", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "alarmLevel", + "property_name": "alarmLevel", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_power_management_over_current_status_10", + "original_name": "Power Management Over-current status (10)", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_idle_power_management_over_current_status_10", + "original_name": "Idle Power Management Over-current status (10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "light", + "entity_id": "light.gp9", + "original_name": "", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 32, + "unit_of_measurement": null, + "value_id": "46-38-8-currentValue", + "primary_value": { + "command_class": 38, + "command_class_name": "Multilevel Switch", + "endpoint": 8, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "light", + "entity_id": "light.gp9_9", + "original_name": "(9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 32, + "unit_of_measurement": null, + "value_id": "46-38-9-currentValue", + "primary_value": { + "command_class": 38, + "command_class_name": "Multilevel Switch", + "endpoint": 9, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "light", + "entity_id": "light.gp9_10", + "original_name": "(10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 32, + "unit_of_measurement": null, + "value_id": "46-38-10-currentValue", + "primary_value": { + "command_class": 38, + "command_class_name": "Multilevel Switch", + "endpoint": 10, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id", + "original_name": "Scene ID", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-2-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 2, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_3", + "original_name": "Scene ID (3)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-3-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 3, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_4", + "original_name": "Scene ID (4)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-4-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 4, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_5", + "original_name": "Scene ID (5)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-5-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 5, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_6", + "original_name": "Scene ID (6)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-6-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 6, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_7", + "original_name": "Scene ID (7)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-7-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 7, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_8", + "original_name": "Scene ID (8)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-8-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 8, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_9", + "original_name": "Scene ID (9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-9-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 9, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_10", + "original_name": "Scene ID (10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-10-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 10, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_11", + "original_name": "Scene ID (11)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-11-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 11, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_12", + "original_name": "Scene ID (12)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-12-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 12, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_13", + "original_name": "Scene ID (13)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-13-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 13, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_3", + "original_name": "(3)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-3-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 3, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_4", + "original_name": "(4)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-4-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 4, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_5", + "original_name": "(5)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-5-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 5, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_6", + "original_name": "(6)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-6-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 6, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_7", + "original_name": "(7)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-7-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 7, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_8", + "original_name": "(8)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-8-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 8, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_9", + "original_name": "(9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-9-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 9, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_10", + "original_name": "(10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-10-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 10, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_11", + "original_name": "(11)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-11-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 11, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_12", + "original_name": "(12)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-12-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 12, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_13", + "original_name": "(13)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-13-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 13, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.gp9_over_current_detected", + "original_name": "Over-current detected", + "original_device_class": "safety", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status", + "state_key": 6 + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.gp9_over_current_detected_9", + "original_name": "Over-current detected (9)", + "original_device_class": "safety", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status", + "state_key": 6 + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.gp9_over_current_detected_10", + "original_name": "Over-current detected (10)", + "original_device_class": "safety", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status", + "state_key": 6 + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_w", + "original_name": "Electric [W]", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-9-value-66051", + "primary_value": null + }, + { + "domain": "number", + "entity_id": "number.gp9_param123", + "original_name": "param123", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-112-0-123", + "primary_value": null + }, + { + "domain": "number", + "entity_id": "number.gp9_param120", + "original_name": "param120", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-112-0-120", + "primary_value": null + }, + { + "domain": "number", + "entity_id": "number.gp9_param124", + "original_name": "param124", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-112-0-124", + "primary_value": null + }, + { + "domain": "number", + "entity_id": "number.gp9_param121", + "original_name": "param121", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-112-0-121", + "primary_value": null + }, + { + "domain": "switch", + "entity_id": "switch.gp9", + "original_name": "", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-2-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 2, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_w_10", + "original_name": "Electric [W]", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-10-value-66051", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 66051, + "property_key_name": "Electric_W_unknown (0x03)" + } + } + ], + "state": { + "nodeId": 46, + "index": 0, + "installerIcon": 2048, + "userIcon": 2048, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 351, + "productId": 33030, + "productType": 36865, + "firmwareVersion": "4.13.8", + "zwavePlusVersion": 2, + "name": "gp9", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/root/zwave/store/config/devices/0x015f/glass9.json", + "isEmbedded": false, + "manufacturer": "TouchWand Co., Ltd.", + "manufacturerId": 351, + "label": "Glass9", + "description": "Glass 9", + "devices": [ + { + "productType": 36865, + "productId": 33030 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "compat": { + "removeCCs": {} + } + }, + "label": "Glass9", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 13, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x015f:0x9001:0x8106:4.13.8", + "statistics": { + "commandsTX": 829, + "commandsRX": 923, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 4, + "rtt": 224.8, + "lastSeen": "2024-08-06T04:06:52.580Z", + "rssi": -49, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -49, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-08-06T04:06:15.046Z", + "protocol": 0, + "values": { + "46-91-0-slowRefresh": { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + "46-114-0-manufacturerId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 351 + }, + "46-114-0-productType": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 36865 + }, + "46-114-0-productId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 33030 + }, + "46-134-0-libraryType": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + "46-134-0-protocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + "46-134-0-firmwareVersions": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["4.13", "2.0"] + }, + "46-134-0-hardwareVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + "46-134-0-sdkVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.8" + }, + "46-134-0-applicationFrameworkAPIVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.8" + }, + "46-134-0-applicationFrameworkBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + "46-134-0-hostInterfaceVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + "46-134-0-hostInterfaceBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-134-0-zWaveProtocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.8" + }, + "46-134-0-zWaveProtocolBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + "46-134-0-applicationVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "4.13.8" + }, + "46-134-0-applicationBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + "46-49-1-Air temperature": { + "endpoint": 1, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 31.1, + "nodeId": 46 + }, + "46-37-2-currentValue": { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-2-targetValue": { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-2-duration": { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-2-sceneId": { + "endpoint": 2, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-2-dimmingDuration": { + "endpoint": 2, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-3-currentValue": { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-3-targetValue": { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-3-duration": { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-3-sceneId": { + "endpoint": 3, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-3-dimmingDuration": { + "endpoint": 3, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-4-currentValue": { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-4-targetValue": { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-4-duration": { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-4-sceneId": { + "endpoint": 4, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-4-dimmingDuration": { + "endpoint": 4, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-5-currentValue": { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-5-targetValue": { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-5-duration": { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-5-sceneId": { + "endpoint": 5, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-5-dimmingDuration": { + "endpoint": 5, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-6-currentValue": { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-6-targetValue": { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-6-duration": { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-6-sceneId": { + "endpoint": 6, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-6-dimmingDuration": { + "endpoint": 6, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-7-currentValue": { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-7-targetValue": { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-7-duration": { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-7-sceneId": { + "endpoint": 7, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-7-dimmingDuration": { + "endpoint": 7, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-8-currentValue": { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-8-targetValue": { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-8-duration": { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-38-8-targetValue": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 63 + }, + "46-38-8-currentValue": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-38-8-Up": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-8-Down": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-8-duration": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + "46-38-8-restorePrevious": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + "46-43-8-sceneId": { + "endpoint": 8, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-8-dimmingDuration": { + "endpoint": 8, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-50-8-value-65537": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-50-8-value-66049": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 8.5 + }, + "46-50-8-value-66561": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + "46-50-8-value-66817": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + "46-50-8-reset": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + "46-113-8-alarmType": { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-8-alarmLevel": { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-8-Power Management-Over-current status": { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + "46-37-9-currentValue": { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-9-targetValue": { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-9-duration": { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-38-9-targetValue": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-38-9-currentValue": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 54 + }, + "46-38-9-Up": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-9-Down": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-9-duration": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + "46-38-9-restorePrevious": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + "46-43-9-sceneId": { + "endpoint": 9, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-9-dimmingDuration": { + "endpoint": 9, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-50-9-value-65537": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-50-9-value-66049": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 8.7 + }, + "46-50-9-value-66561": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + "46-50-9-value-66817": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + "46-50-9-reset": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + "46-113-9-alarmType": { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-9-alarmLevel": { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-9-Power Management-Over-current status": { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + "46-37-10-currentValue": { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-10-targetValue": { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-10-duration": { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-38-10-targetValue": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 56 + }, + "46-38-10-currentValue": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-38-10-Up": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-10-Down": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-10-duration": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + "46-38-10-restorePrevious": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + "46-43-10-sceneId": { + "endpoint": 10, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-10-dimmingDuration": { + "endpoint": 10, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-50-10-value-65537": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-50-10-value-66049": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 9 + }, + "46-50-10-value-66561": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + "46-50-10-value-66817": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + "46-50-10-reset": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + "46-113-10-alarmType": { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-10-alarmLevel": { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-10-Power Management-Over-current status": { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + "46-37-11-currentValue": { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-11-targetValue": { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-11-duration": { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-11-sceneId": { + "endpoint": 11, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-11-dimmingDuration": { + "endpoint": 11, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-12-currentValue": { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-12-targetValue": { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-12-duration": { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-12-sceneId": { + "endpoint": 12, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-12-dimmingDuration": { + "endpoint": 12, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-13-currentValue": { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + "46-37-13-targetValue": { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": true + }, + "46-37-13-duration": { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-13-sceneId": { + "endpoint": 13, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-13-dimmingDuration": { + "endpoint": 13, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-50-10-value-66051": { + "commandClassName": "Meter", + "commandClass": 50, + "property": "value", + "propertyKey": 66051, + "endpoint": 10, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 3 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "propertyName": "value", + "propertyKeyName": "Electric_W_unknown (0x03)", + "nodeId": 46, + "value": 9.2 + } + }, + "endpoints": { + "0": { + "nodeId": 46, + "index": 0, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + } + ] + }, + "1": { + "nodeId": 46, + "index": 1, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + } + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "2": { + "nodeId": 46, + "index": 2, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "3": { + "nodeId": 46, + "index": 3, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "4": { + "nodeId": 46, + "index": 4, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "5": { + "nodeId": 46, + "index": 5, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "6": { + "nodeId": 46, + "index": 6, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "7": { + "nodeId": 46, + "index": 7, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "8": { + "nodeId": 46, + "index": 8, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "9": { + "nodeId": 46, + "index": 9, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "10": { + "nodeId": 46, + "index": 10, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "11": { + "nodeId": 46, + "index": 11, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "12": { + "nodeId": 46, + "index": 12, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "13": { + "nodeId": 46, + "index": 13, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + } + } + } + } +} diff --git a/tests/components/zwave_js/fixtures/ring_keypad_state.json b/tests/components/zwave_js/fixtures/ring_keypad_state.json new file mode 100644 index 00000000000..3d003518b6e --- /dev/null +++ b/tests/components/zwave_js/fixtures/ring_keypad_state.json @@ -0,0 +1,7543 @@ +{ + "nodeId": 4, + "index": 0, + "installerIcon": 8193, + "userIcon": 8193, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 838, + "productId": 1025, + "productType": 257, + "firmwareVersion": "1.18.0", + "zwavePlusVersion": 2, + "deviceConfig": { + "filename": "/home/dominic/Repositories/zwavejs2mqtt/store/.config-db/devices/0x0346/keypad_v2.json", + "isEmbedded": true, + "manufacturer": "Ring", + "manufacturerId": 838, + "label": "4AK1SZ", + "description": "Keypad v2", + "devices": [ + { + "productType": 257, + "productId": 769 + }, + { + "productType": 257, + "productId": 1025 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "compat": { + "disableStrictEntryControlDataValidation": true + }, + "metadata": { + "inclusion": "Classic Inclusion should be used if the controller does not support SmartStart.\n1. Initiate add flow for Security Devices in the Ring mobile application \u2013 Follow the guided add flow instructions provided in the Ring mobile application.\n2. Select add manually and enter the 5-digit DSK PIN found on the package of the Ring Alarm Keypad or the 5-digit DSK PIN found under the QR code on the device.\n3. After powering on the device, press and hold the #1 button for ~3 seconds. Release the button and the device will enter Classic inclusion mode which implements both classic inclusion with a Node Information Frame, and Network Wide Inclusion. During Classic Inclusion mode, the green Connection LED will blink three times followed by a brief pause, repeatedly. When Classic inclusion times-out, the device will blink alternating red and green a few times", + "exclusion": "1. Initiate remove 'Ring Alarm Keypad' flow in the Ring Alarm mobile application \u2013 Select the settings icon from device details page and choose 'Remove Device' to remove the device. This will place the controller into Remove or 'Z-Wave Exclusion' mode.\n2. Locate the pinhole reset button on the back of the device.\n3. With the controller in Remove (Z-Wave Exclusion) mode, use a paper clip or similar object and tap the pinhole button. The device's Connection LED turns on solid red to indicate the device was removed from the network.", + "reset": "Factory Default Instructions\n1. To restore Ring Alarm Keypad to factory default settings, locate the pinhole reset button on the device. This is found on the back of the device after removing the back bracket.\n2. Using a paperclip or similar object, insert it into the pinhole, press and hold the button down for 10 seconds.\n3. The device's Connection icon LED will rapidly blink green continuously for 10 seconds. After about 10 seconds, when the green blinking stops, release the button. The red LED will turn on solid to indicate the device was removed from the network.\nNote\nUse this procedure only in the event that the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/4150/Ring%20Alarm%20Keypad%20Zwave.pdf" + } + }, + "label": "4AK1SZ", + "interviewAttempts": 0, + "isFrequentListening": "250ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 11, + "label": "Secure Keypad" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0346:0x0101:0x0401:1.18.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 27.5, + "lastSeen": "2025-06-18T11:17:39.315Z", + "rssi": -54, + "lwr": { + "protocolDataRate": 2, + "repeaters": [], + "rssi": -54, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 2, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-06-18T11:17:39.315Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 111, + "commandClassName": "Entry Control", + "property": "keyCacheTimeout", + "propertyName": "keyCacheTimeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "How long the key cache must wait for additional characters", + "label": "Key cache timeout", + "min": 1, + "max": 30, + "unit": "seconds", + "stateful": true, + "secret": false + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 111, + "commandClassName": "Entry Control", + "property": "keyCacheSize", + "propertyName": "keyCacheSize", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Number of character that must be stored before sending", + "label": "Key cache size", + "min": 4, + "max": 10, + "stateful": true, + "secret": false + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Heartbeat Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Heartbeat Interval", + "default": 70, + "min": 1, + "max": 70, + "unit": "minutes", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 70 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Message Retry Attempt Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Message Retry Attempt Limit", + "default": 1, + "min": 0, + "max": 5, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Delay Between Retry Attempts", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Delay Between Retry Attempts", + "default": 5, + "min": 1, + "max": 60, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Announcement Audio Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Announcement Audio Volume", + "default": 7, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Key Tone Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Key Tone Volume", + "default": 6, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Siren Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Siren Volume", + "default": 10, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Long Press Duration: Emergency Buttons", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Hold time required to capture a long press", + "label": "Long Press Duration: Emergency Buttons", + "default": 3, + "min": 2, + "max": 5, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Long Press Duration: Number Pad", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Hold time required to capture a long press", + "label": "Long Press Duration: Number Pad", + "default": 3, + "min": 2, + "max": 5, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Timeout: Proximity Display", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Proximity Display", + "default": 5, + "min": 0, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Timeout: Display on Button Press", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Display on Button Press", + "default": 5, + "min": 0, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Timeout: Display on Status Change", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Display on Status Change", + "default": 5, + "min": 1, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Brightness: Security Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Brightness: Security Mode", + "default": 100, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Brightness: Key Backlight", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Brightness: Key Backlight", + "default": 100, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Key Backlight Ambient Light Sensor Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Key Backlight Ambient Light Sensor Level", + "default": 20, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Proximity Detection", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Proximity Detection", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "LED Ramp Time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Ramp Time", + "default": 50, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Battery Low Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Low Threshold", + "default": 15, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 30 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Battery Warning Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Warning Threshold", + "default": 5, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Keypad Language", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Keypad Language", + "default": 30, + "min": 0, + "max": 31, + "states": { + "0": "English", + "2": "French", + "5": "Spanish" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "System Security Mode Blink Duration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "System Security Mode Blink Duration", + "default": 2, + "min": 1, + "max": 60, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Supervision Report Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Supervision Report Timeout", + "default": 10000, + "min": 500, + "max": 30000, + "unit": "ms", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "System Security Mode Display", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Allowable range: 1-600", + "label": "System Security Mode Display", + "default": 0, + "min": 0, + "max": 601, + "states": { + "0": "Always off", + "601": "Always on" + }, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1, + "propertyName": "param023_1", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2, + "propertyName": "param023_2", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4, + "propertyName": "param023_4", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8, + "propertyName": "param023_8", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16, + "propertyName": "param023_16", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 32, + "propertyName": "param023_32", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 64, + "propertyName": "param023_64", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 128, + "propertyName": "param023_128", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 256, + "propertyName": "param023_256", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 512, + "propertyName": "param023_512", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1024, + "propertyName": "param023_1024", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2048, + "propertyName": "param023_2048", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4096, + "propertyName": "param023_4096", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8192, + "propertyName": "param023_8192", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16384, + "propertyName": "param023_16384", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 32768, + "propertyName": "param023_32768", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 65536, + "propertyName": "param023_65536", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 131072, + "propertyName": "param023_131072", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 262144, + "propertyName": "param023_262144", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 524288, + "propertyName": "param023_524288", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1048576, + "propertyName": "param023_1048576", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2097152, + "propertyName": "param023_2097152", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4194304, + "propertyName": "param023_4194304", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8388608, + "propertyName": "param023_8388608", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16777216, + "propertyName": "param023_16777216", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 33554432, + "propertyName": "param023_33554432", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 67108864, + "propertyName": "param023_67108864", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 134217728, + "propertyName": "param023_134217728", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 268435456, + "propertyName": "param023_268435456", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 536870912, + "propertyName": "param023_536870912", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1073741824, + "propertyName": "param023_1073741824", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2147483648, + "propertyName": "param023_2147483648", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Calibrate Speaker", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Calibrate Speaker", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyName": "Motion Sensor Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Motion Sensor Timeout", + "default": 3, + "min": 0, + "max": 60, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Z-Wave Sleep Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "", + "label": "Z-Wave Sleep Timeout", + "default": 10, + "min": 0, + "max": 15, + "valueSize": 1, + "format": 1, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "Languages Supported Report", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "This parameter reports a bitmask of supported languages", + "label": "Languages Supported Report", + "default": 37, + "min": 0, + "max": 4294967295, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Motion sensor status", + "propertyName": "Home Security", + "propertyKeyName": "Motion sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Motion sensor status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Motion detection" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Power status", + "propertyName": "Power Management", + "propertyKeyName": "Power status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Power status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Power has been applied" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Software status", + "propertyName": "System", + "propertyKeyName": "Software status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Software status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "4": "System software failure (with failure code)" + }, + "stateful": true, + "secret": false + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Mains status", + "propertyName": "Power Management", + "propertyKeyName": "Mains status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Mains status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "2": "AC mains disconnected", + "3": "AC mains re-connected" + }, + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1025 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 257 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 838 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "temperature", + "propertyName": "temperature", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Temperature", + "min": -128, + "max": 127, + "unit": "\u00b0C", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "maximumCapacity", + "propertyName": "maximumCapacity", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Maximum capacity", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.18", "1.1"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 28 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.18.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12.4" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "1.18.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.12.4" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 1, + "propertyName": "0", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0 (default) - Multilevel", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 3, + "propertyName": "0", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0 (default) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 4, + "propertyName": "0", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0 (default) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 5, + "propertyName": "0", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0 (default) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 7, + "propertyName": "0", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 9, + "propertyName": "0", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0 (default) - Sound level", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 8, + "propertyName": "0", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 6, + "propertyName": "0", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + }, + "value": "" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 1, + "propertyName": "Ready", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x03 (Ready) - Multilevel", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 3, + "propertyName": "Ready", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x03 (Ready) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 4, + "propertyName": "Ready", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x03 (Ready) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 5, + "propertyName": "Ready", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x03 (Ready) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 7, + "propertyName": "Ready", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 9, + "propertyName": "Ready", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x03 (Ready) - Sound level", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 8, + "propertyName": "Ready", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 6, + "propertyName": "Ready", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 1, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x02 (Not armed / disarmed) - Multilevel", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 3, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x02 (Not armed / disarmed) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 4, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x02 (Not armed / disarmed) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 5, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x02 (Not armed / disarmed) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 7, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 9, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x02 (Not armed / disarmed) - Sound level", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 8, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 6, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 1, + "propertyName": "Code not accepted", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x09 (Code not accepted) - Multilevel", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 3, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x09 (Code not accepted) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 4, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x09 (Code not accepted) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 5, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x09 (Code not accepted) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 7, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 9, + "propertyName": "Code not accepted", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x09 (Code not accepted) - Sound level", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 8, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 6, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 1, + "propertyName": "Armed Stay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0a (Armed Stay) - Multilevel", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 3, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0a (Armed Stay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 4, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0a (Armed Stay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 5, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0a (Armed Stay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 7, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 9, + "propertyName": "Armed Stay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0a (Armed Stay) - Sound level", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 8, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 6, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 1, + "propertyName": "Armed Away", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0b (Armed Away) - Multilevel", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 3, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0b (Armed Away) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 4, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0b (Armed Away) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 5, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0b (Armed Away) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 7, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 9, + "propertyName": "Armed Away", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0b (Armed Away) - Sound level", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 8, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 6, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 1, + "propertyName": "Alarming", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0c (Alarming) - Multilevel", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 3, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0c (Alarming) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 4, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0c (Alarming) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 5, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0c (Alarming) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 7, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 9, + "propertyName": "Alarming", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0c (Alarming) - Sound level", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 8, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 6, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 1, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0d (Alarming: Burglar) - Multilevel", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 3, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0d (Alarming: Burglar) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 4, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0d (Alarming: Burglar) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 5, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0d (Alarming: Burglar) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 7, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 9, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0d (Alarming: Burglar) - Sound level", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 8, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 6, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 1, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0e (Alarming: Smoke / Fire) - Multilevel", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 3, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 4, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 5, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 7, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 9, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0e (Alarming: Smoke / Fire) - Sound level", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 8, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 6, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 1, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0f (Alarming: Carbon Monoxide) - Multilevel", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 3, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 4, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 5, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 7, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 9, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0f (Alarming: Carbon Monoxide) - Sound level", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 8, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 6, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 1, + "propertyName": "Bypass challenge", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x10 (Bypass challenge) - Multilevel", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 3, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x10 (Bypass challenge) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 4, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x10 (Bypass challenge) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 5, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x10 (Bypass challenge) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 7, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 9, + "propertyName": "Bypass challenge", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x10 (Bypass challenge) - Sound level", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 8, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 6, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 1, + "propertyName": "Entry Delay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x11 (Entry Delay) - Multilevel", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 3, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x11 (Entry Delay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 4, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x11 (Entry Delay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 5, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x11 (Entry Delay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 7, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 9, + "propertyName": "Entry Delay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x11 (Entry Delay) - Sound level", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 8, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 6, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 1, + "propertyName": "Exit Delay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x12 (Exit Delay) - Multilevel", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 3, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x12 (Exit Delay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 4, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x12 (Exit Delay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 5, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x12 (Exit Delay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 7, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 9, + "propertyName": "Exit Delay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x12 (Exit Delay) - Sound level", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 8, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 6, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 1, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x13 (Alarming: Medical) - Multilevel", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 3, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x13 (Alarming: Medical) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 4, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x13 (Alarming: Medical) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 5, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x13 (Alarming: Medical) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 7, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 9, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x13 (Alarming: Medical) - Sound level", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 8, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 6, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 1, + "propertyName": "Node Identify", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x50 (Node Identify) - Multilevel", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x50 (Node Identify) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x50 (Node Identify) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x50 (Node Identify) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 7, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 9, + "propertyName": "Node Identify", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x50 (Node Identify) - Sound level", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 8, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 6, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 1, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x60 (Generic event sound notification 1) - Multilevel", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 3, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x60 (Generic event sound notification 1) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 4, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x60 (Generic event sound notification 1) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 5, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x60 (Generic event sound notification 1) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 7, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 9, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x60 (Generic event sound notification 1) - Sound level", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 8, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 6, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 1, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x61 (Generic event sound notification 2) - Multilevel", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 3, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x61 (Generic event sound notification 2) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 4, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x61 (Generic event sound notification 2) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 5, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x61 (Generic event sound notification 2) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 7, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 9, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x61 (Generic event sound notification 2) - Sound level", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 8, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 6, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 1, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x62 (Generic event sound notification 3) - Multilevel", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 3, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x62 (Generic event sound notification 3) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 4, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x62 (Generic event sound notification 3) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 5, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x62 (Generic event sound notification 3) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 7, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 9, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x62 (Generic event sound notification 3) - Sound level", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 8, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 6, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 1, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x63 (Generic event sound notification 4) - Multilevel", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 3, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x63 (Generic event sound notification 4) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 4, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x63 (Generic event sound notification 4) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 5, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x63 (Generic event sound notification 4) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 7, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 9, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x63 (Generic event sound notification 4) - Sound level", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 8, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 6, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 1, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x64 (Generic event sound notification 5) - Multilevel", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 3, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x64 (Generic event sound notification 5) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 4, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x64 (Generic event sound notification 5) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 5, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x64 (Generic event sound notification 5) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 7, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 9, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x64 (Generic event sound notification 5) - Sound level", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 8, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 6, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 1, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x14 (Alarming: Freeze warning) - Multilevel", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 3, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x14 (Alarming: Freeze warning) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 4, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x14 (Alarming: Freeze warning) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 5, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x14 (Alarming: Freeze warning) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 7, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 9, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x14 (Alarming: Freeze warning) - Sound level", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 8, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 6, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 1, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x15 (Alarming: Water leak) - Multilevel", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 3, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x15 (Alarming: Water leak) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 4, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x15 (Alarming: Water leak) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 5, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x15 (Alarming: Water leak) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 7, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 9, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x15 (Alarming: Water leak) - Sound level", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 8, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 6, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 1, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x81 (Manufacturer defined 2) - Multilevel", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 3, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x81 (Manufacturer defined 2) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 4, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x81 (Manufacturer defined 2) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 5, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x81 (Manufacturer defined 2) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 7, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 9, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x81 (Manufacturer defined 2) - Sound level", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 8, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 6, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 1, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x82 (Manufacturer defined 3) - Multilevel", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 3, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x82 (Manufacturer defined 3) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 4, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x82 (Manufacturer defined 3) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 5, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x82 (Manufacturer defined 3) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 7, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 9, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x82 (Manufacturer defined 3) - Sound level", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 8, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 6, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 1, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x83 (Manufacturer defined 4) - Multilevel", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 3, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x83 (Manufacturer defined 4) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 4, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x83 (Manufacturer defined 4) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 5, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x83 (Manufacturer defined 4) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 7, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 9, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x83 (Manufacturer defined 4) - Sound level", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 8, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 6, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 1, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x84 (Manufacturer defined 5) - Multilevel", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 3, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x84 (Manufacturer defined 5) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 4, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x84 (Manufacturer defined 5) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 5, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x84 (Manufacturer defined 5) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 7, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 9, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x84 (Manufacturer defined 5) - Sound level", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 8, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 6, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 1, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x85 (Manufacturer defined 6) - Multilevel", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 3, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x85 (Manufacturer defined 6) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 4, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x85 (Manufacturer defined 6) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 5, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x85 (Manufacturer defined 6) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 7, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 9, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x85 (Manufacturer defined 6) - Sound level", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 8, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 6, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 4, + "index": 0, + "installerIcon": 8193, + "userIcon": 8193, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 11, + "label": "Secure Keypad" + } + }, + "commandClasses": [ + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 111, + "name": "Entry Control", + "version": 1, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/fixtures/touchwand_glass9_state.json b/tests/components/zwave_js/fixtures/touchwand_glass9_state.json new file mode 100644 index 00000000000..a84797b75d4 --- /dev/null +++ b/tests/components/zwave_js/fixtures/touchwand_glass9_state.json @@ -0,0 +1,3467 @@ +{ + "nodeId": 46, + "index": 0, + "installerIcon": 2048, + "userIcon": 2048, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 351, + "productId": 33030, + "productType": 36865, + "firmwareVersion": "4.13.8", + "zwavePlusVersion": 2, + "name": "gp9", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/root/zwave/store/config/devices/0x015f/glass9.json", + "isEmbedded": false, + "manufacturer": "TouchWand Co., Ltd.", + "manufacturerId": 351, + "label": "Glass9", + "description": "Glass 9", + "devices": [ + { + "productType": 36865, + "productId": 33030 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "compat": { + "removeCCs": {} + } + }, + "label": "Glass9", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 13, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x015f:0x9001:0x8106:4.13.8", + "statistics": { + "commandsTX": 829, + "commandsRX": 923, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 4, + "rtt": 224.8, + "lastSeen": "2024-08-06T04:06:52.580Z", + "rssi": -49, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -49, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-08-06T04:06:15.046Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 351 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 36865 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 33030 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["4.13", "2.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.8" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.8" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.8" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "4.13.8" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + { + "endpoint": 1, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 31.1, + "nodeId": 46 + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 2, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 3, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 4, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 5, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 6, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 6, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 7, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 7, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 63 + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 8.5 + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 54 + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 8.7 + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 56 + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 9 + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 11, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 11, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 12, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 12, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 13, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 13, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "commandClassName": "Meter", + "commandClass": 50, + "property": "value", + "propertyKey": 66051, + "endpoint": 10, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 3 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "propertyName": "value", + "propertyKeyName": "Electric_W_unknown (0x03)", + "nodeId": 46, + "value": 9.2 + } + ], + "endpoints": [ + { + "nodeId": 46, + "index": 0, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 1, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + } + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 2, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 3, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 4, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 5, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 6, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 7, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 8, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 9, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 10, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 11, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 12, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 13, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/snapshots/test_diagnostics.ambr b/tests/components/zwave_js/snapshots/test_diagnostics.ambr index dc0dbba59b5..40ed3bbf836 100644 --- a/tests/components/zwave_js/snapshots/test_diagnostics.ambr +++ b/tests/components/zwave_js/snapshots/test_diagnostics.ambr @@ -97,8 +97,8 @@ 'value_id': '52-113-0-Home Security-Cover status', }), dict({ - 'disabled': False, - 'disabled_by': None, + 'disabled': True, + 'disabled_by': 'integration', 'domain': 'button', 'entity_category': 'config', 'entity_id': 'button.multisensor_6_idle_home_security_cover_status', @@ -120,8 +120,8 @@ 'value_id': '52-113-0-Home Security-Cover status', }), dict({ - 'disabled': False, - 'disabled_by': None, + 'disabled': True, + 'disabled_by': 'integration', 'domain': 'button', 'entity_category': 'config', 'entity_id': 'button.multisensor_6_idle_home_security_motion_sensor_status', diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 83a22cbee32..3f1f9b737bd 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -32,7 +32,7 @@ from zwave_js_server.model.controller import ( ProvisioningEntry, QRProvisioningInformation, ) -from zwave_js_server.model.controller.firmware import ControllerFirmwareUpdateData +from zwave_js_server.model.driver.firmware import DriverFirmwareUpdateData from zwave_js_server.model.node import Node from zwave_js_server.model.node.firmware import NodeFirmwareUpdateData from zwave_js_server.model.value import ConfigurationValue, get_value_id_str @@ -3501,7 +3501,7 @@ async def test_firmware_upload_view( "homeassistant.components.zwave_js.api.update_firmware", ) as mock_node_cmd, patch( - "homeassistant.components.zwave_js.api.controller_firmware_update_otw", + "homeassistant.components.zwave_js.api.driver_firmware_update_otw", ) as mock_controller_cmd, patch.dict( "homeassistant.components.zwave_js.api.USER_AGENT", @@ -3544,7 +3544,7 @@ async def test_firmware_upload_view_controller( "homeassistant.components.zwave_js.api.update_firmware", ) as mock_node_cmd, patch( - "homeassistant.components.zwave_js.api.controller_firmware_update_otw", + "homeassistant.components.zwave_js.api.driver_firmware_update_otw", ) as mock_controller_cmd, patch.dict( "homeassistant.components.zwave_js.api.USER_AGENT", @@ -3557,7 +3557,7 @@ async def test_firmware_upload_view_controller( ) mock_node_cmd.assert_not_called() assert mock_controller_cmd.call_args[0][1:2] == ( - ControllerFirmwareUpdateData( + DriverFirmwareUpdateData( "file", b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ), ) @@ -4415,7 +4415,7 @@ async def test_subscribe_controller_firmware_update_status( event = Event( type="firmware update progress", data={ - "source": "controller", + "source": "driver", "event": "firmware update progress", "progress": { "sentFragments": 1, @@ -4424,7 +4424,7 @@ async def test_subscribe_controller_firmware_update_status( }, }, ) - client.driver.controller.receive_event(event) + client.driver.receive_event(event) msg = await ws_client.receive_json() assert msg["event"] == { @@ -4439,7 +4439,7 @@ async def test_subscribe_controller_firmware_update_status( event = Event( type="firmware update finished", data={ - "source": "controller", + "source": "driver", "event": "firmware update finished", "result": { "status": 255, @@ -4447,7 +4447,7 @@ async def test_subscribe_controller_firmware_update_status( }, }, ) - client.driver.controller.receive_event(event) + client.driver.receive_event(event) msg = await ws_client.receive_json() assert msg["event"] == { @@ -4464,13 +4464,13 @@ async def test_subscribe_controller_firmware_update_status_initial_value( ws_client = await hass_ws_client(hass) device = get_device(hass, client.driver.controller.nodes[1]) - assert client.driver.controller.firmware_update_progress is None + assert client.driver.firmware_update_progress is None # Send a firmware update progress event before the WS command event = Event( type="firmware update progress", data={ - "source": "controller", + "source": "driver", "event": "firmware update progress", "progress": { "sentFragments": 1, @@ -4479,7 +4479,7 @@ async def test_subscribe_controller_firmware_update_status_initial_value( }, }, ) - client.driver.controller.receive_event(event) + client.driver.receive_event(event) client.async_send_command_no_wait.return_value = {} diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 93ac52f9041..5dfbb0f5bd8 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -1,10 +1,13 @@ """Test the Z-Wave JS binary sensor platform.""" +from datetime import timedelta + import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, STATE_OFF, @@ -15,17 +18,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .common import ( DISABLED_LEGACY_BINARY_SENSOR, ENABLED_LEGACY_BINARY_SENSOR, - LOW_BATTERY_BINARY_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR, PROPERTY_DOOR_STATUS_BINARY_SENSOR, TAMPER_SENSOR, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -34,21 +37,56 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -async def test_low_battery_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration +async def test_battery_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ring_keypad: Node, + integration: MockConfigEntry, ) -> None: - """Test boolean binary sensor of type low battery.""" - state = hass.states.get(LOW_BATTERY_BINARY_SENSOR) + """Test boolean battery binary sensors.""" + entity_id = "binary_sensor.keypad_v2_low_battery_level" + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY - entity_entry = entity_registry.async_get(LOW_BATTERY_BINARY_SENSOR) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + disabled_binary_sensor_battery_entities = ( + "binary_sensor.keypad_v2_battery_is_disconnected", + "binary_sensor.keypad_v2_fluid_is_low", + "binary_sensor.keypad_v2_overheating", + "binary_sensor.keypad_v2_rechargeable", + "binary_sensor.keypad_v2_used_as_backup", + ) + + for entity_id in disabled_binary_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state is None # disabled by default + + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + for entity_id in disabled_binary_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + async def test_enabled_legacy_sensor( hass: HomeAssistant, ecolink_door_sensor, integration diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 0282a268b54..422888cab23 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -1,13 +1,21 @@ """Test the Z-Wave JS button entities.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest +from zwave_js_server.model.node import Node from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ATTR_ENTITY_ID, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -71,11 +79,32 @@ async def test_ping_entity( async def test_notification_idle_button( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test Notification idle button.""" node = multisensor_6 - state = hass.states.get("button.multisensor_6_idle_home_security_cover_status") + entity_id = "button.multisensor_6_idle_home_security_cover_status" + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + + entity_registry.async_update_entity( + entity_id, + disabled_by=None, + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) assert state assert state.state == "unknown" assert ( @@ -88,13 +117,13 @@ async def test_notification_idle_button( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.multisensor_6_idle_home_security_cover_status", + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - assert len(client.async_send_command_no_wait.call_args_list) == 1 - args = client.async_send_command_no_wait.call_args_list[0][0][0] + assert client.async_send_command_no_wait.call_count == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.manually_idle_notification_value" assert args["nodeId"] == node.node_id assert args["valueId"] == { diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index f312284d897..a356613aa7a 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -264,7 +264,7 @@ async def test_thermostat_v2( client.async_send_command.reset_mock() # Test setting invalid hvac mode - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -574,7 +574,7 @@ async def test_setpoint_thermostat( ) # Test setting illegal mode raises an error - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 7ef5f0e480f..c8bfca2b35f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -1,10 +1,12 @@ """Test entity discovery for device-specific schemas for the Z-Wave JS integration.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.components.number import ( @@ -12,7 +14,6 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -26,12 +27,13 @@ from homeassistant.components.zwave_js.discovery import ( from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) -from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_aeon_smart_switch_6_state( @@ -54,6 +56,24 @@ async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) assert state +async def test_touchwand_glass9( + hass: HomeAssistant, + client: MagicMock, + touchwand_glass9: Node, + integration: MockConfigEntry, +) -> None: + """Test a touchwand_glass9 is discovered as a cover.""" + node = touchwand_glass9 + node_device_class = node.device_class + assert node_device_class + assert node_device_class.specific.label == "Unused" + + assert not hass.states.async_entity_ids_count("light") + assert hass.states.async_entity_ids_count("cover") == 3 + state = hass.states.get("cover.gp9") + assert state + + async def test_zvidar_state(hass: HomeAssistant, client, zvidar, integration) -> None: """Test that an ZVIDAR Z-CM-V01 multilevel switch value is discovered as a cover.""" node = zvidar @@ -222,17 +242,24 @@ async def test_merten_507801_disabled_enitites( async def test_zooz_zen72( hass: HomeAssistant, entity_registry: er.EntityRegistry, - client, - switch_zooz_zen72, - integration, + client: MagicMock, + switch_zooz_zen72: Node, + integration: MockConfigEntry, ) -> None: """Test that Zooz ZEN72 Indicators are discovered as number entities.""" - assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 # includes ping entity_id = "number.z_wave_plus_700_series_dimmer_switch_indicator_value" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + client.async_send_command.reset_mock() state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN @@ -246,7 +273,7 @@ async def test_zooz_zen72( }, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -260,16 +287,18 @@ async def test_zooz_zen72( client.async_send_command.reset_mock() entity_id = "button.z_wave_plus_700_series_dimmer_switch_identify" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is None + assert hass.states.get(entity_id) is not None await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -285,53 +314,55 @@ async def test_indicator_test( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - indicator_test, - integration, + client: MagicMock, + indicator_test: Node, + integration: MockConfigEntry, ) -> None: """Test that Indicators are discovered properly. This test covers indicators that we don't already have device fixtures for. """ - device = device_registry.async_get_device( - identifiers={get_device_id(client.driver, indicator_test)} + binary_sensor_entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" + sensor_entity_id = "sensor.this_is_a_fake_device_sensor" + switch_entity_id = "switch.this_is_a_fake_device_switch" + + for entity_id in ( + binary_sensor_entity_id, + sensor_entity_id, + ): + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + entity_id = switch_entity_id + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - assert device - entities = er.async_entries_for_device(entity_registry, device.id) + await hass.async_block_till_done() + client.async_send_command.reset_mock() - def len_domain(domain): - return len([entity for entity in entities if entity.domain == domain]) - - assert len_domain(NUMBER_DOMAIN) == 0 - assert len_domain(BUTTON_DOMAIN) == 1 # only ping - assert len_domain(BINARY_SENSOR_DOMAIN) == 1 - assert len_domain(SENSOR_DOMAIN) == 3 # include node status + last seen - assert len_domain(SWITCH_DOMAIN) == 1 - - entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = binary_sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - client.async_send_command.reset_mock() - - entity_id = "sensor.this_is_a_fake_device_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == "0.0" - client.async_send_command.reset_mock() - - entity_id = "switch.this_is_a_fake_device_switch" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_id = switch_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -342,7 +373,7 @@ async def test_indicator_test( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id @@ -362,7 +393,7 @@ async def test_indicator_test( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index ef74373ad9e..4350d7f7649 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -27,7 +27,7 @@ from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -366,6 +366,7 @@ async def test_listen_done_after_setup( @pytest.mark.usefixtures("client") +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_new_entity_on_value_added( hass: HomeAssistant, multisensor_6: Node, @@ -1812,7 +1813,8 @@ async def test_disabled_node_status_entity_on_node_replaced( assert state.state == STATE_UNAVAILABLE -async def test_disabled_entity_on_value_removed( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_remove_entity_on_value_removed( hass: HomeAssistant, zp3111: Node, client: MagicMock, @@ -1823,15 +1825,6 @@ async def test_disabled_entity_on_value_removed( "button.4_in_1_sensor_idle_home_security_cover_status" ) - # must reload the integration when enabling an entity - await hass.config_entries.async_unload(integration.entry_id) - await hass.async_block_till_done() - assert integration.state is ConfigEntryState.NOT_LOADED - integration.add_to_hass(hass) - await hass.config_entries.async_setup(integration.entry_id) - await hass.async_block_till_done() - assert integration.state is ConfigEntryState.LOADED - state = hass.states.get(idle_cover_status_button_entity) assert state assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index c93b722334b..ef77e22bbec 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS sensor platform.""" import copy +from datetime import timedelta import pytest from zwave_js_server.const.command_class.meter import MeterType @@ -26,6 +27,7 @@ from homeassistant.components.zwave_js.sensor import ( CONTROLLER_STATISTICS_KEY_MAP, NODE_STATISTICS_KEY_MAP, ) +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -35,6 +37,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UV_INDEX, EntityCategory, + Platform, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -45,6 +48,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .common import ( AIR_TEMPERATURE_SENSOR, @@ -57,7 +61,94 @@ from .common import ( VOLTAGE_SENSOR, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +async def test_battery_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ring_keypad: Node, + integration: MockConfigEntry, +) -> None: + """Test numeric battery sensors.""" + entity_id = "sensor.keypad_v2_battery_level" + state = hass.states.get(entity_id) + assert state + assert state.state == "100.0" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + + disabled_sensor_battery_entities = ( + "sensor.keypad_v2_chargingstatus", + "sensor.keypad_v2_maximum_capacity", + "sensor.keypad_v2_rechargeorreplace", + "sensor.keypad_v2_temperature", + ) + + for entity_id in disabled_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state is None # disabled by default + + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + entity_id = "sensor.keypad_v2_chargingstatus" + state = hass.states.get(entity_id) + assert state + assert state.state == "Maintaining" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert ATTR_STATE_CLASS not in state.attributes + + entity_id = "sensor.keypad_v2_maximum_capacity" + state = hass.states.get(entity_id) + assert state + assert ( + state.state == "0" + ) # This should be None/unknown but will be fixed in a future PR. + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + entity_id = "sensor.keypad_v2_rechargeorreplace" + state = hass.states.get(entity_id) + assert state + assert state.state == "No" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert ATTR_STATE_CLASS not in state.attributes + + entity_id = "sensor.keypad_v2_temperature" + state = hass.states.get(entity_id) + assert state + assert ( + state.state == "0" + ) # This should be None/unknown but will be fixed in a future PR. + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT async def test_numeric_sensor( @@ -564,6 +655,17 @@ async def test_special_meters( "value": 659.813, }, ) + node_data["endpoints"].append( + { + "nodeId": 102, + "index": 10, + "installerIcon": 1792, + "userIcon": 1792, + "commandClasses": [ + {"id": 50, "name": "Meter", "version": 3, "isSecure": False} + ], + } + ) # Add an ElectricScale.KILOVOLT_AMPERE_REACTIVE value to the state so we can test that # it is handled differently (no device class) node_data["values"].append( @@ -587,6 +689,17 @@ async def test_special_meters( "value": 659.813, }, ) + node_data["endpoints"].append( + { + "nodeId": 102, + "index": 11, + "installerIcon": 1792, + "userIcon": 1792, + "commandClasses": [ + {"id": 50, "name": "Meter", "version": 3, "isSecure": False} + ], + } + ) node = Node(client, node_data) event = {"node": node} client.driver.controller.emit("node added", event) diff --git a/tests/conftest.py b/tests/conftest.py index c326f57ca2f..ef31eee4004 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -201,8 +201,7 @@ def pytest_runtest_setup() -> None: # Setup HAFakeDatetime converter for pymysql try: - # pylint: disable-next=import-outside-toplevel - import MySQLdb.converters as MySQLdb_converters + import MySQLdb.converters as MySQLdb_converters # noqa: PLC0415 except ImportError: pass else: @@ -382,8 +381,10 @@ def verify_cleanup( # Verify no threads where left behind. threads = frozenset(threading.enumerate()) - threads_before for thread in threads: - assert isinstance(thread, threading._DummyThread) or thread.name.startswith( - "waitpid-" + assert ( + isinstance(thread, threading._DummyThread) + or thread.name.startswith("waitpid-") + or "_run_safe_shutdown_loop" in thread.name ) try: @@ -1034,7 +1035,7 @@ async def _mqtt_mock_entry( """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase # which does not use MQTT. - from homeassistant.components import mqtt # pylint: disable=import-outside-toplevel + from homeassistant.components import mqtt # noqa: PLC0415 if mqtt_config_entry_data is None: mqtt_config_entry_data = {mqtt.CONF_BROKER: "mock-broker"} @@ -1315,7 +1316,7 @@ def disable_mock_zeroconf_resolver( @pytest.fixture def mock_zeroconf() -> Generator[MagicMock]: """Mock zeroconf.""" - from zeroconf import DNSCache # pylint: disable=import-outside-toplevel + from zeroconf import DNSCache # noqa: PLC0415 with ( patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, @@ -1335,10 +1336,8 @@ def mock_zeroconf() -> Generator[MagicMock]: @pytest.fixture def mock_async_zeroconf(mock_zeroconf: MagicMock) -> Generator[MagicMock]: """Mock AsyncZeroconf.""" - from zeroconf import DNSCache, Zeroconf # pylint: disable=import-outside-toplevel - from zeroconf.asyncio import ( # pylint: disable=import-outside-toplevel - AsyncZeroconf, - ) + from zeroconf import DNSCache, Zeroconf # noqa: PLC0415 + from zeroconf.asyncio import AsyncZeroconf # noqa: PLC0415 with patch( "homeassistant.components.zeroconf.HaAsyncZeroconf", spec=AsyncZeroconf @@ -1494,15 +1493,13 @@ def recorder_db_url( tmp_path = tmp_path_factory.mktemp("recorder") db_url = "sqlite:///" + str(tmp_path / "pytest.db") elif db_url.startswith("mysql://"): - # pylint: disable-next=import-outside-toplevel - import sqlalchemy_utils + import sqlalchemy_utils # noqa: PLC0415 charset = "utf8mb4' COLLATE = 'utf8mb4_unicode_ci" assert not sqlalchemy_utils.database_exists(db_url) sqlalchemy_utils.create_database(db_url, encoding=charset) elif db_url.startswith("postgresql://"): - # pylint: disable-next=import-outside-toplevel - import sqlalchemy_utils + import sqlalchemy_utils # noqa: PLC0415 assert not sqlalchemy_utils.database_exists(db_url) sqlalchemy_utils.create_database(db_url, encoding="utf8") @@ -1510,8 +1507,7 @@ def recorder_db_url( if db_url == "sqlite://" and persistent_database: rmtree(tmp_path, ignore_errors=True) elif db_url.startswith("mysql://"): - # pylint: disable-next=import-outside-toplevel - import sqlalchemy as sa + import sqlalchemy as sa # noqa: PLC0415 made_url = sa.make_url(db_url) db = made_url.database @@ -1542,8 +1538,7 @@ async def _async_init_recorder_component( wait_setup: bool, ) -> None: """Initialize the recorder asynchronously.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder + from homeassistant.components import recorder # noqa: PLC0415 config = dict(add_config) if add_config else {} if recorder.CONF_DB_URL not in config: @@ -1594,21 +1589,16 @@ async def async_test_recorder( enable_migrate_event_ids: bool, ) -> AsyncGenerator[RecorderInstanceContextManager]: """Yield context manager to setup recorder instance.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder + from homeassistant.components import recorder # noqa: PLC0415 + from homeassistant.components.recorder import migration # noqa: PLC0415 - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder import migration - - # pylint: disable-next=import-outside-toplevel - from .components.recorder.common import async_recorder_block_till_done - - # pylint: disable-next=import-outside-toplevel - from .patch_recorder import real_session_scope + from .components.recorder.common import ( # noqa: PLC0415 + async_recorder_block_till_done, + ) + from .patch_recorder import real_session_scope # noqa: PLC0415 if TYPE_CHECKING: - # pylint: disable-next=import-outside-toplevel - from sqlalchemy.orm.session import Session + from sqlalchemy.orm.session import Session # noqa: PLC0415 @contextmanager def debug_session_scope( @@ -1855,8 +1845,7 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: # Late imports to avoid loading bleak unless we need it - # pylint: disable-next=import-outside-toplevel - from habluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # noqa: PLC0415 # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called @@ -1876,13 +1865,9 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: @pytest.fixture def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: """Fixture to inject hassio env.""" - from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel - HassioAPIError, - ) + from homeassistant.components.hassio import HassioAPIError # noqa: PLC0415 - from .components.hassio import ( # pylint: disable=import-outside-toplevel - SUPERVISOR_TOKEN, - ) + from .components.hassio import SUPERVISOR_TOKEN # noqa: PLC0415 with ( patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), @@ -1904,9 +1889,7 @@ async def hassio_stubs( supervisor_client: AsyncMock, ) -> RefreshToken: """Create mock hassio http client.""" - from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel - HassioAPIError, - ) + from homeassistant.components.hassio import HassioAPIError # noqa: PLC0415 with ( patch( diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 61396d97359..706f1a1a806 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta -from enum import IntFlag import logging import threading from typing import Any @@ -827,12 +826,10 @@ 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", }, } @@ -2490,31 +2487,6 @@ async def test_cached_entity_property_override(hass: HomeAssistant) -> None: return "🤡" -async def test_entity_report_deprecated_supported_features_values( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test reporting deprecated supported feature values only happens once.""" - ent = entity.Entity() - - class MockEntityFeatures(IntFlag): - VALUE1 = 1 - VALUE2 = 2 - - ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) - assert ( - "is using deprecated supported features values which will be removed" - in caplog.text - ) - assert "MockEntityFeatures.VALUE2" in caplog.text - - caplog.clear() - ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) - assert ( - "is using deprecated supported features values which will be removed" - not in caplog.text - ) - - async def test_remove_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index e99db76dcbc..54ebfaf953e 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -39,8 +39,9 @@ async def test_get_integration_logger( @pytest.mark.usefixtures("enable_custom_integrations", "hass") async def test_extract_frame_resolve_module() -> None: """Test extracting the current frame from integration context.""" - # pylint: disable-next=import-outside-toplevel - from custom_components.test_integration_frame import call_get_integration_frame + from custom_components.test_integration_frame import ( # noqa: PLC0415 + call_get_integration_frame, + ) integration_frame = call_get_integration_frame() @@ -56,8 +57,9 @@ async def test_extract_frame_resolve_module() -> None: @pytest.mark.usefixtures("enable_custom_integrations", "hass") async def test_get_integration_logger_resolve_module() -> None: """Test getting the logger from integration context.""" - # pylint: disable-next=import-outside-toplevel - from custom_components.test_integration_frame import call_get_integration_logger + from custom_components.test_integration_frame import ( # noqa: PLC0415 + call_get_integration_logger, + ) logger = call_get_integration_logger(__name__) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 1a9225c505b..98dee920bd9 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -36,7 +36,6 @@ def llm_context() -> llm.LLMContext: return llm.LLMContext( platform="", context=None, - user_prompt=None, language=None, assistant=None, device_id=None, @@ -162,7 +161,6 @@ async def test_assist_api( llm_context = llm.LLMContext( platform="test_platform", context=test_context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -237,7 +235,7 @@ async def test_assist_api( "area": {"value": "kitchen"}, "floor": {"value": "ground_floor"}, }, - text_input="test_text", + text_input=None, context=test_context, language="*", assistant="conversation", @@ -296,7 +294,7 @@ async def test_assist_api( "preferred_area_id": {"value": area.id}, "preferred_floor_id": {"value": floor.floor_id}, }, - text_input="test_text", + text_input=None, context=test_context, language="*", assistant="conversation", @@ -412,7 +410,6 @@ async def test_assist_api_prompt( llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -760,7 +757,6 @@ async def test_script_tool( llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -961,7 +957,6 @@ async def test_script_tool_name(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1241,7 +1236,6 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1344,7 +1338,6 @@ async def test_todo_get_items_tool(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1451,7 +1444,6 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 3ddbecaf48d..51ee467b029 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1262,3 +1262,30 @@ def test_label_selector_schema(schema, valid_selections, invalid_selections) -> def test_floor_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test floor selector.""" _test_selector("floor", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + [ + ( + {}, + ("sensor.temperature",), + (None, ["sensor.temperature"]), + ), + ( + {"multiple": True}, + (["sensor.temperature", "sensor:external_temperature"], []), + ("sensor.temperature",), + ), + ( + {"multiple": False}, + ("sensor.temperature",), + (None, ["sensor.temperature"]), + ), + ], +) +def test_statistic_selector_schema( + schema, valid_selections, invalid_selections +) -> None: + """Test statistic selector.""" + _test_selector("statistic", schema, valid_selections, invalid_selections) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 38e7e1ae452..5d018f5f3ee 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -16,6 +16,7 @@ from homeassistant import exceptions from homeassistant.auth.permissions import PolicyPermissions import homeassistant.components # noqa: F401 from homeassistant.components.group import DOMAIN as DOMAIN_GROUP, Group +from homeassistant.components.input_button import DOMAIN as DOMAIN_INPUT_BUTTON from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER from homeassistant.components.shell_command import DOMAIN as DOMAIN_SHELL_COMMAND from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH @@ -42,7 +43,12 @@ from homeassistant.helpers import ( entity_registry as er, service, ) -from homeassistant.loader import async_get_integration +from homeassistant.helpers.translation import async_get_translations +from homeassistant.loader import ( + Integration, + async_get_integration, + async_get_integrations, +) from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml @@ -1092,38 +1098,66 @@ async def test_async_get_all_descriptions_failing_integration( """Test async_get_all_descriptions when async_get_integrations returns an exception.""" group_config = {DOMAIN_GROUP: {}} await async_setup_component(hass, DOMAIN_GROUP, group_config) - descriptions = await service.async_get_all_descriptions(hass) - - assert len(descriptions) == 1 - - assert "description" in descriptions["group"]["reload"] - assert "fields" in descriptions["group"]["reload"] logger_config = {DOMAIN_LOGGER: {}} await async_setup_component(hass, DOMAIN_LOGGER, logger_config) + + input_button_config = {DOMAIN_INPUT_BUTTON: {}} + await async_setup_component(hass, DOMAIN_INPUT_BUTTON, input_button_config) + + async def wrap_get_integrations( + hass: HomeAssistant, domains: Iterable[str] + ) -> dict[str, Integration | Exception]: + integrations = await async_get_integrations(hass, domains) + integrations[DOMAIN_LOGGER] = ImportError("Failed to load services.yaml") + return integrations + + async def wrap_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, str]: + translations = await async_get_translations( + hass, language, category, integrations, config_flow + ) + return { + key: value + for key, value in translations.items() + if not key.startswith("component.logger.services.") + } + with ( patch( "homeassistant.helpers.service.async_get_integrations", - return_value={"logger": ImportError}, + wraps=wrap_get_integrations, ), patch( "homeassistant.helpers.service.translation.async_get_translations", - return_value={}, + wrap_get_translations, ), ): descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 2 - assert "Failed to load integration: logger" in caplog.text + assert len(descriptions) == 3 + assert "Failed to load services.yaml for integration: logger" in caplog.text # Services are empty defaults if the load fails but should # not raise + assert descriptions[DOMAIN_GROUP]["remove"]["description"] + assert descriptions[DOMAIN_GROUP]["remove"]["fields"] + assert descriptions[DOMAIN_LOGGER]["set_level"] == { "description": "", "fields": {}, "name": "", } + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["description"] + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["fields"] == {} + assert "target" in descriptions[DOMAIN_INPUT_BUTTON]["press"] + hass.services.async_register(DOMAIN_LOGGER, "new_service", lambda x: None, None) service.async_set_service_schema( hass, DOMAIN_LOGGER, "new_service", {"description": "new service"} diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 9179a545256..ae426b13fcb 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1161,17 +1161,11 @@ def test_vacuum_entity(linter: UnittestLinter, type_hint_checker: BaseChecker) - class Entity(): pass - class ToggleEntity(Entity): - pass - - class _BaseVacuum(Entity): - pass - - class VacuumEntity(_BaseVacuum, ToggleEntity): + class StateVacuumEntity(Entity): pass class MyVacuum( #@ - VacuumEntity + StateVacuumEntity ): def send_command( self, diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 3a2007060ae..2bb58cd4d68 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -43,7 +43,7 @@ def mock_is_file(): """Mock is_file.""" # All files exist except for the old entity registry file with patch( - "os.path.isfile", lambda path: not path.endswith("entity_registry.yaml") + "os.path.isfile", lambda path: not str(path).endswith("entity_registry.yaml") ): yield diff --git a/tests/test_backports.py b/tests/test_backports.py deleted file mode 100644 index af485abbc36..00000000000 --- a/tests/test_backports.py +++ /dev/null @@ -1,41 +0,0 @@ -"""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, - ) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 2af7ef4dc07..9e1f246b551 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -85,6 +85,17 @@ async def test_async_enable_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test to ensure logging is migrated to the queue handlers.""" + config_log_file_pattern = get_test_config_dir("home-assistant.log*") + arg_log_file_pattern = "test.log*" + + # Ensure we start with a clean slate + for f in glob.glob(arg_log_file_pattern): + os.remove(f) + for f in glob.glob(config_log_file_pattern): + os.remove(f) + assert len(glob.glob(config_log_file_pattern)) == 0 + assert len(glob.glob(arg_log_file_pattern)) == 0 + with ( patch("logging.getLogger"), patch( @@ -97,6 +108,8 @@ async def test_async_enable_logging( ): await bootstrap.async_enable_logging(hass) mock_async_activate_log_queue_handler.assert_called_once() + assert len(glob.glob(config_log_file_pattern)) > 0 + mock_async_activate_log_queue_handler.reset_mock() await bootstrap.async_enable_logging( hass, @@ -104,13 +117,15 @@ async def test_async_enable_logging( log_file="test.log", ) mock_async_activate_log_queue_handler.assert_called_once() - for f in glob.glob("test.log*"): - os.remove(f) - for f in glob.glob("testing_config/home-assistant.log*"): - os.remove(f) + assert len(glob.glob(arg_log_file_pattern)) > 0 assert "Error rolling over log file" in caplog.text + for f in glob.glob(arg_log_file_pattern): + os.remove(f) + for f in glob.glob(config_log_file_pattern): + os.remove(f) + async def test_load_hassio(hass: HomeAssistant) -> None: """Test that we load the hassio integration when using Supervisor.""" diff --git a/tests/test_core.py b/tests/test_core.py index 50f7f92727b..d4b5933aebe 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -255,45 +255,51 @@ async def test_async_add_hass_job_schedule_partial_callback() -> None: partial = functools.partial(ha.callback(job)) ha.HomeAssistant._async_add_hass_job(hass, ha.HassJob(partial)) - assert len(hass.loop.call_soon.mock_calls) == 1 - assert len(hass.loop.create_task.mock_calls) == 0 - assert len(hass.add_job.mock_calls) == 0 + assert hass.loop.call_soon.call_count == 1 + assert hass.loop.create_task.call_count == 0 + assert hass.add_job.call_count == 0 async def test_async_add_hass_job_schedule_corofunction_eager_start() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + hass = MagicMock(loop=(loop := asyncio.get_running_loop())) async def job(): pass - with patch( - "homeassistant.core.create_eager_task", wraps=create_eager_task - ) as mock_create_eager_task: + with ( + patch( + "homeassistant.core.create_eager_task", wraps=create_eager_task + ) as mock_create_eager_task, + patch.object(loop, "call_soon") as mock_loop_call_soon, + ): hass_job = ha.HassJob(job) task = ha.HomeAssistant._async_add_hass_job(hass, hass_job) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.add_job.mock_calls) == 0 + assert mock_loop_call_soon.call_count == 0 + assert hass.add_job.call_count == 0 assert mock_create_eager_task.mock_calls await task async def test_async_add_hass_job_schedule_partial_corofunction_eager_start() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + hass = MagicMock(loop=(loop := asyncio.get_running_loop())) async def job(): pass partial = functools.partial(job) - with patch( - "homeassistant.core.create_eager_task", wraps=create_eager_task - ) as mock_create_eager_task: + with ( + patch( + "homeassistant.core.create_eager_task", wraps=create_eager_task + ) as mock_create_eager_task, + patch.object(loop, "call_soon") as mock_loop_call_soon, + ): hass_job = ha.HassJob(partial) task = ha.HomeAssistant._async_add_hass_job(hass, hass_job) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.add_job.mock_calls) == 0 + assert mock_loop_call_soon.call_count == 0 + assert hass.add_job.call_count == 0 assert mock_create_eager_task.mock_calls await task @@ -306,35 +312,42 @@ async def test_async_add_job_add_hass_threaded_job_to_pool() -> None: pass ha.HomeAssistant._async_add_hass_job(hass, ha.HassJob(job)) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.loop.create_task.mock_calls) == 0 - assert len(hass.loop.run_in_executor.mock_calls) == 2 + assert hass.loop.call_soon.call_count == 0 + assert hass.loop.create_task.call_count == 0 + assert hass.loop.run_in_executor.call_count == 1 async def test_async_create_task_schedule_coroutine() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + hass = MagicMock(loop=(loop := asyncio.get_running_loop())) async def job(): pass - ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=False) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.loop.create_task.mock_calls) == 1 - assert len(hass.add_job.mock_calls) == 0 + with ( + patch.object(loop, "call_soon") as mock_loop_call_soon, + patch.object(loop, "create_task") as mock_loop_create_task, + ): + coro = job() + ha.HomeAssistant.async_create_task_internal(hass, coro, eager_start=False) + assert mock_loop_call_soon.call_count == 0 + assert mock_loop_create_task.call_count == 1 + assert hass.add_job.call_count == 0 + await coro async def test_async_create_task_eager_start_schedule_coroutine() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + hass = MagicMock(loop=(loop := asyncio.get_running_loop())) async def job(): pass - ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=True) - # Should create the task directly since 3.12 supports eager_start - assert len(hass.loop.create_task.mock_calls) == 0 - assert len(hass.add_job.mock_calls) == 0 + with patch.object(loop, "create_task") as mock_loop_create_task: + ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=True) + # Should create the task directly since 3.12 supports eager_start + assert mock_loop_create_task.call_count == 0 + assert hass.add_job.call_count == 0 async def test_async_create_task_schedule_coroutine_with_name() -> None: @@ -344,13 +357,15 @@ async def test_async_create_task_schedule_coroutine_with_name() -> None: async def job(): pass + coro = job() task = ha.HomeAssistant.async_create_task_internal( - hass, job(), "named task", eager_start=False + hass, coro, "named task", eager_start=False ) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.loop.create_task.mock_calls) == 1 - assert len(hass.add_job.mock_calls) == 0 + assert hass.loop.call_soon.call_count == 0 + assert hass.loop.create_task.call_count == 1 + assert hass.add_job.call_count == 0 assert "named task" in str(task) + await coro async def test_async_run_eager_hass_job_calls_callback() -> None: diff --git a/tests/test_loader.py b/tests/test_loader.py index 16515cbd4e6..2d5ad76aa8a 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -134,8 +134,7 @@ async def test_custom_component_name(hass: HomeAssistant) -> None: assert platform.__package__ == "custom_components.test" # Test custom components is mounted - # pylint: disable-next=import-outside-toplevel - from custom_components.test_package import TEST + from custom_components.test_package import TEST # noqa: PLC0415 assert TEST == 5 @@ -1295,12 +1294,11 @@ async def test_config_folder_not_in_path() -> None: # Verify that we are unable to import this file from top level with pytest.raises(ImportError): - # pylint: disable-next=import-outside-toplevel - import check_config_not_in_path # noqa: F401 + import check_config_not_in_path # noqa: F401, PLC0415 # Verify that we are able to load the file with absolute path - # pylint: disable-next=import-outside-toplevel,hass-relative-import - import tests.testing_config.check_config_not_in_path # noqa: F401 + # pylint: disable-next=hass-relative-import + import tests.testing_config.check_config_not_in_path # noqa: F401, PLC0415 async def test_async_get_component_preloads_config_and_config_flow( diff --git a/tests/test_main.py b/tests/test_main.py index d32ca59a846..acb0146545e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -36,7 +36,7 @@ def test_validate_python(mock_exit) -> None: with patch( "sys.version_info", new_callable=PropertyMock( - return_value=(REQUIRED_PYTHON_VER[0] - 1,) + REQUIRED_PYTHON_VER[1:] + return_value=(REQUIRED_PYTHON_VER[0] - 1, *REQUIRED_PYTHON_VER[1:]) ), ): main.validate_python() @@ -55,7 +55,7 @@ def test_validate_python(mock_exit) -> None: with patch( "sys.version_info", new_callable=PropertyMock( - return_value=(REQUIRED_PYTHON_VER[:2]) + (REQUIRED_PYTHON_VER[2] + 1,) + return_value=(*REQUIRED_PYTHON_VER[:2], REQUIRED_PYTHON_VER[2] + 1) ), ): main.validate_python() diff --git a/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml b/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml new file mode 100644 index 00000000000..94a13f699ec --- /dev/null +++ b/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +alarm_control_panel: + availability: "{{ sensor | has_value }}" + state: "{{ 'armed_home' if is_state(sensor,'on') else 'disarmed' }}" diff --git a/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml b/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml new file mode 100644 index 00000000000..3cdda37644b --- /dev/null +++ b/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +binary_sensor: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor, 'on') }}" diff --git a/tests/testing_config/blueprints/template/test_cover_with_variables.yaml b/tests/testing_config/blueprints/template/test_cover_with_variables.yaml new file mode 100644 index 00000000000..dcef425f3a0 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_cover_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +cover: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + open_cover: [] + close_cover: [] diff --git a/tests/testing_config/blueprints/template/test_fan_with_variables.yaml b/tests/testing_config/blueprints/template/test_fan_with_variables.yaml new file mode 100644 index 00000000000..c37cd325420 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_fan_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +fan: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_image_with_variables.yaml b/tests/testing_config/blueprints/template/test_image_with_variables.yaml new file mode 100644 index 00000000000..990cf403f0c --- /dev/null +++ b/tests/testing_config/blueprints/template/test_image_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +image: + availability: "{{ sensor | has_value }}" + url: "{{ states(sensor) }}" diff --git a/tests/testing_config/blueprints/template/test_light_with_variables.yaml b/tests/testing_config/blueprints/template/test_light_with_variables.yaml new file mode 100644 index 00000000000..90b70d12105 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_light_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +light: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_lock_with_variables.yaml b/tests/testing_config/blueprints/template/test_lock_with_variables.yaml new file mode 100644 index 00000000000..3c2e53bdff4 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_lock_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +lock: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + lock: [] + unlock: [] diff --git a/tests/testing_config/blueprints/template/test_number_with_variables.yaml b/tests/testing_config/blueprints/template/test_number_with_variables.yaml new file mode 100644 index 00000000000..55c829a4a6e --- /dev/null +++ b/tests/testing_config/blueprints/template/test_number_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +number: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + set_value: [] + step: 1 diff --git a/tests/testing_config/blueprints/template/test_select_with_variables.yaml b/tests/testing_config/blueprints/template/test_select_with_variables.yaml new file mode 100644 index 00000000000..35d55f1abe9 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_select_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +select: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + options: "{{ ['option1', 'option2'] }}" + select_option: [] diff --git a/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml b/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml new file mode 100644 index 00000000000..41d5dcf5bb6 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +sensor: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" diff --git a/tests/testing_config/blueprints/template/test_switch_with_variables.yaml b/tests/testing_config/blueprints/template/test_switch_with_variables.yaml new file mode 100644 index 00000000000..7e145de9976 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_switch_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +switch: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml b/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml new file mode 100644 index 00000000000..63858da9943 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml @@ -0,0 +1,17 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +vacuum: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + start: [] diff --git a/tests/testing_config/blueprints/template/test_weather_with_variables.yaml b/tests/testing_config/blueprints/template/test_weather_with_variables.yaml new file mode 100644 index 00000000000..d50702bde81 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_weather_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +weather: + availability: "{{ sensor | has_value }}" + condition_template: "{{ states(sensor) }}" + temperature_template: "{{ 20 }}" + humidity_template: "{{ 25 }}" diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index dacbd2c1247..94c3dd204f7 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -559,6 +559,10 @@ def test_load_yaml_dict(expected_data: Any) -> None: @pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") def test_load_yaml_dict_fail() -> None: """Test item without a key.""" + # Make sure we raise a subclass of HomeAssistantError, not + # annotated_yaml.YAMLException + assert issubclass(yaml_loader.YamlTypeError, HomeAssistantError) + with pytest.raises(yaml_loader.YamlTypeError): yaml_loader.load_yaml_dict(YAML_CONFIG_FILE)