Compare commits

..

1 Commits

Author SHA1 Message Date
epenet
1bfac54e56 Drop ignore-missing-annotations from pylint 2025-08-10 17:28:33 +02:00
3325 changed files with 45216 additions and 273411 deletions

View File

@@ -1,77 +0,0 @@
---
name: quality-scale-rule-verifier
description: |
Use this agent when you need to verify that a Home Assistant integration follows a specific quality scale rule. This includes checking if the integration implements required patterns, configurations, or code structures defined by the quality scale system.
<example>
Context: The user wants to verify if an integration follows a specific quality scale rule.
user: "Check if the peblar integration follows the config-flow rule"
assistant: "I'll use the quality scale rule verifier to check if the peblar integration properly implements the config-flow rule."
<commentary>
Since the user is asking to verify a quality scale rule implementation, use the quality-scale-rule-verifier agent.
</commentary>
</example>
<example>
Context: The user is reviewing if an integration reaches a specific quality scale level.
user: "Verify that this integration reaches the bronze quality scale"
assistant: "Let me use the quality scale rule verifier to check the bronze quality scale implementation."
<commentary>
The user wants to verify the integration has reached a certain quality level, so use multiple quality-scale-rule-verifier agents to verify each bronze rule.
</commentary>
</example>
model: inherit
color: yellow
tools: Read, Bash, Grep, Glob, WebFetch
---
You are an expert Home Assistant integration quality scale auditor specializing in verifying compliance with specific quality scale rules. You have deep knowledge of Home Assistant's architecture, best practices, and the quality scale system that ensures integration consistency and reliability.
You will verify if an integration follows a specific quality scale rule by:
1. **Fetching Rule Documentation**: Retrieve the official rule documentation from:
`https://raw.githubusercontent.com/home-assistant/developers.home-assistant/refs/heads/master/docs/core/integration-quality-scale/rules/{rule_name}.md`
where `{rule_name}` is the rule identifier (e.g., 'config-flow', 'entity-unique-id', 'parallel-updates')
2. **Understanding Rule Requirements**: Parse the rule documentation to identify:
- Core requirements and mandatory implementations
- Specific code patterns or configurations required
- Common violations and anti-patterns
- Exemption criteria (when a rule might not apply)
- The quality tier this rule belongs to (Bronze, Silver, Gold, Platinum)
3. **Analyzing Integration Code**: Examine the integration's codebase at `homeassistant/components/<integration domain>` focusing on:
- `manifest.json` for quality scale declaration and configuration
- `quality_scale.yaml` for rule status (done, todo, exempt)
- Relevant Python modules based on the rule requirements
- Configuration files and service definitions as needed
4. **Verification Process**:
- Check if the rule is marked as 'done', 'todo', or 'exempt' in quality_scale.yaml
- If marked 'exempt', verify the exemption reason is valid
- If marked 'done', verify the actual implementation matches requirements
- Identify specific files and code sections that demonstrate compliance or violations
- Consider the integration's declared quality tier when applying rules
- To fetch the integration docs, use WebFetch to fetch from `https://raw.githubusercontent.com/home-assistant/home-assistant.io/refs/heads/current/source/_integrations/<integration domain>.markdown`
- To fetch information about a PyPI package, use the URL `https://pypi.org/pypi/<package>/json`
5. **Reporting Findings**: Provide a comprehensive verification report that includes:
- **Rule Summary**: Brief description of what the rule requires
- **Compliance Status**: Clear pass/fail/exempt determination
- **Evidence**: Specific code examples showing compliance or violations
- **Issues Found**: Detailed list of any non-compliance issues with file locations
- **Recommendations**: Actionable steps to achieve compliance if needed
- **Exemption Analysis**: If applicable, whether the exemption is justified
When examining code, you will:
- Look for exact implementation patterns specified in the rule
- Verify all required components are present and properly configured
- Check for common mistakes and anti-patterns
- Consider edge cases and error handling requirements
- Validate that implementations follow Home Assistant conventions
You will be thorough but focused, examining only the aspects relevant to the specific rule being verified. You will provide clear, actionable feedback that helps developers understand both what needs to be fixed and why it matters for integration quality.
If you cannot access the rule documentation or find the integration code, clearly state what information is missing and what you would need to complete the verification.
Remember that quality scale rules are cumulative - Bronze rules apply to all integrations with a quality scale, Silver rules apply to Silver+ integrations, and so on. Always consider the integration's target quality level when determining which rules should be enforced.

View File

@@ -58,7 +58,6 @@ base_platforms: &base_platforms
# Extra components that trigger the full suite # Extra components that trigger the full suite
components: &components components: &components
- homeassistant/components/alexa/** - homeassistant/components/alexa/**
- homeassistant/components/analytics/**
- homeassistant/components/application_credentials/** - homeassistant/components/application_credentials/**
- homeassistant/components/assist_pipeline/** - homeassistant/components/assist_pipeline/**
- homeassistant/components/auth/** - homeassistant/components/auth/**

View File

@@ -8,8 +8,6 @@
"PYTHONASYNCIODEBUG": "1" "PYTHONASYNCIODEBUG": "1"
}, },
"features": { "features": {
// Node feature required for Claude Code until fixed https://github.com/anthropics/devcontainer-features/issues/28
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {} "ghcr.io/devcontainers/features/github-cli:1": {}
}, },

View File

@@ -14,8 +14,7 @@ tests
# Other virtualization methods # Other virtualization methods
venv venv
.venv
.vagrant .vagrant
# Temporary files # Temporary files
**/__pycache__ **/__pycache__

View File

@@ -55,12 +55,8 @@
creating the PR. If you're unsure about any of them, don't hesitate to ask. creating the PR. If you're unsure about any of them, don't hesitate to ask.
We're here to help! This is simply a reminder of what we are going to look We're here to help! This is simply a reminder of what we are going to look
for before merging your code. for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
--> -->
- [ ] I understand the code I am submitting and can explain how it works.
- [ ] The code change is tested and works locally. - [ ] The code change is tested and works locally.
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
- [ ] There is no commented out code in this PR. - [ ] There is no commented out code in this PR.
@@ -68,7 +64,6 @@
- [ ] I have followed the [perfect PR recommendations][perfect-pr] - [ ] I have followed the [perfect PR recommendations][perfect-pr]
- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`) - [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`)
- [ ] Tests have been added to verify that the new code works. - [ ] Tests have been added to verify that the new code works.
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
If user exposed functionality or configuration variables are added/changed: If user exposed functionality or configuration variables are added/changed:

View File

@@ -1073,11 +1073,7 @@ async def test_flow_connection_error(hass, mock_api_error):
### Entity Testing Patterns ### Entity Testing Patterns
```python ```python
@pytest.fixture @pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True)
def platforms() -> list[Platform]:
"""Overridden fixture to specify platforms to test."""
return [Platform.SENSOR] # Or another specific platform as needed.
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities( async def test_entities(
hass: HomeAssistant, hass: HomeAssistant,
@@ -1124,25 +1120,16 @@ def mock_device_api() -> Generator[MagicMock]:
) )
yield api yield api
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return PLATFORMS
@pytest.fixture @pytest.fixture
async def init_integration( async def init_integration(
hass: HomeAssistant, hass: HomeAssistant,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
mock_device_api: MagicMock, mock_device_api: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Set up the integration for testing.""" """Set up the integration for testing."""
mock_config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
with patch("homeassistant.components.my_integration.PLATFORMS", platforms): await hass.async_block_till_done()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry return mock_config_entry
``` ```

View File

@@ -27,12 +27,12 @@ jobs:
publish: ${{ steps.version.outputs.publish }} publish: ${{ steps.version.outputs.publish }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations - name: Upload translations
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz
@@ -90,11 +90,11 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Download nightly wheels of frontend - name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11 uses: dawidd6/action-download-artifact@v11
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents - name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11 uses: dawidd6/action-download-artifact@v11
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package repo: OHF-Voice/intents-package
@@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations - name: Download translations
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: translations name: translations
@@ -190,15 +190,14 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# home-assistant/builder doesn't support sha pinning
- name: Build base image - name: Build base image
uses: home-assistant/builder@2025.09.0 uses: home-assistant/builder@2025.03.0
with: with:
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
@@ -243,7 +242,7 @@ jobs:
- green - green
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Set build additional args - name: Set build additional args
run: | run: |
@@ -257,15 +256,14 @@ jobs:
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# home-assistant/builder doesn't support sha pinning
- name: Build base image - name: Build base image
uses: home-assistant/builder@2025.09.0 uses: home-assistant/builder@2025.03.0
with: with:
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
@@ -281,7 +279,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Initialize git - name: Initialize git
uses: home-assistant/actions/helpers/git-init@master uses: home-assistant/actions/helpers/git-init@master
@@ -323,23 +321,23 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 uses: sigstore/cosign-installer@v3.9.2
with: with:
cosign-release: "v2.2.3" cosign-release: "v2.2.3"
- name: Login to DockerHub - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@v3.5.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant' if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -456,15 +454,15 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations - name: Download translations
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: translations name: translations
@@ -482,7 +480,7 @@ jobs:
python -m build python -m build
- name: Upload package to PyPI - name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 uses: pypa/gh-action-pypi-publish@v1.12.4
with: with:
skip-existing: true skip-existing: true
@@ -501,10 +499,10 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }} HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -533,7 +531,7 @@ jobs:
- name: Generate artifact attestation - name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with: with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}

File diff suppressed because it is too large Load Diff

View File

@@ -21,14 +21,14 @@ jobs:
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 uses: github/codeql-action/init@v3.29.7
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6 uses: github/codeql-action/analyze@v3.29.7
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Check if integration label was added and extract details - name: Check if integration label was added and extract details
id: extract id: extract
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v7.0.1
with: with:
script: | script: |
// Debug: Log the event payload // Debug: Log the event payload
@@ -113,7 +113,7 @@ jobs:
- name: Fetch similar issues - name: Fetch similar issues
id: fetch_similar id: fetch_similar
if: steps.extract.outputs.should_continue == 'true' if: steps.extract.outputs.should_continue == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v7.0.1
env: env:
INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }} INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }}
CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }} CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }}
@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI - name: Detect duplicates using AI
id: ai_detection id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1 uses: actions/ai-inference@v1.2.8
with: with:
model: openai/gpt-4o model: openai/gpt-4o
system-prompt: | system-prompt: |
@@ -280,7 +280,7 @@ jobs:
- name: Post duplicate detection results - name: Post duplicate detection results
id: post_results id: post_results
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v7.0.1
env: env:
AI_RESPONSE: ${{ steps.ai_detection.outputs.response }} AI_RESPONSE: ${{ steps.ai_detection.outputs.response }}
SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }} SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }}

View File

@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Check issue language - name: Check issue language
id: detect_language id: detect_language
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v7.0.1
env: env:
ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_TITLE: ${{ github.event.issue.title }}
@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI - name: Detect language using AI
id: ai_language_detection id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true' if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1 uses: actions/ai-inference@v1.2.8
with: with:
model: openai/gpt-4o-mini model: openai/gpt-4o-mini
system-prompt: | system-prompt: |
@@ -90,7 +90,7 @@ jobs:
- name: Process non-English issues - name: Process non-English issues
if: steps.detect_language.outputs.should_continue == 'true' if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v7.0.1
env: env:
AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }} AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }}
ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }} ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }}

View File

@@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 - uses: dessant/lock-threads@v5.0.1
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
issue-inactive-days: "30" issue-inactive-days: "30"

View File

@@ -9,10 +9,10 @@ jobs:
check-authorization: check-authorization:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form) # Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task' if: github.event.issue.issue_type == 'Task'
steps: steps:
- name: Check if user is authorized - name: Check if user is authorized
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@v7
with: with:
script: | script: |
const issueAuthor = context.payload.issue.user.login; const issueAuthor = context.payload.issue.user.login;

View File

@@ -17,7 +17,7 @@ jobs:
# - No PRs marked as no-stale # - No PRs marked as no-stale
# - No issues (-1) # - No issues (-1)
- name: 60 days stale PRs policy - name: 60 days stale PRs policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@v9.1.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60 days-before-stale: 60
@@ -57,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted # - No issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: 90 days stale issues - name: 90 days stale issues
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@v9.1.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90 days-before-stale: 90
@@ -87,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted # - No Issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: Needs more information stale issues policy - name: Needs more information stale issues policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@v9.1.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information" only-labels: "needs-more-information"

View File

@@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

View File

@@ -32,11 +32,11 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }} architectures: ${{ steps.info.outputs.architectures }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v5.6.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@@ -91,7 +91,7 @@ jobs:
) > build_constraints.txt ) > build_constraints.txt
- name: Upload env_file - name: Upload env_file
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: env_file name: env_file
path: ./.env_file path: ./.env_file
@@ -99,14 +99,14 @@ jobs:
overwrite: true overwrite: true
- name: Upload build_constraints - name: Upload build_constraints
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: build_constraints name: build_constraints
path: ./build_constraints.txt path: ./build_constraints.txt
overwrite: true overwrite: true
- name: Upload requirements_diff - name: Upload requirements_diff
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: requirements_diff name: requirements_diff
path: ./requirements_diff.txt path: ./requirements_diff.txt
@@ -118,7 +118,7 @@ jobs:
python -m script.gen_requirements_all ci python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels - name: Upload requirements_all_wheels
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: requirements_all_wheels name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt path: ./requirements_all_wheels_*.txt
@@ -135,20 +135,20 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: env_file name: env_file
- name: Download build_constraints - name: Download build_constraints
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: build_constraints name: build_constraints
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: requirements_diff name: requirements_diff
@@ -158,9 +158,8 @@ jobs:
sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt sed -i "/uv/d" requirements_diff.txt
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2025.09.1 uses: home-assistant/wheels@2025.07.0
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@@ -185,25 +184,25 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4.2.2
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: env_file name: env_file
- name: Download build_constraints - name: Download build_constraints
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: build_constraints name: build_constraints
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: requirements_diff name: requirements_diff
- name: Download requirements_all_wheels - name: Download requirements_all_wheels
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: requirements_all_wheels name: requirements_all_wheels
@@ -219,9 +218,8 @@ jobs:
sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt sed -i "/uv/d" requirements_diff.txt
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2025.09.1 uses: home-assistant/wheels@2025.07.0
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2

2
.gitignore vendored
View File

@@ -140,5 +140,5 @@ tmp_cache
pytest_buckets.txt pytest_buckets.txt
# AI tooling # AI tooling
.claude/settings.local.json .claude

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.0 rev: v0.12.1
hooks: hooks:
- id: ruff-check - id: ruff-check
args: args:
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html] exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v5.0.0
hooks: hooks:
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
stages: [manual] stages: [manual]

View File

@@ -142,7 +142,6 @@ homeassistant.components.cloud.*
homeassistant.components.co2signal.* homeassistant.components.co2signal.*
homeassistant.components.comelit.* homeassistant.components.comelit.*
homeassistant.components.command_line.* homeassistant.components.command_line.*
homeassistant.components.compit.*
homeassistant.components.config.* homeassistant.components.config.*
homeassistant.components.configurator.* homeassistant.components.configurator.*
homeassistant.components.cookidoo.* homeassistant.components.cookidoo.*
@@ -170,7 +169,6 @@ homeassistant.components.dnsip.*
homeassistant.components.doorbird.* homeassistant.components.doorbird.*
homeassistant.components.dormakaba_dkey.* homeassistant.components.dormakaba_dkey.*
homeassistant.components.downloader.* homeassistant.components.downloader.*
homeassistant.components.droplet.*
homeassistant.components.dsmr.* homeassistant.components.dsmr.*
homeassistant.components.duckdns.* homeassistant.components.duckdns.*
homeassistant.components.dunehd.* homeassistant.components.dunehd.*
@@ -203,7 +201,6 @@ homeassistant.components.feedreader.*
homeassistant.components.file_upload.* homeassistant.components.file_upload.*
homeassistant.components.filesize.* homeassistant.components.filesize.*
homeassistant.components.filter.* homeassistant.components.filter.*
homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.* homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.* homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.* homeassistant.components.flux_led.*
@@ -310,7 +307,6 @@ homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.* homeassistant.components.led_ble.*
homeassistant.components.lektrico.* homeassistant.components.lektrico.*
homeassistant.components.letpot.* homeassistant.components.letpot.*
homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.* homeassistant.components.lidarr.*
homeassistant.components.lifx.* homeassistant.components.lifx.*
homeassistant.components.light.* homeassistant.components.light.*
@@ -326,7 +322,6 @@ homeassistant.components.london_underground.*
homeassistant.components.lookin.* homeassistant.components.lookin.*
homeassistant.components.lovelace.* homeassistant.components.lovelace.*
homeassistant.components.luftdaten.* homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.madvr.* homeassistant.components.madvr.*
homeassistant.components.manual.* homeassistant.components.manual.*
homeassistant.components.mastodon.* homeassistant.components.mastodon.*
@@ -387,7 +382,6 @@ homeassistant.components.openai_conversation.*
homeassistant.components.openexchangerates.* homeassistant.components.openexchangerates.*
homeassistant.components.opensky.* homeassistant.components.opensky.*
homeassistant.components.openuv.* homeassistant.components.openuv.*
homeassistant.components.opnsense.*
homeassistant.components.opower.* homeassistant.components.opower.*
homeassistant.components.oralb.* homeassistant.components.oralb.*
homeassistant.components.otbr.* homeassistant.components.otbr.*
@@ -405,7 +399,6 @@ homeassistant.components.person.*
homeassistant.components.pi_hole.* homeassistant.components.pi_hole.*
homeassistant.components.ping.* homeassistant.components.ping.*
homeassistant.components.plugwise.* homeassistant.components.plugwise.*
homeassistant.components.portainer.*
homeassistant.components.powerfox.* homeassistant.components.powerfox.*
homeassistant.components.powerwall.* homeassistant.components.powerwall.*
homeassistant.components.private_ble_device.* homeassistant.components.private_ble_device.*
@@ -445,7 +438,6 @@ homeassistant.components.rituals_perfume_genie.*
homeassistant.components.roborock.* homeassistant.components.roborock.*
homeassistant.components.roku.* homeassistant.components.roku.*
homeassistant.components.romy.* homeassistant.components.romy.*
homeassistant.components.route_b_smart_meter.*
homeassistant.components.rpi_power.* homeassistant.components.rpi_power.*
homeassistant.components.rss_feed_template.* homeassistant.components.rss_feed_template.*
homeassistant.components.russound_rio.* homeassistant.components.russound_rio.*
@@ -466,7 +458,6 @@ homeassistant.components.sensorpush_cloud.*
homeassistant.components.sensoterra.* homeassistant.components.sensoterra.*
homeassistant.components.senz.* homeassistant.components.senz.*
homeassistant.components.sfr_box.* homeassistant.components.sfr_box.*
homeassistant.components.sftp_storage.*
homeassistant.components.shell_command.* homeassistant.components.shell_command.*
homeassistant.components.shelly.* homeassistant.components.shelly.*
homeassistant.components.shopping_list.* homeassistant.components.shopping_list.*
@@ -475,7 +466,6 @@ homeassistant.components.simplisafe.*
homeassistant.components.siren.* homeassistant.components.siren.*
homeassistant.components.skybell.* homeassistant.components.skybell.*
homeassistant.components.slack.* homeassistant.components.slack.*
homeassistant.components.sleep_as_android.*
homeassistant.components.sleepiq.* homeassistant.components.sleepiq.*
homeassistant.components.smhi.* homeassistant.components.smhi.*
homeassistant.components.smlight.* homeassistant.components.smlight.*
@@ -555,7 +545,6 @@ homeassistant.components.vacuum.*
homeassistant.components.vallox.* homeassistant.components.vallox.*
homeassistant.components.valve.* homeassistant.components.valve.*
homeassistant.components.velbus.* homeassistant.components.velbus.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.* homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.* homeassistant.components.vodafone_station.*
homeassistant.components.volvo.* homeassistant.components.volvo.*

View File

@@ -1 +0,0 @@
.github/copilot-instructions.md

129
CODEOWNERS generated
View File

@@ -87,8 +87,6 @@ build.json @home-assistant/supervisor
/tests/components/airzone/ @Noltari /tests/components/airzone/ @Noltari
/homeassistant/components/airzone_cloud/ @Noltari /homeassistant/components/airzone_cloud/ @Noltari
/tests/components/airzone_cloud/ @Noltari /tests/components/airzone_cloud/ @Noltari
/homeassistant/components/aladdin_connect/ @swcloudgenie
/tests/components/aladdin_connect/ @swcloudgenie
/homeassistant/components/alarm_control_panel/ @home-assistant/core /homeassistant/components/alarm_control_panel/ @home-assistant/core
/tests/components/alarm_control_panel/ @home-assistant/core /tests/components/alarm_control_panel/ @home-assistant/core
/homeassistant/components/alert/ @home-assistant/core @frenck /homeassistant/components/alert/ @home-assistant/core @frenck
@@ -107,8 +105,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/ambient_station/ @bachya /homeassistant/components/ambient_station/ @bachya
/tests/components/ambient_station/ @bachya /tests/components/ambient_station/ @bachya
/homeassistant/components/amcrest/ @flacjacket /homeassistant/components/amcrest/ @flacjacket
/homeassistant/components/analytics/ @home-assistant/core /homeassistant/components/analytics/ @home-assistant/core @ludeeus
/tests/components/analytics/ @home-assistant/core /tests/components/analytics/ @home-assistant/core @ludeeus
/homeassistant/components/analytics_insights/ @joostlek /homeassistant/components/analytics_insights/ @joostlek
/tests/components/analytics_insights/ @joostlek /tests/components/analytics_insights/ @joostlek
/homeassistant/components/android_ip_webcam/ @engrbm87 /homeassistant/components/android_ip_webcam/ @engrbm87
@@ -154,12 +152,12 @@ build.json @home-assistant/supervisor
/tests/components/arve/ @ikalnyi /tests/components/arve/ @ikalnyi
/homeassistant/components/aseko_pool_live/ @milanmeu /homeassistant/components/aseko_pool_live/ @milanmeu
/tests/components/aseko_pool_live/ @milanmeu /tests/components/aseko_pool_live/ @milanmeu
/homeassistant/components/assist_pipeline/ @synesthesiam @arturpragacz /homeassistant/components/assist_pipeline/ @balloob @synesthesiam
/tests/components/assist_pipeline/ @synesthesiam @arturpragacz /tests/components/assist_pipeline/ @balloob @synesthesiam
/homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam
/tests/components/assist_satellite/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/assist_satellite/ @home-assistant/core @synesthesiam
/homeassistant/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi /homeassistant/components/asuswrt/ @kennedyshead @ollo69
/tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi /tests/components/asuswrt/ @kennedyshead @ollo69
/homeassistant/components/atag/ @MatsNL /homeassistant/components/atag/ @MatsNL
/tests/components/atag/ @MatsNL /tests/components/atag/ @MatsNL
/homeassistant/components/aten_pe/ @mtdcr /homeassistant/components/aten_pe/ @mtdcr
@@ -292,16 +290,14 @@ build.json @home-assistant/supervisor
/tests/components/command_line/ @gjohansson-ST /tests/components/command_line/ @gjohansson-ST
/homeassistant/components/compensation/ @Petro31 /homeassistant/components/compensation/ @Petro31
/tests/components/compensation/ @Petro31 /tests/components/compensation/ @Petro31
/homeassistant/components/compit/ @Przemko92
/tests/components/compit/ @Przemko92
/homeassistant/components/config/ @home-assistant/core /homeassistant/components/config/ @home-assistant/core
/tests/components/config/ @home-assistant/core /tests/components/config/ @home-assistant/core
/homeassistant/components/configurator/ @home-assistant/core /homeassistant/components/configurator/ @home-assistant/core
/tests/components/configurator/ @home-assistant/core /tests/components/configurator/ @home-assistant/core
/homeassistant/components/control4/ @lawtancool /homeassistant/components/control4/ @lawtancool
/tests/components/control4/ @lawtancool /tests/components/control4/ @lawtancool
/homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/conversation/ @home-assistant/core @synesthesiam
/tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/conversation/ @home-assistant/core @synesthesiam
/homeassistant/components/cookidoo/ @miaucl /homeassistant/components/cookidoo/ @miaucl
/tests/components/cookidoo/ @miaucl /tests/components/cookidoo/ @miaucl
/homeassistant/components/coolmaster/ @OnFreund /homeassistant/components/coolmaster/ @OnFreund
@@ -316,8 +312,6 @@ build.json @home-assistant/supervisor
/tests/components/crownstone/ @Crownstone @RicArch97 /tests/components/crownstone/ @Crownstone @RicArch97
/homeassistant/components/cups/ @fabaff /homeassistant/components/cups/ @fabaff
/tests/components/cups/ @fabaff /tests/components/cups/ @fabaff
/homeassistant/components/cync/ @Kinachi249
/tests/components/cync/ @Kinachi249
/homeassistant/components/daikin/ @fredrike /homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike /tests/components/daikin/ @fredrike
/homeassistant/components/date/ @home-assistant/core /homeassistant/components/date/ @home-assistant/core
@@ -381,8 +375,6 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob /tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer
/homeassistant/components/droplet/ @sarahseidman
/tests/components/droplet/ @sarahseidman
/homeassistant/components/dsmr/ @Robbie1221 /homeassistant/components/dsmr/ @Robbie1221
/tests/components/dsmr/ @Robbie1221 /tests/components/dsmr/ @Robbie1221
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
@@ -412,8 +404,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/egardia/ @jeroenterheerdt
/homeassistant/components/eheimdigital/ @autinerd /homeassistant/components/eheimdigital/ @autinerd
/tests/components/eheimdigital/ @autinerd /tests/components/eheimdigital/ @autinerd
/homeassistant/components/ekeybionyx/ @richardpolzer
/tests/components/ekeybionyx/ @richardpolzer
/homeassistant/components/electrasmart/ @jafar-atili /homeassistant/components/electrasmart/ @jafar-atili
/tests/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000 /homeassistant/components/electric_kiwi/ @mikey0000
@@ -432,8 +422,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/emby/ @mezz64 /homeassistant/components/emby/ @mezz64
/homeassistant/components/emoncms/ @borpin @alexandrecuer /homeassistant/components/emoncms/ @borpin @alexandrecuer
/tests/components/emoncms/ @borpin @alexandrecuer /tests/components/emoncms/ @borpin @alexandrecuer
/homeassistant/components/emoncms_history/ @alexandrecuer
/tests/components/emoncms_history/ @alexandrecuer
/homeassistant/components/emonitor/ @bdraco /homeassistant/components/emonitor/ @bdraco
/tests/components/emonitor/ @bdraco /tests/components/emonitor/ @bdraco
/homeassistant/components/emulated_hue/ @bdraco @Tho85 /homeassistant/components/emulated_hue/ @bdraco @Tho85
@@ -448,8 +436,10 @@ build.json @home-assistant/supervisor
/tests/components/energyzero/ @klaasnicolaas /tests/components/energyzero/ @klaasnicolaas
/homeassistant/components/enigma2/ @autinerd /homeassistant/components/enigma2/ @autinerd
/tests/components/enigma2/ @autinerd /tests/components/enigma2/ @autinerd
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /homeassistant/components/enocean/ @bdurrer
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /tests/components/enocean/ @bdurrer
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie
@@ -470,6 +460,8 @@ build.json @home-assistant/supervisor
/tests/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99
/homeassistant/components/event/ @home-assistant/core /homeassistant/components/event/ @home-assistant/core
/tests/components/event/ @home-assistant/core /tests/components/event/ @home-assistant/core
/homeassistant/components/evil_genius_labs/ @balloob
/tests/components/evil_genius_labs/ @balloob
/homeassistant/components/evohome/ @zxdavb /homeassistant/components/evohome/ @zxdavb
/tests/components/evohome/ @zxdavb /tests/components/evohome/ @zxdavb
/homeassistant/components/ezviz/ @RenierM26 /homeassistant/components/ezviz/ @RenierM26
@@ -492,8 +484,6 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST /tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes /homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes /tests/components/filter/ @dgomes
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky /homeassistant/components/fireservicerota/ @cyberjunky
/tests/components/fireservicerota/ @cyberjunky /tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP /homeassistant/components/firmata/ @DaAwesomeP
@@ -521,8 +511,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/forked_daapd/ @uvjustin /homeassistant/components/forked_daapd/ @uvjustin
/tests/components/forked_daapd/ @uvjustin /tests/components/forked_daapd/ @uvjustin
/homeassistant/components/fortios/ @kimfrellsen /homeassistant/components/fortios/ @kimfrellsen
/homeassistant/components/foscam/ @Foscam-wangzhengyu /homeassistant/components/foscam/ @krmarien
/tests/components/foscam/ @Foscam-wangzhengyu /tests/components/foscam/ @krmarien
/homeassistant/components/freebox/ @hacf-fr @Quentame /homeassistant/components/freebox/ @hacf-fr @Quentame
/tests/components/freebox/ @hacf-fr @Quentame /tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415 /homeassistant/components/freedompro/ @stefano055415
@@ -656,8 +646,6 @@ build.json @home-assistant/supervisor
/tests/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core
/homeassistant/components/homeassistant_alerts/ @home-assistant/core /homeassistant/components/homeassistant_alerts/ @home-assistant/core
/tests/components/homeassistant_alerts/ @home-assistant/core /tests/components/homeassistant_alerts/ @home-assistant/core
/homeassistant/components/homeassistant_connect_zbt2/ @home-assistant/core
/tests/components/homeassistant_connect_zbt2/ @home-assistant/core
/homeassistant/components/homeassistant_green/ @home-assistant/core /homeassistant/components/homeassistant_green/ @home-assistant/core
/tests/components/homeassistant_green/ @home-assistant/core /tests/components/homeassistant_green/ @home-assistant/core
/homeassistant/components/homeassistant_hardware/ @home-assistant/core /homeassistant/components/homeassistant_hardware/ @home-assistant/core
@@ -686,8 +674,8 @@ build.json @home-assistant/supervisor
/tests/components/http/ @home-assistant/core /tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle /homeassistant/components/huawei_lte/ @scop @fphammerle
/tests/components/huawei_lte/ @scop @fphammerle /tests/components/huawei_lte/ @scop @fphammerle
/homeassistant/components/hue/ @marcelveldt /homeassistant/components/hue/ @balloob @marcelveldt
/tests/components/hue/ @marcelveldt /tests/components/hue/ @balloob @marcelveldt
/homeassistant/components/huisbaasje/ @dennisschroer /homeassistant/components/huisbaasje/ @dennisschroer
/tests/components/huisbaasje/ @dennisschroer /tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
@@ -759,8 +747,8 @@ build.json @home-assistant/supervisor
/tests/components/integration/ @dgomes /tests/components/integration/ @dgomes
/homeassistant/components/intellifire/ @jeeftor /homeassistant/components/intellifire/ @jeeftor
/tests/components/intellifire/ @jeeftor /tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/intent/ @home-assistant/core @synesthesiam
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/intent/ @home-assistant/core @synesthesiam
/homeassistant/components/intesishome/ @jnimmo /homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @MaestroOnICe /homeassistant/components/iometer/ @MaestroOnICe
/tests/components/iometer/ @MaestroOnICe /tests/components/iometer/ @MaestroOnICe
@@ -778,8 +766,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/iqvia/ @bachya /homeassistant/components/iqvia/ @bachya
/tests/components/iqvia/ @bachya /tests/components/iqvia/ @bachya
/homeassistant/components/irish_rail_transport/ @ttroy50 /homeassistant/components/irish_rail_transport/ @ttroy50
/homeassistant/components/irm_kmi/ @jdejaegh
/tests/components/irm_kmi/ @jdejaegh
/homeassistant/components/iron_os/ @tr4nt0r /homeassistant/components/iron_os/ @tr4nt0r
/tests/components/iron_os/ @tr4nt0r /tests/components/iron_os/ @tr4nt0r
/homeassistant/components/isal/ @bdraco /homeassistant/components/isal/ @bdraco
@@ -870,8 +856,6 @@ build.json @home-assistant/supervisor
/tests/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
/tests/components/lg_thinq/ @LG-ThinQ-Integration /tests/components/lg_thinq/ @LG-ThinQ-Integration
/homeassistant/components/libre_hardware_monitor/ @Sab44
/tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lidarr/ @tkdrob /homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob
/homeassistant/components/lifx/ @Djelibeybi /homeassistant/components/lifx/ @Djelibeybi
@@ -910,8 +894,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/luci/ @mzdrale /homeassistant/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck /homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck
/homeassistant/components/lunatone/ @MoonDevLT
/tests/components/lunatone/ @MoonDevLT
/homeassistant/components/lupusec/ @majuss @suaveolent /homeassistant/components/lupusec/ @majuss @suaveolent
/tests/components/lupusec/ @majuss @suaveolent /tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce /homeassistant/components/lutron/ @cdheiser @wilburCForce
@@ -957,8 +939,6 @@ build.json @home-assistant/supervisor
/tests/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore
/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame
/homeassistant/components/meteo_lt/ @xE1H
/tests/components/meteo_lt/ @xE1H
/homeassistant/components/meteoalarm/ @rolfberkenbosch /homeassistant/components/meteoalarm/ @rolfberkenbosch
/homeassistant/components/meteoclimatic/ @adrianmo /homeassistant/components/meteoclimatic/ @adrianmo
/tests/components/meteoclimatic/ @adrianmo /tests/components/meteoclimatic/ @adrianmo
@@ -1029,8 +1009,7 @@ build.json @home-assistant/supervisor
/tests/components/nanoleaf/ @milanmeu @joostlek /tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio /homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio /tests/components/nasweb/ @nasWebio
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /homeassistant/components/nederlandse_spoorwegen/ @YarmoM
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/homeassistant/components/ness_alarm/ @nickw444 /homeassistant/components/ness_alarm/ @nickw444
/tests/components/ness_alarm/ @nickw444 /tests/components/ness_alarm/ @nickw444
/homeassistant/components/nest/ @allenporter /homeassistant/components/nest/ @allenporter
@@ -1065,8 +1044,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nilu/ @hfurubotten
/homeassistant/components/nina/ @DeerMaximum /homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum
/homeassistant/components/nintendo_parental/ @pantherale0
/tests/components/nintendo_parental/ @pantherale0
/homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/nissan_leaf/ @filcole
/homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
@@ -1127,6 +1104,8 @@ build.json @home-assistant/supervisor
/tests/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek /homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek /tests/components/open_router/ @joostlek
/homeassistant/components/openai_conversation/ @balloob
/tests/components/openai_conversation/ @balloob
/homeassistant/components/openerz/ @misialq /homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq /tests/components/openerz/ @misialq
/homeassistant/components/openexchangerates/ @MartinHjelmare /homeassistant/components/openexchangerates/ @MartinHjelmare
@@ -1198,14 +1177,12 @@ build.json @home-assistant/supervisor
/tests/components/plex/ @jjlawren /tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew /homeassistant/components/plugwise/ @CoMPaTech @bouwew
/tests/components/plugwise/ @CoMPaTech @bouwew /tests/components/plugwise/ @CoMPaTech @bouwew
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike /homeassistant/components/point/ @fredrike
/tests/components/point/ @fredrike /tests/components/point/ @fredrike
/homeassistant/components/pooldose/ @lmaertin
/tests/components/pooldose/ @lmaertin
/homeassistant/components/poolsense/ @haemishkyd /homeassistant/components/poolsense/ @haemishkyd
/tests/components/poolsense/ @haemishkyd /tests/components/poolsense/ @haemishkyd
/homeassistant/components/portainer/ @erwindouna
/tests/components/portainer/ @erwindouna
/homeassistant/components/powerfox/ @klaasnicolaas /homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas /tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
@@ -1225,6 +1202,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/proximity/ @mib1185 /homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185 /tests/components/proximity/ @mib1185
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno /homeassistant/components/proxmoxve/ @jhollowe @Corbeno
/homeassistant/components/prusalink/ @balloob
/tests/components/prusalink/ @balloob
/homeassistant/components/ps4/ @ktnrg45 /homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato /homeassistant/components/pterodactyl/ @elmurato
@@ -1318,8 +1297,8 @@ build.json @home-assistant/supervisor
/tests/components/rflink/ @javicalle /tests/components/rflink/ @javicalle
/homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
/tests/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 /tests/components/rfxtrx/ @danielhiversen @elupus @RobBie1221
/homeassistant/components/rhasspy/ @synesthesiam /homeassistant/components/rhasspy/ @balloob @synesthesiam
/tests/components/rhasspy/ @synesthesiam /tests/components/rhasspy/ @balloob @synesthesiam
/homeassistant/components/ridwell/ @bachya /homeassistant/components/ridwell/ @bachya
/tests/components/ridwell/ @bachya /tests/components/ridwell/ @bachya
/homeassistant/components/ring/ @sdb9696 /homeassistant/components/ring/ @sdb9696
@@ -1340,8 +1319,6 @@ build.json @home-assistant/supervisor
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous /tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roon/ @pavoni /homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni /tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
/tests/components/route_b_smart_meter/ @SeraphicRav
/homeassistant/components/rpi_power/ @shenxn @swetoast /homeassistant/components/rpi_power/ @shenxn @swetoast
/tests/components/rpi_power/ @shenxn @swetoast /tests/components/rpi_power/ @shenxn @swetoast
/homeassistant/components/rss_feed_template/ @home-assistant/core /homeassistant/components/rss_feed_template/ @home-assistant/core
@@ -1364,8 +1341,6 @@ build.json @home-assistant/supervisor
/tests/components/samsungtv/ @chemelli74 @epenet /tests/components/samsungtv/ @chemelli74 @epenet
/homeassistant/components/sanix/ @tomaszsluszniak /homeassistant/components/sanix/ @tomaszsluszniak
/tests/components/sanix/ @tomaszsluszniak /tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen
/tests/components/satel_integra/ @Tommatheussen
/homeassistant/components/scene/ @home-assistant/core /homeassistant/components/scene/ @home-assistant/core
/tests/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core /homeassistant/components/schedule/ @home-assistant/core
@@ -1411,14 +1386,12 @@ build.json @home-assistant/supervisor
/tests/components/seventeentrack/ @shaiu /tests/components/seventeentrack/ @shaiu
/homeassistant/components/sfr_box/ @epenet /homeassistant/components/sfr_box/ @epenet
/tests/components/sfr_box/ @epenet /tests/components/sfr_box/ @epenet
/homeassistant/components/sftp_storage/ @maretodoric
/tests/components/sftp_storage/ @maretodoric
/homeassistant/components/sharkiq/ @JeffResc @funkybunch /homeassistant/components/sharkiq/ @JeffResc @funkybunch
/tests/components/sharkiq/ @JeffResc @funkybunch /tests/components/sharkiq/ @JeffResc @funkybunch
/homeassistant/components/shell_command/ @home-assistant/core /homeassistant/components/shell_command/ @home-assistant/core
/tests/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco /homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco
/tests/components/shelly/ @bieniu @thecode @chemelli74 @bdraco /tests/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco
/homeassistant/components/shodan/ @fabaff /homeassistant/components/shodan/ @fabaff
/homeassistant/components/sia/ @eavanvalkenburg /homeassistant/components/sia/ @eavanvalkenburg
/tests/components/sia/ @eavanvalkenburg /tests/components/sia/ @eavanvalkenburg
@@ -1442,8 +1415,6 @@ build.json @home-assistant/supervisor
/tests/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau /homeassistant/components/slack/ @tkdrob @fletcherau
/tests/components/slack/ @tkdrob @fletcherau /tests/components/slack/ @tkdrob @fletcherau
/homeassistant/components/sleep_as_android/ @tr4nt0r
/tests/components/sleep_as_android/ @tr4nt0r
/homeassistant/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/sleepiq/ @mfugate1 @kbickar
/tests/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar
/homeassistant/components/slide/ @ualex73 /homeassistant/components/slide/ @ualex73
@@ -1547,8 +1518,8 @@ build.json @home-assistant/supervisor
/tests/components/switchbee/ @jafar-atili /tests/components/switchbee/ @jafar-atili
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang /homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git /homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git /tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza /homeassistant/components/switcher_kis/ @thecode @YogevBokobza
/tests/components/switcher_kis/ @thecode @YogevBokobza /tests/components/switcher_kis/ @thecode @YogevBokobza
/homeassistant/components/switchmate/ @danielhiversen @qiz-li /homeassistant/components/switchmate/ @danielhiversen @qiz-li
@@ -1565,8 +1536,8 @@ build.json @home-assistant/supervisor
/tests/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/tado/ @erwindouna /homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna /tests/components/tado/ @erwindouna
/homeassistant/components/tag/ @home-assistant/core /homeassistant/components/tag/ @balloob @dmulcahey
/tests/components/tag/ @home-assistant/core /tests/components/tag/ @balloob @dmulcahey
/homeassistant/components/tailscale/ @frenck /homeassistant/components/tailscale/ @frenck
/tests/components/tailscale/ @frenck /tests/components/tailscale/ @frenck
/homeassistant/components/tailwind/ @frenck /homeassistant/components/tailwind/ @frenck
@@ -1693,8 +1664,6 @@ build.json @home-assistant/supervisor
/tests/components/uptime_kuma/ @tr4nt0r /tests/components/uptime_kuma/ @tr4nt0r
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74 /homeassistant/components/uptimerobot/ @ludeeus @chemelli74
/tests/components/uptimerobot/ @ludeeus @chemelli74 /tests/components/uptimerobot/ @ludeeus @chemelli74
/homeassistant/components/usage_prediction/ @home-assistant/core
/tests/components/usage_prediction/ @home-assistant/core
/homeassistant/components/usb/ @bdraco /homeassistant/components/usb/ @bdraco
/tests/components/usb/ @bdraco /tests/components/usb/ @bdraco
/homeassistant/components/usgs_earthquakes_feed/ @exxamalte /homeassistant/components/usgs_earthquakes_feed/ @exxamalte
@@ -1713,19 +1682,17 @@ build.json @home-assistant/supervisor
/tests/components/vegehub/ @ghowevege /tests/components/vegehub/ @ghowevege
/homeassistant/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew /homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew /tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio
/homeassistant/components/venstar/ @garbled1 @jhollowe /homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/versasense/ @imstevenxyz
/homeassistant/components/version/ @ludeeus /homeassistant/components/version/ @ludeeus
/tests/components/version/ @ludeeus /tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak
/homeassistant/components/vicare/ @CFenner /homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner /tests/components/vicare/ @CFenner
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW /homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel /homeassistant/components/vivotek/ @HarlemSquirrel
@@ -1735,14 +1702,16 @@ build.json @home-assistant/supervisor
/tests/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare
/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74 /homeassistant/components/vodafone_station/ @paoloantinori @chemelli74
/tests/components/vodafone_station/ @paoloantinori @chemelli74 /tests/components/vodafone_station/ @paoloantinori @chemelli74
/homeassistant/components/voip/ @synesthesiam @jaminh /homeassistant/components/voip/ @balloob @synesthesiam @jaminh
/tests/components/voip/ @synesthesiam @jaminh /tests/components/voip/ @balloob @synesthesiam @jaminh
/homeassistant/components/volumio/ @OnFreund /homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund
/homeassistant/components/volvo/ @thomasddn /homeassistant/components/volvo/ @thomasddn
/tests/components/volvo/ @thomasddn /tests/components/volvo/ @thomasddn
/homeassistant/components/volvooncall/ @molobrakos @svrooij /homeassistant/components/volvooncall/ @molobrakos
/tests/components/volvooncall/ @molobrakos @svrooij /tests/components/volvooncall/ @molobrakos
/homeassistant/components/vulcan/ @Antoni-Czaplicki
/tests/components/vulcan/ @Antoni-Czaplicki
/homeassistant/components/wake_on_lan/ @ntilley905 /homeassistant/components/wake_on_lan/ @ntilley905
/tests/components/wake_on_lan/ @ntilley905 /tests/components/wake_on_lan/ @ntilley905
/homeassistant/components/wake_word/ @home-assistant/core @synesthesiam /homeassistant/components/wake_word/ @home-assistant/core @synesthesiam
@@ -1807,8 +1776,8 @@ build.json @home-assistant/supervisor
/tests/components/worldclock/ @fabaff /tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger /homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger /tests/components/ws66i/ @ssaenger
/homeassistant/components/wyoming/ @synesthesiam /homeassistant/components/wyoming/ @balloob @synesthesiam
/tests/components/wyoming/ @synesthesiam /tests/components/wyoming/ @balloob @synesthesiam
/homeassistant/components/xbox/ @hunterjm /homeassistant/components/xbox/ @hunterjm
/tests/components/xbox/ @hunterjm /tests/components/xbox/ @hunterjm
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi

View File

@@ -14,8 +14,5 @@ Still interested? Then you should take a peek at the [developer documentation](h
## Feature suggestions ## Feature suggestions
If you want to suggest a new feature for Home Assistant (e.g. new integrations), please [start a discussion](https://github.com/orgs/home-assistant/discussions) on GitHub. If you want to suggest a new feature for Home Assistant (e.g., new integrations), please open a thread in our [Community Forum: Feature Requests](https://community.home-assistant.io/c/feature-requests).
We use [GitHub for tracking issues](https://github.com/home-assistant/core/issues), not for tracking feature requests.
## Issue Tracker
If you want to report an issue, please [create an issue](https://github.com/home-assistant/core/issues) on GitHub.

2
Dockerfile generated
View File

@@ -31,7 +31,7 @@ RUN \
&& go2rtc --version && go2rtc --version
# Install uv # Install uv
RUN pip3 install uv==0.8.9 RUN pip3 install uv==0.7.1
WORKDIR /usr/src WORKDIR /usr/src

View File

@@ -3,7 +3,8 @@ FROM mcr.microsoft.com/vscode/devcontainers/base:debian
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN \ RUN \
apt-get update \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
# Additional library needed by some tests and accordingly by VScode Tests Discovery # Additional library needed by some tests and accordingly by VScode Tests Discovery
bluez \ bluez \

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View File

@@ -187,42 +187,36 @@ def main() -> int:
from . import config, runner # noqa: PLC0415 from . import config, runner # noqa: PLC0415
# Ensure only one instance runs per config directory safe_mode = config.safe_mode_enabled(config_dir)
with runner.ensure_single_execution(config_dir) as single_execution_lock:
# Check if another instance is already running
if single_execution_lock.exit_code is not None:
return single_execution_lock.exit_code
safe_mode = config.safe_mode_enabled(config_dir) runtime_conf = runner.RuntimeConfig(
config_dir=config_dir,
verbose=args.verbose,
log_rotate_days=args.log_rotate_days,
log_file=args.log_file,
log_no_color=args.log_no_color,
skip_pip=args.skip_pip,
skip_pip_packages=args.skip_pip_packages,
recovery_mode=args.recovery_mode,
debug=args.debug,
open_ui=args.open_ui,
safe_mode=safe_mode,
)
runtime_conf = runner.RuntimeConfig( fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME)
config_dir=config_dir, with open(fault_file_name, mode="a", encoding="utf8") as fault_file:
verbose=args.verbose, faulthandler.enable(fault_file)
log_rotate_days=args.log_rotate_days, exit_code = runner.run(runtime_conf)
log_file=args.log_file, faulthandler.disable()
log_no_color=args.log_no_color,
skip_pip=args.skip_pip,
skip_pip_packages=args.skip_pip_packages,
recovery_mode=args.recovery_mode,
debug=args.debug,
open_ui=args.open_ui,
safe_mode=safe_mode,
)
fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME) # It's possible for the fault file to disappear, so suppress obvious errors
with open(fault_file_name, mode="a", encoding="utf8") as fault_file: with suppress(FileNotFoundError):
faulthandler.enable(fault_file) if os.path.getsize(fault_file_name) == 0:
exit_code = runner.run(runtime_conf) os.remove(fault_file_name)
faulthandler.disable()
# It's possible for the fault file to disappear, so suppress obvious errors check_threads()
with suppress(FileNotFoundError):
if os.path.getsize(fault_file_name) == 0:
os.remove(fault_file_name)
check_threads() return exit_code
return exit_code
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -27,7 +27,7 @@ from . import (
SetupFlow, SetupFlow,
) )
REQUIREMENTS = ["pyotp==2.9.0"] REQUIREMENTS = ["pyotp==2.8.0"]
CONF_MESSAGE = "message" CONF_MESSAGE = "message"

View File

@@ -20,7 +20,7 @@ from . import (
SetupFlow, SetupFlow,
) )
REQUIREMENTS = ["pyotp==2.9.0", "PyQRCode==1.2.1"] REQUIREMENTS = ["pyotp==2.8.0", "PyQRCode==1.2.1"]
CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA) CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({}, extra=vol.PREVENT_EXTRA)

View File

@@ -616,34 +616,34 @@ async def async_enable_logging(
), ),
) )
logger = logging.getLogger() # Log errors to a file if we have write access to file or config dir
logger.setLevel(logging.INFO if verbose else logging.WARNING)
if log_file is None: if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME) err_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
err_log_path = None
else:
err_log_path = default_log_path
else: else:
err_log_path = os.path.abspath(log_file) err_log_path = os.path.abspath(log_file)
if err_log_path: err_path_exists = os.path.isfile(err_log_path)
err_dir = os.path.dirname(err_log_path)
# Check if we can write to the error log if it exists or that
# we can create files in the containing directory if not.
if (err_path_exists and os.access(err_log_path, os.W_OK)) or (
not err_path_exists and os.access(err_dir, os.W_OK)
):
err_handler = await hass.async_add_executor_job( err_handler = await hass.async_add_executor_job(
_create_log_file, err_log_path, log_rotate_days _create_log_file, err_log_path, log_rotate_days
) )
err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
logger = logging.getLogger()
logger.addHandler(err_handler) logger.addHandler(err_handler)
logger.setLevel(logging.INFO if verbose else logging.WARNING)
# Save the log file location for access by other components. # Save the log file location for access by other components.
hass.data[DATA_LOGGING] = err_log_path hass.data[DATA_LOGGING] = err_log_path
else:
_LOGGER.error("Unable to set up error log %s (access denied)", err_log_path)
async_activate_log_queue_handler(hass) async_activate_log_queue_handler(hass)

View File

@@ -1,5 +0,0 @@
{
"domain": "eltako",
"name": "Eltako",
"iot_standards": ["matter"]
}

View File

@@ -1,5 +1,5 @@
{ {
"domain": "fritzbox", "domain": "fritzbox",
"name": "FRITZ!", "name": "FRITZ!Box",
"integrations": ["fritz", "fritzbox", "fritzbox_callmonitor"] "integrations": ["fritz", "fritzbox", "fritzbox_callmonitor"]
} }

View File

@@ -6,6 +6,7 @@
"google_assistant_sdk", "google_assistant_sdk",
"google_cloud", "google_cloud",
"google_drive", "google_drive",
"google_gemini",
"google_generative_ai_conversation", "google_generative_ai_conversation",
"google_mail", "google_mail",
"google_maps", "google_maps",

View File

@@ -0,0 +1,5 @@
{
"domain": "ibm",
"name": "IBM",
"integrations": ["watson_iot", "watson_tts"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "konnected",
"name": "Konnected",
"integrations": ["konnected", "konnected_esphome"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "level",
"name": "Level",
"iot_standards": ["matter"]
}

View File

@@ -8,17 +8,14 @@ import logging
from aioacaia.acaiascale import AcaiaScale from aioacaia.acaiascale import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from homeassistant.components.bluetooth import async_get_scanner
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15) SCAN_INTERVAL = timedelta(seconds=15)
UPDATE_DEBOUNCE_TIME = 0.2
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -40,20 +37,11 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
config_entry=entry, config_entry=entry,
) )
debouncer = Debouncer(
hass=hass,
logger=_LOGGER,
cooldown=UPDATE_DEBOUNCE_TIME,
immediate=True,
function=self.async_update_listeners,
)
self._scale = AcaiaScale( self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS], address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title, name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=debouncer.async_schedule_call, notify_callback=self.async_update_listeners,
scanner=async_get_scanner(hass),
) )
@property @property

View File

@@ -26,5 +26,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aioacaia"], "loggers": ["aioacaia"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioacaia==0.1.17"] "requirements": ["aioacaia==0.1.14"]
} }

View File

@@ -2,23 +2,21 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from accuweather import AccuWeather from accuweather import AccuWeather
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.const import CONF_API_KEY, Platform from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN, UPDATE_INTERVAL_DAILY_FORECAST, UPDATE_INTERVAL_OBSERVATION
from .coordinator import ( from .coordinator import (
AccuWeatherConfigEntry, AccuWeatherConfigEntry,
AccuWeatherDailyForecastDataUpdateCoordinator, AccuWeatherDailyForecastDataUpdateCoordinator,
AccuWeatherData, AccuWeatherData,
AccuWeatherHourlyForecastDataUpdateCoordinator,
AccuWeatherObservationDataUpdateCoordinator, AccuWeatherObservationDataUpdateCoordinator,
) )
@@ -30,6 +28,7 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry) -> bool:
"""Set up AccuWeather as config entry.""" """Set up AccuWeather as config entry."""
api_key: str = entry.data[CONF_API_KEY] api_key: str = entry.data[CONF_API_KEY]
name: str = entry.data[CONF_NAME]
location_key = entry.unique_id location_key = entry.unique_id
@@ -42,28 +41,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry)
hass, hass,
entry, entry,
accuweather, accuweather,
name,
"observation",
UPDATE_INTERVAL_OBSERVATION,
) )
coordinator_daily_forecast = AccuWeatherDailyForecastDataUpdateCoordinator( coordinator_daily_forecast = AccuWeatherDailyForecastDataUpdateCoordinator(
hass, hass,
entry, entry,
accuweather, accuweather,
) name,
coordinator_hourly_forecast = AccuWeatherHourlyForecastDataUpdateCoordinator( "daily forecast",
hass, UPDATE_INTERVAL_DAILY_FORECAST,
entry,
accuweather,
) )
await asyncio.gather( await coordinator_observation.async_config_entry_first_refresh()
coordinator_observation.async_config_entry_first_refresh(), await coordinator_daily_forecast.async_config_entry_first_refresh()
coordinator_daily_forecast.async_config_entry_first_refresh(),
coordinator_hourly_forecast.async_config_entry_first_refresh(),
)
entry.runtime_data = AccuWeatherData( entry.runtime_data = AccuWeatherData(
coordinator_observation=coordinator_observation, coordinator_observation=coordinator_observation,
coordinator_daily_forecast=coordinator_daily_forecast, coordinator_daily_forecast=coordinator_daily_forecast,
coordinator_hourly_forecast=coordinator_hourly_forecast,
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from asyncio import timeout from asyncio import timeout
from collections.abc import Mapping
from typing import Any from typing import Any
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
@@ -23,8 +22,6 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for AccuWeather.""" """Config flow for AccuWeather."""
VERSION = 1 VERSION = 1
_latitude: float | None = None
_longitude: float | None = None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -53,7 +50,6 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id( await self.async_set_unique_id(
accuweather.location_key, raise_on_progress=False accuweather.location_key, raise_on_progress=False
) )
self._abort_if_unique_id_configured()
return self.async_create_entry( return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input title=user_input[CONF_NAME], data=user_input
@@ -77,46 +73,3 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
), ),
errors=errors, errors=errors,
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
self._latitude = entry_data[CONF_LATITUDE]
self._longitude = entry_data[CONF_LONGITUDE]
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
if user_input is not None:
websession = async_get_clientsession(self.hass)
try:
async with timeout(10):
accuweather = AccuWeather(
user_input[CONF_API_KEY],
websession,
latitude=self._latitude,
longitude=self._longitude,
)
await accuweather.async_get_location()
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
errors["base"] = "cannot_connect"
except InvalidApiKeyError:
errors["base"] = "invalid_api_key"
except RequestsExceededError:
errors["base"] = "requests_exceeded"
else:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)

View File

@@ -69,6 +69,5 @@ POLLEN_CATEGORY_MAP = {
4: "very_high", 4: "very_high",
5: "extreme", 5: "extreme",
} }
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10) UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from asyncio import timeout from asyncio import timeout
from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging import logging
@@ -13,9 +12,7 @@ from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExcee
from aiohttp.client_exceptions import ClientConnectorError from aiohttp.client_exceptions import ClientConnectorError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator, DataUpdateCoordinator,
@@ -23,15 +20,9 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed, UpdateFailed,
) )
from .const import ( from .const import DOMAIN, MANUFACTURER
DOMAIN,
MANUFACTURER,
UPDATE_INTERVAL_DAILY_FORECAST,
UPDATE_INTERVAL_HOURLY_FORECAST,
UPDATE_INTERVAL_OBSERVATION,
)
EXCEPTIONS = (ApiError, ClientConnectorError, RequestsExceededError) EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -42,7 +33,6 @@ class AccuWeatherData:
coordinator_observation: AccuWeatherObservationDataUpdateCoordinator coordinator_observation: AccuWeatherObservationDataUpdateCoordinator
coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator
coordinator_hourly_forecast: AccuWeatherHourlyForecastDataUpdateCoordinator
type AccuWeatherConfigEntry = ConfigEntry[AccuWeatherData] type AccuWeatherConfigEntry = ConfigEntry[AccuWeatherData]
@@ -53,18 +43,18 @@ class AccuWeatherObservationDataUpdateCoordinator(
): ):
"""Class to manage fetching AccuWeather data API.""" """Class to manage fetching AccuWeather data API."""
config_entry: AccuWeatherConfigEntry
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry, config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather, accuweather: AccuWeather,
name: str,
coordinator_type: str,
update_interval: timedelta,
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
self.accuweather = accuweather self.accuweather = accuweather
self.location_key = accuweather.location_key self.location_key = accuweather.location_key
name = config_entry.data[CONF_NAME]
if TYPE_CHECKING: if TYPE_CHECKING:
assert self.location_key is not None assert self.location_key is not None
@@ -75,8 +65,8 @@ class AccuWeatherObservationDataUpdateCoordinator(
hass, hass,
_LOGGER, _LOGGER,
config_entry=config_entry, config_entry=config_entry,
name=f"{name} (observation)", name=f"{name} ({coordinator_type})",
update_interval=UPDATE_INTERVAL_OBSERVATION, update_interval=update_interval,
) )
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
@@ -90,39 +80,29 @@ class AccuWeatherObservationDataUpdateCoordinator(
translation_key="current_conditions_update_error", translation_key="current_conditions_update_error",
translation_placeholders={"error": repr(error)}, translation_placeholders={"error": repr(error)},
) from error ) from error
except InvalidApiKeyError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders={"entry": self.config_entry.title},
) from err
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
return result return result
class AccuWeatherForecastDataUpdateCoordinator( class AccuWeatherDailyForecastDataUpdateCoordinator(
TimestampDataUpdateCoordinator[list[dict[str, Any]]] TimestampDataUpdateCoordinator[list[dict[str, Any]]]
): ):
"""Base class for AccuWeather forecast.""" """Class to manage fetching AccuWeather data API."""
config_entry: AccuWeatherConfigEntry
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry, config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather, accuweather: AccuWeather,
name: str,
coordinator_type: str, coordinator_type: str,
update_interval: timedelta, update_interval: timedelta,
fetch_method: Callable[..., Awaitable[list[dict[str, Any]]]],
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
self.accuweather = accuweather self.accuweather = accuweather
self.location_key = accuweather.location_key self.location_key = accuweather.location_key
self._fetch_method = fetch_method
name = config_entry.data[CONF_NAME]
if TYPE_CHECKING: if TYPE_CHECKING:
assert self.location_key is not None assert self.location_key is not None
@@ -138,71 +118,24 @@ class AccuWeatherForecastDataUpdateCoordinator(
) )
async def _async_update_data(self) -> list[dict[str, Any]]: async def _async_update_data(self) -> list[dict[str, Any]]:
"""Update forecast data via library.""" """Update data via library."""
try: try:
async with timeout(10): async with timeout(10):
result = await self._fetch_method(language=self.hass.config.language) result = await self.accuweather.async_get_daily_forecast(
language=self.hass.config.language
)
except EXCEPTIONS as error: except EXCEPTIONS as error:
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="forecast_update_error", translation_key="forecast_update_error",
translation_placeholders={"error": repr(error)}, translation_placeholders={"error": repr(error)},
) from error ) from error
except InvalidApiKeyError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders={"entry": self.config_entry.title},
) from err
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
return result return result
class AccuWeatherDailyForecastDataUpdateCoordinator(
AccuWeatherForecastDataUpdateCoordinator
):
"""Coordinator for daily forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather,
) -> None:
"""Initialize."""
super().__init__(
hass,
config_entry,
accuweather,
"daily forecast",
UPDATE_INTERVAL_DAILY_FORECAST,
fetch_method=accuweather.async_get_daily_forecast,
)
class AccuWeatherHourlyForecastDataUpdateCoordinator(
AccuWeatherForecastDataUpdateCoordinator
):
"""Coordinator for hourly forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: AccuWeatherConfigEntry,
accuweather: AccuWeather,
) -> None:
"""Initialize."""
super().__init__(
hass,
config_entry,
accuweather,
"hourly forecast",
UPDATE_INTERVAL_HOURLY_FORECAST,
fetch_method=accuweather.async_get_hourly_forecast,
)
def _get_device_info(location_key: str, name: str) -> DeviceInfo: def _get_device_info(location_key: str, name: str) -> DeviceInfo:
"""Get device info.""" """Get device info."""
return DeviceInfo( return DeviceInfo(

View File

@@ -1,9 +1,6 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"air_quality": {
"default": "mdi:air-filter"
},
"cloud_ceiling": { "cloud_ceiling": {
"default": "mdi:weather-fog" "default": "mdi:weather-fog"
}, },
@@ -37,6 +34,9 @@
"thunderstorm_probability_night": { "thunderstorm_probability_night": {
"default": "mdi:weather-lightning" "default": "mdi:weather-lightning"
}, },
"translation_key": {
"default": "mdi:air-filter"
},
"tree_pollen": { "tree_pollen": {
"default": "mdi:tree-outline" "default": "mdi:tree-outline"
}, },

View File

@@ -7,5 +7,6 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["accuweather"], "loggers": ["accuweather"],
"requirements": ["accuweather==4.2.2"] "requirements": ["accuweather==4.2.0"],
"single_config_entry": true
} }

View File

@@ -7,17 +7,6 @@
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]", "latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]" "longitude": "[%key:common::config_flow::data::longitude%]"
},
"data_description": {
"api_key": "API key generated in the AccuWeather APIs portal."
}
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::accuweather::config::step::user::data_description::api_key%]"
} }
} }
}, },
@@ -28,10 +17,6 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key." "requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"entity": { "entity": {
@@ -251,9 +236,6 @@
} }
}, },
"exceptions": { "exceptions": {
"auth_error": {
"message": "Authentication failed for {entry}, please update your API key"
},
"current_conditions_update_error": { "current_conditions_update_error": {
"message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}" "message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}"
}, },

View File

@@ -45,7 +45,6 @@ from .coordinator import (
AccuWeatherConfigEntry, AccuWeatherConfigEntry,
AccuWeatherDailyForecastDataUpdateCoordinator, AccuWeatherDailyForecastDataUpdateCoordinator,
AccuWeatherData, AccuWeatherData,
AccuWeatherHourlyForecastDataUpdateCoordinator,
AccuWeatherObservationDataUpdateCoordinator, AccuWeatherObservationDataUpdateCoordinator,
) )
@@ -65,7 +64,6 @@ class AccuWeatherEntity(
CoordinatorWeatherEntity[ CoordinatorWeatherEntity[
AccuWeatherObservationDataUpdateCoordinator, AccuWeatherObservationDataUpdateCoordinator,
AccuWeatherDailyForecastDataUpdateCoordinator, AccuWeatherDailyForecastDataUpdateCoordinator,
AccuWeatherHourlyForecastDataUpdateCoordinator,
] ]
): ):
"""Define an AccuWeather entity.""" """Define an AccuWeather entity."""
@@ -78,7 +76,6 @@ class AccuWeatherEntity(
super().__init__( super().__init__(
observation_coordinator=accuweather_data.coordinator_observation, observation_coordinator=accuweather_data.coordinator_observation,
daily_coordinator=accuweather_data.coordinator_daily_forecast, daily_coordinator=accuweather_data.coordinator_daily_forecast,
hourly_coordinator=accuweather_data.coordinator_hourly_forecast,
) )
self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
@@ -89,13 +86,10 @@ class AccuWeatherEntity(
self._attr_unique_id = accuweather_data.coordinator_observation.location_key self._attr_unique_id = accuweather_data.coordinator_observation.location_key
self._attr_attribution = ATTRIBUTION self._attr_attribution = ATTRIBUTION
self._attr_device_info = accuweather_data.coordinator_observation.device_info self._attr_device_info = accuweather_data.coordinator_observation.device_info
self._attr_supported_features = ( self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
self.observation_coordinator = accuweather_data.coordinator_observation self.observation_coordinator = accuweather_data.coordinator_observation
self.daily_coordinator = accuweather_data.coordinator_daily_forecast self.daily_coordinator = accuweather_data.coordinator_daily_forecast
self.hourly_coordinator = accuweather_data.coordinator_hourly_forecast
@property @property
def condition(self) -> str | None: def condition(self) -> str | None:
@@ -213,32 +207,3 @@ class AccuWeatherEntity(
} }
for item in self.daily_coordinator.data for item in self.daily_coordinator.data
] ]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
return [
{
ATTR_FORECAST_TIME: utc_from_timestamp(
item["EpochDateTime"]
).isoformat(),
ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCover"],
ATTR_FORECAST_HUMIDITY: item["RelativeHumidity"],
ATTR_FORECAST_NATIVE_TEMP: item["Temperature"][ATTR_VALUE],
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperature"][
ATTR_VALUE
],
ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquid"][ATTR_VALUE],
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[
"PrecipitationProbability"
],
ATTR_FORECAST_NATIVE_WIND_SPEED: item["Wind"][ATTR_SPEED][ATTR_VALUE],
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item["WindGust"][ATTR_SPEED][
ATTR_VALUE
],
ATTR_FORECAST_UV_INDEX: item["UVIndex"],
ATTR_FORECAST_WIND_BEARING: item["Wind"][ATTR_DIRECTION]["Degrees"],
ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["WeatherIcon"]),
}
for item in self.hourly_coordinator.data
]

View File

@@ -29,19 +29,11 @@ from .const import (
DATA_PREFERENCES, DATA_PREFERENCES,
DOMAIN, DOMAIN,
SERVICE_GENERATE_DATA, SERVICE_GENERATE_DATA,
SERVICE_GENERATE_IMAGE,
AITaskEntityFeature, AITaskEntityFeature,
) )
from .entity import AITaskEntity from .entity import AITaskEntity
from .http import async_setup as async_setup_http from .http import async_setup as async_setup_http
from .task import ( from .task import GenDataTask, GenDataTaskResult, async_generate_data
GenDataTask,
GenDataTaskResult,
GenImageTask,
GenImageTaskResult,
async_generate_data,
async_generate_image,
)
__all__ = [ __all__ = [
"DOMAIN", "DOMAIN",
@@ -49,10 +41,7 @@ __all__ = [
"AITaskEntityFeature", "AITaskEntityFeature",
"GenDataTask", "GenDataTask",
"GenDataTaskResult", "GenDataTaskResult",
"GenImageTask",
"GenImageTaskResult",
"async_generate_data", "async_generate_data",
"async_generate_image",
"async_setup", "async_setup",
"async_setup_entry", "async_setup_entry",
"async_unload_entry", "async_unload_entry",
@@ -112,23 +101,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
job_type=HassJobType.Coroutinefunction, job_type=HassJobType.Coroutinefunction,
) )
hass.services.async_register(
DOMAIN,
SERVICE_GENERATE_IMAGE,
async_service_generate_image,
schema=vol.Schema(
{
vol.Required(ATTR_TASK_NAME): cv.string,
vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_INSTRUCTIONS): cv.string,
vol.Optional(ATTR_ATTACHMENTS): vol.All(
cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})]
),
}
),
supports_response=SupportsResponse.ONLY,
job_type=HassJobType.Coroutinefunction,
)
return True return True
@@ -143,23 +115,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_service_generate_data(call: ServiceCall) -> ServiceResponse: async def async_service_generate_data(call: ServiceCall) -> ServiceResponse:
"""Run the data task service.""" """Run the run task service."""
result = await async_generate_data(hass=call.hass, **call.data) result = await async_generate_data(hass=call.hass, **call.data)
return result.as_dict() return result.as_dict()
async def async_service_generate_image(call: ServiceCall) -> ServiceResponse:
"""Run the image task service."""
return await async_generate_image(hass=call.hass, **call.data)
class AITaskPreferences: class AITaskPreferences:
"""AI Task preferences.""" """AI Task preferences."""
KEYS = ("gen_data_entity_id", "gen_image_entity_id") KEYS = ("gen_data_entity_id",)
gen_data_entity_id: str | None = None gen_data_entity_id: str | None = None
gen_image_entity_id: str | None = None
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the preferences.""" """Initialize the preferences."""
@@ -173,21 +139,17 @@ class AITaskPreferences:
if data is None: if data is None:
return return
for key in self.KEYS: for key in self.KEYS:
setattr(self, key, data.get(key)) setattr(self, key, data[key])
@callback @callback
def async_set_preferences( def async_set_preferences(
self, self,
*, *,
gen_data_entity_id: str | None | UndefinedType = UNDEFINED, gen_data_entity_id: str | None | UndefinedType = UNDEFINED,
gen_image_entity_id: str | None | UndefinedType = UNDEFINED,
) -> None: ) -> None:
"""Set the preferences.""" """Set the preferences."""
changed = False changed = False
for key, value in ( for key, value in (("gen_data_entity_id", gen_data_entity_id),):
("gen_data_entity_id", gen_data_entity_id),
("gen_image_entity_id", gen_image_entity_id),
):
if value is not UNDEFINED: if value is not UNDEFINED:
if getattr(self, key) != value: if getattr(self, key) != value:
setattr(self, key, value) setattr(self, key, value)

View File

@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Final
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.components.media_source import local_source
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from . import AITaskPreferences from . import AITaskPreferences
@@ -17,13 +16,8 @@ if TYPE_CHECKING:
DOMAIN = "ai_task" DOMAIN = "ai_task"
DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN) DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN)
DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences") DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences")
DATA_MEDIA_SOURCE: HassKey[local_source.LocalSource] = HassKey(f"{DOMAIN}_media_source")
IMAGE_DIR: Final = "image"
IMAGE_EXPIRY_TIME = 60 * 60 # 1 hour
SERVICE_GENERATE_DATA = "generate_data" SERVICE_GENERATE_DATA = "generate_data"
SERVICE_GENERATE_IMAGE = "generate_image"
ATTR_INSTRUCTIONS: Final = "instructions" ATTR_INSTRUCTIONS: Final = "instructions"
ATTR_TASK_NAME: Final = "task_name" ATTR_TASK_NAME: Final = "task_name"
@@ -44,6 +38,3 @@ class AITaskEntityFeature(IntFlag):
SUPPORT_ATTACHMENTS = 2 SUPPORT_ATTACHMENTS = 2
"""Support attachments with generate data.""" """Support attachments with generate data."""
GENERATE_IMAGE = 4
"""Generate images based on instructions."""

View File

@@ -18,7 +18,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature
from .task import GenDataTask, GenDataTaskResult, GenImageTask, GenImageTaskResult from .task import GenDataTask, GenDataTaskResult
class AITaskEntity(RestoreEntity): class AITaskEntity(RestoreEntity):
@@ -57,13 +57,9 @@ class AITaskEntity(RestoreEntity):
async def _async_get_ai_task_chat_log( async def _async_get_ai_task_chat_log(
self, self,
session: ChatSession, session: ChatSession,
task: GenDataTask | GenImageTask, task: GenDataTask,
) -> AsyncGenerator[ChatLog]: ) -> AsyncGenerator[ChatLog]:
"""Context manager used to manage the ChatLog used during an AI Task.""" """Context manager used to manage the ChatLog used during an AI Task."""
user_llm_hass_api: llm.API | None = None
if isinstance(task, GenDataTask):
user_llm_hass_api = task.llm_api
# pylint: disable-next=contextmanager-generator-missing-cleanup # pylint: disable-next=contextmanager-generator-missing-cleanup
with ( with (
async_get_chat_log( async_get_chat_log(
@@ -81,7 +77,6 @@ class AITaskEntity(RestoreEntity):
device_id=None, device_id=None,
), ),
user_llm_prompt=DEFAULT_SYSTEM_PROMPT, user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
user_llm_hass_api=user_llm_hass_api,
) )
chat_log.async_add_user_content( chat_log.async_add_user_content(
@@ -109,23 +104,3 @@ class AITaskEntity(RestoreEntity):
) -> GenDataTaskResult: ) -> GenDataTaskResult:
"""Handle a gen data task.""" """Handle a gen data task."""
raise NotImplementedError raise NotImplementedError
@final
async def internal_async_generate_image(
self,
session: ChatSession,
task: GenImageTask,
) -> GenImageTaskResult:
"""Run a gen image task."""
self.__last_activity = dt_util.utcnow().isoformat()
self.async_write_ha_state()
async with self._async_get_ai_task_chat_log(session, task) as chat_log:
return await self._async_generate_image(task, chat_log)
async def _async_generate_image(
self,
task: GenImageTask,
chat_log: ChatLog,
) -> GenImageTaskResult:
"""Handle a gen image task."""
raise NotImplementedError

View File

@@ -37,7 +37,6 @@ def websocket_get_preferences(
{ {
vol.Required("type"): "ai_task/preferences/set", vol.Required("type"): "ai_task/preferences/set",
vol.Optional("gen_data_entity_id"): vol.Any(str, None), vol.Optional("gen_data_entity_id"): vol.Any(str, None),
vol.Optional("gen_image_entity_id"): vol.Any(str, None),
} }
) )
@websocket_api.require_admin @websocket_api.require_admin

View File

@@ -1,15 +1,7 @@
{ {
"entity_component": {
"_": {
"default": "mdi:star-four-points"
}
},
"services": { "services": {
"generate_data": { "generate_data": {
"service": "mdi:file-star-four-points-outline" "service": "mdi:file-star-four-points-outline"
},
"generate_image": {
"service": "mdi:star-four-points-box-outline"
} }
} }
} }

View File

@@ -5,6 +5,6 @@
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"dependencies": ["conversation", "media_source"], "dependencies": ["conversation", "media_source"],
"documentation": "https://www.home-assistant.io/integrations/ai_task", "documentation": "https://www.home-assistant.io/integrations/ai_task",
"integration_type": "entity", "integration_type": "system",
"quality_scale": "internal" "quality_scale": "internal"
} }

View File

@@ -1,32 +0,0 @@
"""Expose images as media sources."""
from __future__ import annotations
from pathlib import Path
from homeassistant.components.media_source import MediaSource, local_source
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up local media source."""
media_dirs = list(hass.config.media_dirs.values())
if not media_dirs:
raise HomeAssistantError(
"AI Task media source requires at least one media directory configured"
)
media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
hass,
DOMAIN,
"AI Generated Images",
{IMAGE_DIR: str(media_dir)},
f"/{DOMAIN}",
)
return source

View File

@@ -20,6 +20,7 @@ generate_data:
supported_features: supported_features:
- ai_task.AITaskEntityFeature.GENERATE_DATA - ai_task.AITaskEntityFeature.GENERATE_DATA
structure: structure:
advanced: true
required: false required: false
example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }' example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }'
selector: selector:
@@ -30,30 +31,3 @@ generate_data:
media: media:
accept: accept:
- "*" - "*"
generate_image:
fields:
task_name:
example: "picture of a dog"
required: true
selector:
text:
instructions:
example: "Generate a high quality square image of a dog on transparent background"
required: true
selector:
text:
multiline: true
entity_id:
required: true
selector:
entity:
filter:
domain: ai_task
supported_features:
- ai_task.AITaskEntityFeature.GENERATE_IMAGE
attachments:
required: false
selector:
media:
accept:
- "*"

View File

@@ -25,28 +25,6 @@
"description": "List of files to attach for multi-modal AI analysis." "description": "List of files to attach for multi-modal AI analysis."
} }
} }
},
"generate_image": {
"name": "Generate image",
"description": "Uses AI to generate image.",
"fields": {
"task_name": {
"name": "Task name",
"description": "Name of the task."
},
"instructions": {
"name": "Instructions",
"description": "Instructions that explains the image to be generated."
},
"entity_id": {
"name": "Entity ID",
"description": "Entity ID to run the task on."
},
"attachments": {
"name": "Attachments",
"description": "List of files to attach for using as references."
}
}
} }
} }
} }

View File

@@ -3,8 +3,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta
import io
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
import tempfile import tempfile
@@ -12,106 +10,25 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components import camera, conversation, image, media_source from homeassistant.components import camera, conversation, media_source
from homeassistant.components.http.auth import async_sign_path from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import llm from homeassistant.helpers.chat_session import async_get_chat_session
from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session
from homeassistant.util import RE_SANITIZE_FILENAME, slugify
from .const import ( from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
DATA_COMPONENT,
DATA_MEDIA_SOURCE,
DATA_PREFERENCES,
DOMAIN,
IMAGE_DIR,
IMAGE_EXPIRY_TIME,
AITaskEntityFeature,
)
def _save_camera_snapshot(image_data: camera.Image | image.Image) -> Path: def _save_camera_snapshot(image: camera.Image) -> Path:
"""Save camera snapshot to temp file.""" """Save camera snapshot to temp file."""
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode="wb", mode="wb",
suffix=mimetypes.guess_extension(image_data.content_type, False), suffix=mimetypes.guess_extension(image.content_type, False),
delete=False, delete=False,
) as temp_file: ) as temp_file:
temp_file.write(image_data.content) temp_file.write(image.content)
return Path(temp_file.name) return Path(temp_file.name)
async def _resolve_attachments(
hass: HomeAssistant,
session: ChatSession,
attachments: list[dict] | None = None,
) -> list[conversation.Attachment]:
"""Resolve attachments for a task."""
resolved_attachments: list[conversation.Attachment] = []
created_files: list[Path] = []
for attachment in attachments or []:
media_content_id = attachment["media_content_id"]
# Special case for certain media sources
for integration in camera, image:
media_source_prefix = f"media-source://{integration.DOMAIN}/"
if not media_content_id.startswith(media_source_prefix):
continue
# Extract entity_id from the media content ID
entity_id = media_content_id.removeprefix(media_source_prefix)
# Get snapshot from entity
image_data = await integration.async_get_image(hass, entity_id)
temp_filename = await hass.async_add_executor_job(
_save_camera_snapshot, image_data
)
created_files.append(temp_filename)
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=image_data.content_type,
path=temp_filename,
)
)
break
else:
# Handle regular media sources
media = await media_source.async_resolve_media(hass, media_content_id, None)
if media.path is None:
raise HomeAssistantError(
"Only local attachments are currently supported"
)
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=media.mime_type,
path=media.path,
)
)
if not created_files:
return resolved_attachments
def cleanup_files() -> None:
"""Cleanup temporary files."""
for file in created_files:
file.unlink(missing_ok=True)
@callback
def cleanup_files_callback() -> None:
"""Cleanup temporary files."""
hass.async_add_executor_job(cleanup_files)
session.async_on_cleanup(cleanup_files_callback)
return resolved_attachments
async def async_generate_data( async def async_generate_data(
hass: HomeAssistant, hass: HomeAssistant,
*, *,
@@ -120,9 +37,8 @@ async def async_generate_data(
instructions: str, instructions: str,
structure: vol.Schema | None = None, structure: vol.Schema | None = None,
attachments: list[dict] | None = None, attachments: list[dict] | None = None,
llm_api: llm.API | None = None,
) -> GenDataTaskResult: ) -> GenDataTaskResult:
"""Run a data generation task in the AI Task integration.""" """Run a task in the AI Task integration."""
if entity_id is None: if entity_id is None:
entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id
@@ -138,6 +54,10 @@ async def async_generate_data(
f"AI Task entity {entity_id} does not support generating data" f"AI Task entity {entity_id} does not support generating data"
) )
# Resolve attachments
resolved_attachments: list[conversation.Attachment] = []
created_files: list[Path] = []
if ( if (
attachments attachments
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
@@ -146,8 +66,58 @@ async def async_generate_data(
f"AI Task entity {entity_id} does not support attachments" f"AI Task entity {entity_id} does not support attachments"
) )
for attachment in attachments or []:
media_content_id = attachment["media_content_id"]
# Special case for camera media sources
if media_content_id.startswith("media-source://camera/"):
# Extract entity_id from the media content ID
entity_id = media_content_id.removeprefix("media-source://camera/")
# Get snapshot from camera
image = await camera.async_get_image(hass, entity_id)
temp_filename = await hass.async_add_executor_job(
_save_camera_snapshot, image
)
created_files.append(temp_filename)
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=image.content_type,
path=temp_filename,
)
)
else:
# Handle regular media sources
media = await media_source.async_resolve_media(hass, media_content_id, None)
if media.path is None:
raise HomeAssistantError(
"Only local attachments are currently supported"
)
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=media.mime_type,
path=media.path,
)
)
with async_get_chat_session(hass) as session: with async_get_chat_session(hass) as session:
resolved_attachments = await _resolve_attachments(hass, session, attachments) if created_files:
def cleanup_files() -> None:
"""Cleanup temporary files."""
for file in created_files:
file.unlink(missing_ok=True)
@callback
def cleanup_files_callback() -> None:
"""Cleanup temporary files."""
hass.async_add_executor_job(cleanup_files)
session.async_on_cleanup(cleanup_files_callback)
return await entity.internal_async_generate_data( return await entity.internal_async_generate_data(
session, session,
@@ -156,92 +126,10 @@ async def async_generate_data(
instructions=instructions, instructions=instructions,
structure=structure, structure=structure,
attachments=resolved_attachments or None, attachments=resolved_attachments or None,
llm_api=llm_api,
), ),
) )
async def async_generate_image(
hass: HomeAssistant,
*,
task_name: str,
entity_id: str | None = None,
instructions: str,
attachments: list[dict] | None = None,
) -> ServiceResponse:
"""Run an image generation task in the AI Task integration."""
if entity_id is None:
entity_id = hass.data[DATA_PREFERENCES].gen_image_entity_id
if entity_id is None:
raise HomeAssistantError("No entity_id provided and no preferred entity set")
entity = hass.data[DATA_COMPONENT].get_entity(entity_id)
if entity is None:
raise HomeAssistantError(f"AI Task entity {entity_id} not found")
if AITaskEntityFeature.GENERATE_IMAGE not in entity.supported_features:
raise HomeAssistantError(
f"AI Task entity {entity_id} does not support generating images"
)
if (
attachments
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
):
raise HomeAssistantError(
f"AI Task entity {entity_id} does not support attachments"
)
with async_get_chat_session(hass) as session:
resolved_attachments = await _resolve_attachments(hass, session, attachments)
task_result = await entity.internal_async_generate_image(
session,
GenImageTask(
name=task_name,
instructions=instructions,
attachments=resolved_attachments or None,
),
)
service_result = task_result.as_dict()
image_data = service_result.pop("image_data")
if service_result.get("revised_prompt") is None:
service_result["revised_prompt"] = instructions
source = hass.data[DATA_MEDIA_SOURCE]
current_time = datetime.now()
ext = mimetypes.guess_extension(task_result.mime_type, False) or ".png"
sanitized_task_name = RE_SANITIZE_FILENAME.sub("", slugify(task_name))
image_file = ImageData(
filename=f"{current_time.strftime('%Y-%m-%d_%H%M%S')}_{sanitized_task_name}{ext}",
file=io.BytesIO(image_data),
content_type=task_result.mime_type,
)
target_folder = media_source.MediaSourceItem.from_uri(
hass, f"media-source://{DOMAIN}/{IMAGE_DIR}", None
)
service_result["media_source_id"] = await source.async_upload_media(
target_folder, image_file
)
item = media_source.MediaSourceItem.from_uri(
hass, service_result["media_source_id"], None
)
service_result["url"] = async_sign_path(
hass,
(await source.async_resolve_media(item)).url,
timedelta(seconds=IMAGE_EXPIRY_TIME),
)
return service_result
@dataclass(slots=True) @dataclass(slots=True)
class GenDataTask: class GenDataTask:
"""Gen data task to be processed.""" """Gen data task to be processed."""
@@ -258,9 +146,6 @@ class GenDataTask:
attachments: list[conversation.Attachment] | None = None attachments: list[conversation.Attachment] | None = None
"""List of attachments to go along the instructions.""" """List of attachments to go along the instructions."""
llm_api: llm.API | None = None
"""API to provide to the LLM."""
def __str__(self) -> str: def __str__(self) -> str:
"""Return task as a string.""" """Return task as a string."""
return f"<GenDataTask {self.name}: {id(self)}>" return f"<GenDataTask {self.name}: {id(self)}>"
@@ -282,68 +167,3 @@ class GenDataTaskResult:
"conversation_id": self.conversation_id, "conversation_id": self.conversation_id,
"data": self.data, "data": self.data,
} }
@dataclass(slots=True)
class GenImageTask:
"""Gen image task to be processed."""
name: str
"""Name of the task."""
instructions: str
"""Instructions on what needs to be done."""
attachments: list[conversation.Attachment] | None = None
"""List of attachments to go along the instructions."""
def __str__(self) -> str:
"""Return task as a string."""
return f"<GenImageTask {self.name}: {id(self)}>"
@dataclass(slots=True)
class GenImageTaskResult:
"""Result of gen image task."""
image_data: bytes
"""Raw image data generated by the model."""
conversation_id: str
"""Unique identifier for the conversation."""
mime_type: str
"""MIME type of the generated image."""
width: int | None = None
"""Width of the generated image, if available."""
height: int | None = None
"""Height of the generated image, if available."""
model: str | None = None
"""Model used to generate the image, if available."""
revised_prompt: str | None = None
"""Revised prompt used to generate the image, if applicable."""
def as_dict(self) -> dict[str, Any]:
"""Return result as a dict."""
return {
"image_data": self.image_data,
"conversation_id": self.conversation_id,
"mime_type": self.mime_type,
"width": self.width,
"height": self.height,
"model": self.model,
"revised_prompt": self.revised_prompt,
}
@dataclass(slots=True)
class ImageData:
"""Implementation of media_source.local_source.UploadedFile protocol."""
filename: str
file: io.IOBase
content_type: str

View File

@@ -61,7 +61,7 @@
"display_pm_standard": { "display_pm_standard": {
"name": "Display PM standard", "name": "Display PM standard",
"state": { "state": {
"ugm3": "μg/m³", "ugm3": "µg/m³",
"us_aqi": "US AQI" "us_aqi": "US AQI"
} }
}, },

View File

@@ -1,9 +1,7 @@
"""Airgradient Update platform.""" """Airgradient Update platform."""
from datetime import timedelta from datetime import timedelta
import logging
from airgradient import AirGradientConnectionError
from propcache.api import cached_property from propcache.api import cached_property
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
@@ -15,7 +13,6 @@ from .entity import AirGradientEntity
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=1) SCAN_INTERVAL = timedelta(hours=1)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
@@ -34,7 +31,6 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Representation of Airgradient Update.""" """Representation of Airgradient Update."""
_attr_device_class = UpdateDeviceClass.FIRMWARE _attr_device_class = UpdateDeviceClass.FIRMWARE
_server_unreachable_logged = False
def __init__(self, coordinator: AirGradientCoordinator) -> None: def __init__(self, coordinator: AirGradientCoordinator) -> None:
"""Initialize the entity.""" """Initialize the entity."""
@@ -51,27 +47,10 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity):
"""Return the installed version of the entity.""" """Return the installed version of the entity."""
return self.coordinator.data.measures.firmware_version return self.coordinator.data.measures.firmware_version
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._attr_available
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update the entity.""" """Update the entity."""
try: self._attr_latest_version = (
self._attr_latest_version = ( await self.coordinator.client.get_latest_firmware_version(
await self.coordinator.client.get_latest_firmware_version( self.coordinator.serial_number
self.coordinator.serial_number
)
) )
except AirGradientConnectionError: )
self._attr_latest_version = None
self._attr_available = False
if not self._server_unreachable_logged:
_LOGGER.error(
"Unable to connect to AirGradient server to check for updates"
)
self._server_unreachable_logged = True
else:
self._server_unreachable_logged = False
self._attr_available = True

View File

@@ -2,20 +2,12 @@
from __future__ import annotations from __future__ import annotations
from airos.airos8 import AirOS8 from airos.airos8 import AirOS
from homeassistant.const import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
CONF_HOST,
CONF_PASSWORD,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, SECTION_ADVANCED_SETTINGS
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [ _PLATFORMS: list[Platform] = [
@@ -29,16 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
# By default airOS 8 comes with self-signed SSL certificates, # By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate. # with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession( session = async_get_clientsession(hass, verify_ssl=False)
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
)
airos_device = AirOS8( airos_device = AirOS(
host=entry.data[CONF_HOST], host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME], username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD], password=entry.data[CONF_PASSWORD],
session=session, session=session,
use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
) )
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device) coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
@@ -51,30 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
return True return True
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version == 1:
new_data = {**entry.data}
advanced_data = {
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
}
new_data[SECTION_ADVANCED_SETTINGS] = advanced_data
hass.config_entries.async_update_entry(
entry,
data=new_data,
minor_version=2,
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -15,7 +15,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
from .entity import AirOSEntity from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -27,7 +27,7 @@ PARALLEL_UPDATES = 0
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription): class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe an AirOS binary sensor.""" """Describe an AirOS binary sensor."""
value_fn: Callable[[AirOS8Data], bool] value_fn: Callable[[AirOSData], bool]
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = ( BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
@@ -15,24 +14,12 @@ from airos.exceptions import (
) )
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
CONF_HOST,
CONF_PASSWORD,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS from .const import DOMAIN
from .coordinator import AirOS8 from .coordinator import AirOS
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -41,15 +28,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_HOST): str, vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str, vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_SSL, default=DEFAULT_SSL): bool,
vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
}
),
{"collapsed": True},
),
} }
) )
@@ -58,109 +36,47 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS.""" """Handle a config flow for Ubiquiti airOS."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.airos_device: AirOS8
self.errors: dict[str, str] = {}
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the manual input of host and credentials.""" """Handle the initial step."""
self.errors = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
validated_info = await self._validate_and_get_device_info(user_input) # By default airOS 8 comes with self-signed SSL certificates,
if validated_info: # with no option in the web UI to change or upload a custom certificate.
return self.async_create_entry( session = async_get_clientsession(self.hass, verify_ssl=False)
title=validated_info["title"],
data=validated_info["data"],
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
)
async def _validate_and_get_device_info( airos_device = AirOS(
self, config_data: dict[str, Any] host=user_input[CONF_HOST],
) -> dict[str, Any] | None: username=user_input[CONF_USERNAME],
"""Validate user input with the device API.""" password=user_input[CONF_PASSWORD],
# By default airOS 8 comes with self-signed SSL certificates, session=session,
# with no option in the web UI to change or upload a custom certificate. )
session = async_get_clientsession( try:
self.hass, await airos_device.login()
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL], airos_data = await airos_device.status()
)
airos_device = AirOS8( except (
host=config_data[CONF_HOST], AirOSConnectionSetupError,
username=config_data[CONF_USERNAME], AirOSDeviceConnectionError,
password=config_data[CONF_PASSWORD], ):
session=session, errors["base"] = "cannot_connect"
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL], except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
) errors["base"] = "invalid_auth"
try: except AirOSKeyDataMissingError:
await airos_device.login() errors["base"] = "key_data_missing"
airos_data = await airos_device.status() except Exception:
_LOGGER.exception("Unexpected exception")
except ( errors["base"] = "unknown"
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
):
self.errors["base"] = "cannot_connect"
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
self.errors["base"] = "invalid_auth"
except AirOSKeyDataMissingError:
self.errors["base"] = "key_data_missing"
except Exception:
_LOGGER.exception("Unexpected exception during credential validation")
self.errors["base"] = "unknown"
else:
await self.async_set_unique_id(airos_data.derived.mac)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
else: else:
await self.async_set_unique_id(airos_data.derived.mac)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry(
return {"title": airos_data.host.hostname, "data": config_data} title=airos_data.host.hostname, data=user_input
return None
async def async_step_reauth(
self,
user_input: Mapping[str, Any],
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm(user_input)
async def async_step_reauth_confirm(
self,
user_input: Mapping[str, Any],
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
self.errors = {}
if user_input:
validate_data = {**self._get_reauth_entry().data, **user_input}
if await self._validate_and_get_device_info(config_data=validate_data):
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=validate_data,
) )
return self.async_show_form( return self.async_show_form(
step_id="reauth_confirm", step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
),
errors=self.errors,
) )

View File

@@ -7,8 +7,3 @@ DOMAIN = "airos"
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)
MANUFACTURER = "Ubiquiti" MANUFACTURER = "Ubiquiti"
DEFAULT_VERIFY_SSL = False
DEFAULT_SSL = True
SECTION_ADVANCED_SETTINGS = "advanced_settings"

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
from airos.airos8 import AirOS8, AirOS8Data from airos.airos8 import AirOS, AirOSData
from airos.exceptions import ( from airos.exceptions import (
AirOSConnectionAuthenticationError, AirOSConnectionAuthenticationError,
AirOSConnectionSetupError, AirOSConnectionSetupError,
@@ -14,7 +14,7 @@ from airos.exceptions import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, SCAN_INTERVAL from .const import DOMAIN, SCAN_INTERVAL
@@ -24,13 +24,13 @@ _LOGGER = logging.getLogger(__name__)
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]): class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
"""Class to manage fetching AirOS data from single endpoint.""" """Class to manage fetching AirOS data from single endpoint."""
config_entry: AirOSConfigEntry config_entry: AirOSConfigEntry
def __init__( def __init__(
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8 self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
self.airos_device = airos_device self.airos_device = airos_device
@@ -42,14 +42,14 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )
async def _async_update_data(self) -> AirOS8Data: async def _async_update_data(self) -> AirOSData:
"""Fetch data from AirOS.""" """Fetch data from AirOS."""
try: try:
await self.airos_device.login() await self.airos_device.login()
return await self.airos_device.status() return await self.airos_device.status()
except AirOSConnectionAuthenticationError as err: except (AirOSConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device") _LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryAuthFailed( raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth" translation_domain=DOMAIN, translation_key="invalid_auth"
) from err ) from err
except ( except (

View File

@@ -2,11 +2,11 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.const import CONF_HOST, CONF_SSL from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS from .const import DOMAIN, MANUFACTURER
from .coordinator import AirOSDataUpdateCoordinator from .coordinator import AirOSDataUpdateCoordinator
@@ -20,14 +20,9 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
super().__init__(coordinator) super().__init__(coordinator)
airos_data = self.coordinator.data airos_data = self.coordinator.data
url_schema = (
"https"
if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
else "http"
)
configuration_url: str | None = ( configuration_url: str | None = (
f"{url_schema}://{coordinator.config_entry.data[CONF_HOST]}" f"https://{coordinator.config_entry.data[CONF_HOST]}"
) )
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos", "documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["airos==0.5.5"] "requirements": ["airos==0.2.7"]
} }

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole from airos.data import NetRole, WirelessMode
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@@ -19,30 +19,25 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS,
UnitOfDataRate, UnitOfDataRate,
UnitOfFrequency, UnitOfFrequency,
UnitOfLength,
UnitOfTime,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
from .entity import AirOSEntity from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
WIRELESS_MODE_OPTIONS = [mode.value.replace("-", "_").lower() for mode in WirelessMode]
NETROLE_OPTIONS = [mode.value for mode in NetRole] NETROLE_OPTIONS = [mode.value for mode in NetRole]
WIRELESS_MODE_OPTIONS = [mode.value for mode in DerivedWirelessMode]
WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class AirOSSensorEntityDescription(SensorEntityDescription): class AirOSSensorEntityDescription(SensorEntityDescription):
"""Describe an AirOS sensor.""" """Describe an AirOS sensor."""
value_fn: Callable[[AirOS8Data], StateType] value_fn: Callable[[AirOSData], StateType]
SENSORS: tuple[AirOSSensorEntityDescription, ...] = ( SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
@@ -123,41 +118,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.ul_capacity, value_fn=lambda data: data.wireless.polling.ul_capacity,
), ),
AirOSSensorEntityDescription(
key="host_uptime",
translation_key="host_uptime",
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda data: data.host.uptime,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="wireless_distance",
translation_key="wireless_distance",
native_unit_of_measurement=UnitOfLength.METERS,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=1,
suggested_unit_of_measurement=UnitOfLength.KILOMETERS,
value_fn=lambda data: data.wireless.distance,
),
AirOSSensorEntityDescription(
key="wireless_mode",
translation_key="wireless_mode",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.derived.mode.value,
options=WIRELESS_MODE_OPTIONS,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="wireless_role",
translation_key="wireless_role",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.derived.role.value,
options=WIRELESS_ROLE_OPTIONS,
entity_registry_enabled_default=False,
),
) )

View File

@@ -2,14 +2,6 @@
"config": { "config": {
"flow_title": "Ubiquiti airOS device", "flow_title": "Ubiquiti airOS device",
"step": { "step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
}
},
"user": { "user": {
"data": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
@@ -20,18 +12,6 @@
"host": "IP address or hostname of the airOS device", "host": "IP address or hostname of the airOS device",
"username": "Administrator username for the airOS device, normally 'ubnt'", "username": "Administrator username for the airOS device, normally 'ubnt'",
"password": "Password configured through the UISP app or web interface" "password": "Password configured through the UISP app or web interface"
},
"sections": {
"advanced_settings": {
"data": {
"ssl": "Use HTTPS",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "Whether the connection should be encrypted (required for most devices)",
"verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
}
}
} }
} }
}, },
@@ -42,9 +22,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
} }
}, },
"entity": { "entity": {
@@ -99,26 +77,6 @@
}, },
"wireless_remote_hostname": { "wireless_remote_hostname": {
"name": "Remote hostname" "name": "Remote hostname"
},
"host_uptime": {
"name": "Uptime"
},
"wireless_distance": {
"name": "Wireless distance"
},
"wireless_role": {
"name": "Wireless role",
"state": {
"access_point": "Access point",
"station": "Station"
}
},
"wireless_mode": {
"name": "Wireless mode",
"state": {
"point_to_point": "Point-to-point",
"point_to_multipoint": "Point-to-multipoint"
}
} }
} }
}, },

View File

@@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE
from .coordinator import AirQCoordinator from .coordinator import AirQCoordinator
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]
AirQConfigEntry = ConfigEntry[AirQCoordinator] AirQConfigEntry = ConfigEntry[AirQCoordinator]

View File

@@ -75,7 +75,6 @@ class AirQCoordinator(DataUpdateCoordinator):
return_average=self.return_average, return_average=self.return_average,
clip_negative_values=self.clip_negative, clip_negative_values=self.clip_negative,
) )
data["brightness"] = await self.airq.get_current_brightness()
if warming_up_sensors := identify_warming_up_sensors(data): if warming_up_sensors := identify_warming_up_sensors(data):
_LOGGER.debug( _LOGGER.debug(
"Following sensors are still warming up: %s", warming_up_sensors "Following sensors are still warming up: %s", warming_up_sensors

View File

@@ -1,85 +0,0 @@
"""Definition of air-Q number platform used to control the LED strips."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from aioairq.core import AirQ
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirQConfigEntry, AirQCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class AirQBrightnessDescription(NumberEntityDescription):
"""Describes AirQ number entity responsible for brightness control."""
value: Callable[[dict], float]
set_value: Callable[[AirQ, float], Awaitable[None]]
AIRQ_LED_BRIGHTNESS = AirQBrightnessDescription(
key="airq_led_brightness",
translation_key="airq_led_brightness",
native_min_value=0.0,
native_max_value=100.0,
native_step=1.0,
native_unit_of_measurement=PERCENTAGE,
value=lambda data: data["brightness"],
set_value=lambda device, value: device.set_current_brightness(value),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirQConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities: a single entity for the LEDs."""
coordinator = entry.runtime_data
entities = [AirQLEDBrightness(coordinator, AIRQ_LED_BRIGHTNESS)]
async_add_entities(entities)
class AirQLEDBrightness(CoordinatorEntity[AirQCoordinator], NumberEntity):
"""Representation of the LEDs from a single AirQ."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirQCoordinator,
description: AirQBrightnessDescription,
) -> None:
"""Initialize a single sensor."""
super().__init__(coordinator)
self.entity_description: AirQBrightnessDescription = description
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
@property
def native_value(self) -> float:
"""Return the brightness of the LEDs in %."""
return self.entity_description.value(self.coordinator.data)
async def async_set_native_value(self, value: float) -> None:
"""Set the brightness of the LEDs to the value in %."""
_LOGGER.debug(
"Changing LED brighntess from %.0f%% to %.0f%%",
self.coordinator.data["brightness"],
value,
)
await self.entity_description.set_value(self.coordinator.airq, value)
await self.coordinator.async_request_refresh()

View File

@@ -35,11 +35,6 @@
} }
}, },
"entity": { "entity": {
"number": {
"airq_led_brightness": {
"name": "LED brightness"
}
},
"sensor": { "sensor": {
"acetaldehyde": { "acetaldehyde": {
"name": "Acetaldehyde" "name": "Acetaldehyde"

View File

@@ -23,10 +23,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
} }
) )
URL_API_INTEGRATION = {
"url": "https://dashboard.airthings.com/integrations/api-integration"
}
class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Airthings.""" """Handle a config flow for Airthings."""
@@ -41,7 +37,11 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=STEP_USER_DATA_SCHEMA, data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders=URL_API_INTEGRATION, description_placeholders={
"url": (
"https://dashboard.airthings.com/integrations/api-integration"
),
},
) )
errors = {} errors = {}
@@ -65,8 +65,5 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Airthings", data=user_input) return self.async_create_entry(title="Airthings", data=user_input)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders=URL_API_INTEGRATION,
) )

View File

@@ -4,9 +4,9 @@
"user": { "user": {
"data": { "data": {
"id": "ID", "id": "ID",
"secret": "Secret" "secret": "Secret",
}, "description": "Login at {url} to find your credentials"
"description": "Log in at {url} to find your credentials" }
} }
}, },
"error": { "error": {

View File

@@ -6,13 +6,8 @@ import dataclasses
import logging import logging
from typing import Any from typing import Any
from airthings_ble import ( from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
AirthingsBluetoothDeviceData,
AirthingsDevice,
UnsupportedDeviceError,
)
from bleak import BleakError from bleak import BleakError
from habluetooth import BluetoothServiceInfoBleak
import voluptuous as vol import voluptuous as vol
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
@@ -32,7 +27,6 @@ SERVICE_UUIDS = [
"b42e4a8e-ade7-11e4-89d3-123b93f75cba", "b42e4a8e-ade7-11e4-89d3-123b93f75cba",
"b42e1c08-ade7-11e4-89d3-123b93f75cba", "b42e1c08-ade7-11e4-89d3-123b93f75cba",
"b42e3882-ade7-11e4-89d3-123b93f75cba", "b42e3882-ade7-11e4-89d3-123b93f75cba",
"b42e90a2-ade7-11e4-89d3-123b93f75cba",
] ]
@@ -43,7 +37,6 @@ class Discovery:
name: str name: str
discovery_info: BluetoothServiceInfo discovery_info: BluetoothServiceInfo
device: AirthingsDevice device: AirthingsDevice
data: AirthingsBluetoothDeviceData
def get_name(device: AirthingsDevice) -> str: def get_name(device: AirthingsDevice) -> str:
@@ -51,7 +44,7 @@ def get_name(device: AirthingsDevice) -> str:
name = device.friendly_name() name = device.friendly_name()
if identifier := device.identifier: if identifier := device.identifier:
name += f" ({device.model.value}{identifier})" name += f" ({identifier})"
return name return name
@@ -69,8 +62,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_device: Discovery | None = None self._discovered_device: Discovery | None = None
self._discovered_devices: dict[str, Discovery] = {} self._discovered_devices: dict[str, Discovery] = {}
async def _get_device( async def _get_device_data(
self, data: AirthingsBluetoothDeviceData, discovery_info: BluetoothServiceInfo self, discovery_info: BluetoothServiceInfo
) -> AirthingsDevice: ) -> AirthingsDevice:
ble_device = bluetooth.async_ble_device_from_address( ble_device = bluetooth.async_ble_device_from_address(
self.hass, discovery_info.address self.hass, discovery_info.address
@@ -79,8 +72,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("no ble_device in _get_device_data") _LOGGER.debug("no ble_device in _get_device_data")
raise AirthingsDeviceUpdateError("No ble_device") raise AirthingsDeviceUpdateError("No ble_device")
airthings = AirthingsBluetoothDeviceData(_LOGGER)
try: try:
device = await data.update_device(ble_device) data = await airthings.update_device(ble_device)
except BleakError as err: except BleakError as err:
_LOGGER.error( _LOGGER.error(
"Error connecting to and getting data from %s: %s", "Error connecting to and getting data from %s: %s",
@@ -88,15 +83,12 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
err, err,
) )
raise AirthingsDeviceUpdateError("Failed getting device data") from err raise AirthingsDeviceUpdateError("Failed getting device data") from err
except UnsupportedDeviceError:
_LOGGER.debug("Skipping unsupported device: %s", discovery_info.name)
raise
except Exception as err: except Exception as err:
_LOGGER.error( _LOGGER.error(
"Unknown error occurred from %s: %s", discovery_info.address, err "Unknown error occurred from %s: %s", discovery_info.address, err
) )
raise raise
return device return data
async def async_step_bluetooth( async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo self, discovery_info: BluetoothServiceInfo
@@ -106,21 +98,17 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.address) await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
try: try:
device = await self._get_device(data=data, discovery_info=discovery_info) device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError: except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
except UnsupportedDeviceError:
return self.async_abort(reason="unsupported_device")
except Exception: except Exception:
_LOGGER.exception("Unknown error occurred") _LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
name = get_name(device) name = get_name(device)
self.context["title_placeholders"] = {"name": name} self.context["title_placeholders"] = {"name": name}
self._discovered_device = Discovery(name, discovery_info, device, data=data) self._discovered_device = Discovery(name, discovery_info, device)
return await self.async_step_bluetooth_confirm() return await self.async_step_bluetooth_confirm()
@@ -129,12 +117,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Confirm discovery.""" """Confirm discovery."""
if user_input is not None: if user_input is not None:
if (
self._discovered_device is not None
and self._discovered_device.device.firmware.need_firmware_upgrade
):
return self.async_abort(reason="firmware_upgrade_required")
return self.async_create_entry( return self.async_create_entry(
title=self.context["title_placeholders"]["name"], data={} title=self.context["title_placeholders"]["name"], data={}
) )
@@ -155,9 +137,6 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
discovery = self._discovered_devices[address] discovery = self._discovered_devices[address]
if discovery.device.firmware.need_firmware_upgrade:
return self.async_abort(reason="firmware_upgrade_required")
self.context["title_placeholders"] = { self.context["title_placeholders"] = {
"name": discovery.name, "name": discovery.name,
} }
@@ -167,53 +146,32 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={}) return self.async_create_entry(title=discovery.name, data={})
current_addresses = self._async_current_ids(include_ignore=False) current_addresses = self._async_current_ids(include_ignore=False)
devices: list[BluetoothServiceInfoBleak] = []
for discovery_info in async_discovered_service_info(self.hass): for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:
continue continue
if MFCT_ID not in discovery_info.manufacturer_data: if MFCT_ID not in discovery_info.manufacturer_data:
continue continue
if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
_LOGGER.debug(
"Skipping unsupported device: %s (%s)", discovery_info.name, address
)
continue
devices.append(discovery_info)
for discovery_info in devices: if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
address = discovery_info.address continue
data = AirthingsBluetoothDeviceData(logger=_LOGGER)
try: try:
device = await self._get_device(data, discovery_info) device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError: except AirthingsDeviceUpdateError:
_LOGGER.error( return self.async_abort(reason="cannot_connect")
"Error connecting to and getting data from %s (%s)",
discovery_info.name,
discovery_info.address,
)
continue
except UnsupportedDeviceError:
_LOGGER.debug(
"Skipping unsupported device: %s (%s)",
discovery_info.name,
discovery_info.address,
)
continue
except Exception: except Exception:
_LOGGER.exception("Unknown error occurred") _LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
name = get_name(device) name = get_name(device)
_LOGGER.debug("Discovered Airthings device: %s (%s)", name, address) self._discovered_devices[address] = Discovery(name, discovery_info, device)
self._discovered_devices[address] = Discovery(
name, discovery_info, device, data
)
if not self._discovered_devices: if not self._discovered_devices:
return self.async_abort(reason="no_devices_found") return self.async_abort(reason="no_devices_found")
titles = { titles = {
address: get_name(discovery.device) address: discovery.device.name
for (address, discovery) in self._discovered_devices.items() for (address, discovery) in self._discovered_devices.items()
} }
return self.async_show_form( return self.async_show_form(

View File

@@ -17,10 +17,6 @@
{ {
"manufacturer_id": 820, "manufacturer_id": 820,
"service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba"
},
{
"manufacturer_id": 820,
"service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba"
} }
], ],
"codeowners": ["@vincegio", "@LaStrada"], "codeowners": ["@vincegio", "@LaStrada"],
@@ -28,5 +24,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble", "documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airthings-ble==1.1.1"] "requirements": ["airthings-ble==0.9.2"]
} }

View File

@@ -114,8 +114,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
), ),
} }
PARALLEL_UPDATES = 0
@callback @callback
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:

View File

@@ -6,9 +6,6 @@
"description": "[%key:component::bluetooth::config::step::user::description%]", "description": "[%key:component::bluetooth::config::step::user::description%]",
"data": { "data": {
"address": "[%key:common::config_flow::data::device%]" "address": "[%key:common::config_flow::data::device%]"
},
"data_description": {
"address": "The Airthings devices discovered via Bluetooth."
} }
}, },
"bluetooth_confirm": { "bluetooth_confirm": {
@@ -20,8 +17,6 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"firmware_upgrade_required": "Your device requires a firmware upgrade. Please use the Airthings app (Android/iOS) to upgrade it.",
"unsupported_device": "Unsupported device",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },

View File

@@ -2,14 +2,17 @@
from airtouch4pyapi import AirTouch from airtouch4pyapi import AirTouch
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator from .coordinator import AirtouchDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE] PLATFORMS = [Platform.CLIMATE]
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
"""Set up AirTouch4 from a config entry.""" """Set up AirTouch4 from a config entry."""
@@ -19,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
info = airtouch.GetAcs() info = airtouch.GetAcs()
if not info: if not info:
raise ConfigEntryNotReady raise ConfigEntryNotReady
coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch) coordinator = AirtouchDataUpdateCoordinator(hass, airtouch)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator entry.runtime_data = coordinator

View File

@@ -2,34 +2,26 @@
import logging import logging
from airtouch4pyapi import AirTouch
from airtouch4pyapi.airtouch import AirTouchStatus from airtouch4pyapi.airtouch import AirTouchStatus
from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.components.climate import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Airtouch data.""" """Class to manage fetching Airtouch data."""
def __init__( def __init__(self, hass, airtouch):
self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch
) -> None:
"""Initialize global Airtouch data updater.""" """Initialize global Airtouch data updater."""
self.airtouch = airtouch self.airtouch = airtouch
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone", "documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairzone"], "loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.1"] "requirements": ["aioairzone==1.0.0"]
} }

View File

@@ -6,19 +6,17 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Final from typing import Any, Final
from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout
from aioairzone.const import ( from aioairzone.const import (
API_COLD_ANGLE, API_COLD_ANGLE,
API_HEAT_ANGLE, API_HEAT_ANGLE,
API_MODE, API_MODE,
API_Q_ADAPT,
API_SLEEP, API_SLEEP,
AZD_COLD_ANGLE, AZD_COLD_ANGLE,
AZD_HEAT_ANGLE, AZD_HEAT_ANGLE,
AZD_MASTER, AZD_MASTER,
AZD_MODE, AZD_MODE,
AZD_MODES, AZD_MODES,
AZD_Q_ADAPT,
AZD_SLEEP, AZD_SLEEP,
AZD_ZONES, AZD_ZONES,
) )
@@ -67,14 +65,6 @@ SLEEP_DICT: Final[dict[str, int]] = {
"90m": SleepTimeout.SLEEP_90, "90m": SleepTimeout.SLEEP_90,
} }
Q_ADAPT_DICT: Final[dict[str, int]] = {
"standard": QAdapt.STANDARD,
"power": QAdapt.POWER,
"silence": QAdapt.SILENCE,
"minimum": QAdapt.MINIMUM,
"maximum": QAdapt.MAXIMUM,
}
def main_zone_options( def main_zone_options(
zone_data: dict[str, Any], zone_data: dict[str, Any],
@@ -93,14 +83,6 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
options_fn=main_zone_options, options_fn=main_zone_options,
translation_key="modes", translation_key="modes",
), ),
AirzoneSelectDescription(
api_param=API_Q_ADAPT,
entity_category=EntityCategory.CONFIG,
key=AZD_Q_ADAPT,
options=list(Q_ADAPT_DICT),
options_dict=Q_ADAPT_DICT,
translation_key="q_adapt",
),
) )

View File

@@ -63,16 +63,6 @@
"stop": "Stop" "stop": "Stop"
} }
}, },
"q_adapt": {
"name": "Q-Adapt",
"state": {
"standard": "Standard",
"power": "Power",
"silence": "Silence",
"minimum": "Minimum",
"maximum": "Maximum"
}
},
"sleep_times": { "sleep_times": {
"name": "Sleep", "name": "Sleep",
"state": { "state": {

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"], "loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.7.2"] "requirements": ["aioairzone-cloud==0.7.1"]
} }

View File

@@ -2,96 +2,39 @@
from __future__ import annotations from __future__ import annotations
from genie_partner_sdk.client import AladdinConnectClient from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import ( from homeassistant.helpers import issue_registry as ir
aiohttp_client,
config_entry_oauth2_flow,
device_registry as dr,
)
from . import api DOMAIN = "aladdin_connect"
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR]
async def async_setup_entry( async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
hass: HomeAssistant, entry: AladdinConnectConfigEntry """Set up Aladdin Connect from a config entry."""
) -> bool: ir.async_create_issue(
"""Set up Aladdin Connect Genie from a config entry.""" hass,
implementation = ( DOMAIN,
await config_entry_oauth2_flow.async_get_config_entry_implementation( DOMAIN,
hass, entry is_fixable=False,
) severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"entries": "/config/integrations/integration/aladdin_connect",
},
) )
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
client = AladdinConnectClient(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
doors = await client.get_doors()
entry.runtime_data = {
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
for door in doors
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
remove_stale_devices(hass, entry)
return True return True
async def async_unload_entry( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass: HomeAssistant, entry: AladdinConnectConfigEntry
) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: AladdinConnectConfigEntry
) -> bool:
"""Migrate old config."""
if config_entry.version < CONFIG_FLOW_VERSION:
config_entry.async_start_reauth(hass)
new_data = {**config_entry.data}
hass.config_entries.async_update_entry(
config_entry,
data=new_data,
version=CONFIG_FLOW_VERSION,
minor_version=CONFIG_FLOW_MINOR_VERSION,
)
return True return True
def remove_stale_devices( async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
hass: HomeAssistant, """Remove a config entry."""
config_entry: AladdinConnectConfigEntry, if not hass.config_entries.async_loaded_entries(DOMAIN):
) -> None: ir.async_delete_issue(hass, DOMAIN, DOMAIN)
"""Remove stale devices from device registry.""" # Remove any remaining disabled or ignored entries
device_registry = dr.async_get(hass) for _entry in hass.config_entries.async_entries(DOMAIN):
device_entries = dr.async_entries_for_config_entry( hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
device_registry, config_entry.entry_id
)
all_device_ids = set(config_entry.runtime_data)
for device_entry in device_entries:
device_id: str | None = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
device_id = identifier[1]
break
if device_id and device_id not in all_device_ids:
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)

View File

@@ -1,33 +0,0 @@
"""API for Aladdin Connect Genie bound to Home Assistant OAuth."""
from typing import cast
from aiohttp import ClientSession
from genie_partner_sdk.auth import Auth
from homeassistant.helpers import config_entry_oauth2_flow
API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
class AsyncConfigEntryAuth(Auth):
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Aladdin Connect Genie auth."""
super().__init__(
websession, API_URL, oauth_session.token["access_token"], API_KEY
)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])

View File

@@ -1,14 +0,0 @@
"""application_credentials platform the Aladdin Connect Genie integration."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)

View File

@@ -1,74 +1,11 @@
"""Config flow for Aladdin Connect Genie.""" """Config flow for Aladdin Connect integration."""
from collections.abc import Mapping from homeassistant.config_entries import ConfigFlow
import logging
from typing import Any
import jwt from . import DOMAIN
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
class OAuth2FlowHandler( class AladdinConnectConfigFlow(ConfigFlow, domain=DOMAIN):
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN """Handle a config flow for Aladdin Connect."""
):
"""Config flow to handle Aladdin Connect Genie OAuth2 authentication."""
DOMAIN = DOMAIN VERSION = 1
VERSION = CONFIG_FLOW_VERSION
MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Check we have the cloud integration set up."""
if "cloud" not in self.hass.config.components:
return self.async_abort(
reason="cloud_not_enabled",
description_placeholders={"default_config": "default_config"},
)
return await super().async_step_user(user_input)
async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon API auth error or upgrade from v1 to v2."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({}),
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
# Extract the user ID from the JWT token's 'sub' field
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
user_id = token["sub"]
await self.async_set_unique_id(user_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
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="Aladdin Connect", data=data)
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)

View File

@@ -1,14 +0,0 @@
"""Constants for the Aladdin Connect Genie integration."""
from typing import Final
from homeassistant.components.cover import CoverEntityFeature
DOMAIN = "aladdin_connect"
CONFIG_FLOW_VERSION = 2
CONFIG_FLOW_MINOR_VERSION = 1
OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html"
OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token"
SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE

View File

@@ -1,50 +0,0 @@
"""Coordinator for Aladdin Connect integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
SCAN_INTERVAL = timedelta(seconds=15)
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
"""Coordinator for Aladdin Connect integration."""
def __init__(
self,
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
client: AladdinConnectClient,
garage_door: GarageDoor,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
config_entry=entry,
name="Aladdin Connect Coordinator",
update_interval=SCAN_INTERVAL,
)
self.client = client
self.data = garage_door
async def _async_update_data(self) -> GarageDoor:
"""Fetch data from the Aladdin Connect API."""
await self.client.update_door(self.data.device_id, self.data.door_number)
self.data.status = self.client.get_door_status(
self.data.device_id, self.data.door_number
)
self.data.battery_level = self.client.get_battery_status(
self.data.device_id, self.data.door_number
)
return self.data

View File

@@ -1,64 +0,0 @@
"""Cover Entity for Genie Garage Door."""
from __future__ import annotations
from typing import Any
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SUPPORTED_FEATURES
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the cover platform."""
coordinators = entry.runtime_data
async_add_entities(
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
)
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
"""Representation of Aladdin Connect cover."""
_attr_device_class = CoverDeviceClass.GARAGE
_attr_supported_features = SUPPORTED_FEATURES
_attr_name = None
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
"""Initialize the Aladdin Connect cover."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.data.unique_id
async def async_open_cover(self, **kwargs: Any) -> None:
"""Issue open command to cover."""
await self.client.open_door(self._device_id, self._number)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Issue close command to cover."""
await self.client.close_door(self._device_id, self._number)
@property
def is_closed(self) -> bool | None:
"""Update is closed attribute."""
if (status := self.coordinator.data.status) is None:
return None
return status == "closed"
@property
def is_closing(self) -> bool | None:
"""Update is closing attribute."""
return self.coordinator.data.status == "closing"
@property
def is_opening(self) -> bool | None:
"""Update is opening attribute."""
return self.coordinator.data.status == "opening"

View File

@@ -1,32 +0,0 @@
"""Base class for Aladdin Connect entities."""
from genie_partner_sdk.client import AladdinConnectClient
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AladdinConnectCoordinator
class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
"""Defines a base Aladdin Connect entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
"""Initialize Aladdin Connect entity."""
super().__init__(coordinator)
device = coordinator.data
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
manufacturer="Aladdin Connect",
name=device.name,
)
self._device_id = device.device_id
self._number = device.door_number
@property
def client(self) -> AladdinConnectClient:
"""Return the client for this entity."""
return self.coordinator.client

View File

@@ -1,16 +1,9 @@
{ {
"domain": "aladdin_connect", "domain": "aladdin_connect",
"name": "Aladdin Connect", "name": "Aladdin Connect",
"codeowners": ["@swcloudgenie"], "codeowners": [],
"config_flow": true,
"dependencies": ["application_credentials"],
"dhcp": [
{
"hostname": "gdocntl-*"
}
],
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
"integration_type": "hub", "integration_type": "system",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["genie-partner-sdk==1.0.11"] "requirements": []
} }

View File

@@ -1,94 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register any service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: todo
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register any service actions.
docs-high-level-description: done
docs-installation-instructions:
status: todo
comment: Documentation needs to be created.
docs-removal-instructions:
status: todo
comment: Documentation needs to be created.
entity-event-setup:
status: exempt
comment: Integration does not subscribe to external events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure:
status: todo
comment: Config flow does not currently test connection during setup.
test-before-setup: todo
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: todo
comment: Documentation needs to be created.
docs-installation-parameters:
status: todo
comment: Documentation needs to be created.
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: done
test-coverage:
status: todo
comment: Platform tests for cover and sensor need to be implemented to reach 95% coverage.
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update:
status: todo
comment: Documentation needs to be created.
docs-examples:
status: todo
comment: Documentation needs to be created.
docs-known-limitations:
status: todo
comment: Documentation needs to be created.
docs-supported-devices:
status: todo
comment: Documentation needs to be created.
docs-supported-functions:
status: todo
comment: Documentation needs to be created.
docs-troubleshooting:
status: todo
comment: Documentation needs to be created.
docs-use-cases:
status: todo
comment: Documentation needs to be created.
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: todo
comment: Stale devices can be done dynamically
# Platinum
async-dependency: todo
inject-websession: done
strict-typing: done

View File

@@ -1,77 +0,0 @@
"""Support for Aladdin Connect Genie sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from genie_partner_sdk.model import GarageDoor
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
from .entity import AladdinConnectEntity
@dataclass(frozen=True, kw_only=True)
class AladdinConnectSensorEntityDescription(SensorEntityDescription):
"""Sensor entity description for Aladdin Connect."""
value_fn: Callable[[GarageDoor], float | None]
SENSOR_TYPES: tuple[AladdinConnectSensorEntityDescription, ...] = (
AladdinConnectSensorEntityDescription(
key="battery_level",
device_class=SensorDeviceClass.BATTERY,
entity_registry_enabled_default=False,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda garage_door: garage_door.battery_level,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AladdinConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aladdin Connect sensor devices."""
coordinators = entry.runtime_data
async_add_entities(
AladdinConnectSensor(coordinator, description)
for coordinator in coordinators.values()
for description in SENSOR_TYPES
)
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
"""A sensor implementation for Aladdin Connect device."""
entity_description: AladdinConnectSensorEntityDescription
def __init__(
self,
coordinator: AladdinConnectCoordinator,
entity_description: AladdinConnectSensorEntityDescription,
) -> None:
"""Initialize the Aladdin Connect sensor."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -1,34 +1,8 @@
{ {
"config": { "issues": {
"step": { "integration_removed": {
"pick_implementation": { "title": "The Aladdin Connect integration has been removed",
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" "description": "The Aladdin Connect integration has been removed from Home Assistant.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Aladdin Connect integration entries]({entries})."
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Aladdin Connect needs to re-authenticate your account"
},
"oauth_discovery": {
"description": "Home Assistant has found an Aladdin Connect device on your network. Press **Submit** to continue setting up Aladdin Connect."
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account.",
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
} }
} }
} }

View File

@@ -61,7 +61,7 @@ ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the alarm control panel component.""" """Track states and offer events for sensors."""
component = hass.data[DATA_COMPONENT] = EntityComponent[AlarmControlPanelEntity]( component = hass.data[DATA_COMPONENT] = EntityComponent[AlarmControlPanelEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL _LOGGER, DOMAIN, hass, SCAN_INTERVAL
) )

View File

@@ -1,7 +1,4 @@
"""Support for repeating alerts when conditions are met. """Support for repeating alerts when conditions are met."""
DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
"""
from __future__ import annotations from __future__ import annotations
@@ -66,10 +63,7 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Alert component. """Set up the Alert component."""
DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
"""
component = EntityComponent[AlertEntity](LOGGER, DOMAIN, hass) component = EntityComponent[AlertEntity](LOGGER, DOMAIN, hass)
entities: list[AlertEntity] = [] entities: list[AlertEntity] = []

View File

@@ -1,7 +1,4 @@
"""Support for repeating alerts when conditions are met. """Support for repeating alerts when conditions are met."""
DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
"""
from __future__ import annotations from __future__ import annotations
@@ -30,10 +27,7 @@ from .const import DOMAIN, LOGGER
class AlertEntity(Entity): class AlertEntity(Entity):
"""Representation of an alert. """Representation of an alert."""
DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
"""
_attr_should_poll = False _attr_should_poll = False

View File

@@ -1,7 +1,4 @@
"""Reproduce an Alert state. """Reproduce an Alert state."""
DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN.
"""
from __future__ import annotations from __future__ import annotations

View File

@@ -1,11 +1,11 @@
"""Alexa Devices integration.""" """Alexa Devices integration."""
from homeassistant.const import CONF_COUNTRY, Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN from .const import DOMAIN
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .services import async_setup_services from .services import async_setup_services
@@ -40,48 +40,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
return True return True
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version == 1 and entry.minor_version < 3:
if CONF_SITE in entry.data:
# Site in data (wrong place), just move to login data
new_data = entry.data.copy()
new_data[CONF_LOGIN_DATA][CONF_SITE] = new_data[CONF_SITE]
new_data.pop(CONF_SITE)
hass.config_entries.async_update_entry(
entry, data=new_data, version=1, minor_version=3
)
return True
if CONF_SITE in entry.data[CONF_LOGIN_DATA]:
# Site is there, just update version to avoid future migrations
hass.config_entries.async_update_entry(entry, version=1, minor_version=3)
return True
_LOGGER.debug(
"Migrating from version %s.%s", entry.version, entry.minor_version
)
# Convert country in domain
country = entry.data[CONF_COUNTRY].lower()
domain = COUNTRY_DOMAINS.get(country, country)
# Add site to login data
new_data = entry.data.copy()
new_data[CONF_LOGIN_DATA][CONF_SITE] = f"https://www.amazon.{domain}"
hass.config_entries.async_update_entry(
entry, data=new_data, version=1, minor_version=3
)
_LOGGER.info(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -10,7 +10,6 @@ from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SENSOR_STATE_OFF from aioamazondevices.const import SENSOR_STATE_OFF
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
@@ -18,12 +17,9 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonConfigEntry from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity from .entity import AmazonEntity
from .utils import async_update_unique_id
# Coordinator is used to centralize the data updates # Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -35,7 +31,6 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
is_on_fn: Callable[[AmazonDevice, str], bool] is_on_fn: Callable[[AmazonDevice, str], bool]
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True
BINARY_SENSORS: Final = ( BINARY_SENSORS: Final = (
@@ -45,52 +40,47 @@ BINARY_SENSORS: Final = (
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda device, _: device.online, is_on_fn=lambda device, _: device.online,
), ),
AmazonBinarySensorEntityDescription(
key="detectionState",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda device, key: bool(
device.sensors[key].value != SENSOR_STATE_OFF
),
is_supported=lambda device, key: device.sensors.get(key) is not None,
is_available_fn=lambda device, key: (
device.online
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
),
),
)
DEPRECATED_BINARY_SENSORS: Final = (
AmazonBinarySensorEntityDescription( AmazonBinarySensorEntityDescription(
key="bluetooth", key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth", translation_key="bluetooth",
is_on_fn=lambda device, key: False, is_on_fn=lambda device, _: device.bluetooth_state,
), ),
AmazonBinarySensorEntityDescription( AmazonBinarySensorEntityDescription(
key="babyCryDetectionState", key="babyCryDetectionState",
translation_key="baby_cry_detection", translation_key="baby_cry_detection",
is_on_fn=lambda device, key: False, is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
), ),
AmazonBinarySensorEntityDescription( AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState", key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection", translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: False, is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
), ),
AmazonBinarySensorEntityDescription( AmazonBinarySensorEntityDescription(
key="coughDetectionState", key="coughDetectionState",
translation_key="cough_detection", translation_key="cough_detection",
is_on_fn=lambda device, key: False, is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
), ),
AmazonBinarySensorEntityDescription( AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState", key="dogBarkDetectionState",
translation_key="dog_bark_detection", translation_key="dog_bark_detection",
is_on_fn=lambda device, key: False, is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="humanPresenceDetectionState",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
), ),
AmazonBinarySensorEntityDescription( AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState", key="waterSoundsDetectionState",
translation_key="water_sounds_detection", translation_key="water_sounds_detection",
is_on_fn=lambda device, key: False, is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
), ),
) )
@@ -104,46 +94,13 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
entity_registry = er.async_get(hass) async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
# Replace unique id for "detectionState" binary sensor for sensor_desc in BINARY_SENSORS
await async_update_unique_id( for serial_num in coordinator.data
hass, if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
coordinator,
BINARY_SENSOR_DOMAIN,
"humanPresenceDetectionState",
"detectionState",
) )
# Clean up deprecated sensors
for sensor_desc in DEPRECATED_BINARY_SENSORS:
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{sensor_desc.key}"
if entity_id := entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, unique_id
):
_LOGGER.debug("Removing deprecated entity %s", entity_id)
entity_registry.async_remove(entity_id)
known_devices: set[str] = set()
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in new_devices
if sensor_desc.is_supported(
coordinator.data[serial_num], sensor_desc.key
)
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
"""Binary sensor device.""" """Binary sensor device."""
@@ -156,13 +113,3 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
return self.entity_description.is_on_fn( return self.entity_description.is_on_fn(
self.device, self.entity_description.key self.device, self.entity_description.key
) )
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
self.entity_description.is_available_fn(
self.device, self.entity_description.key
)
and super().available
)

Some files were not shown because too many files have changed in this diff Show More