Compare commits

..

1 Commits

Author SHA1 Message Date
Paulus Schoutsen
6c2fc12b6a Validate Platform constant up to date 2025-09-12 11:04:25 -04:00
1079 changed files with 12130 additions and 62248 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

@@ -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

@@ -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@v5.0.0
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@v6.0.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@v5.0.0
- 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@v6.0.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@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.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@v5.0.0
- 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@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.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@v5.0.0
- 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@v5.0.0
- 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@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.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@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.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@v5.0.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@v6.0.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.13.0
with: with:
skip-existing: true skip-existing: true

View File

@@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Generate partial Python venv restore key - name: Generate partial Python venv restore key
id: generate_python_cache_key id: generate_python_cache_key
run: | run: |
@@ -120,7 +120,7 @@ jobs:
run: | run: |
echo "key=$(lsb_release -rs)-apt-${{ env.CACHE_VERSION }}-${{ env.HA_SHORT_VERSION }}" >> $GITHUB_OUTPUT echo "key=$(lsb_release -rs)-apt-${{ env.CACHE_VERSION }}-${{ env.HA_SHORT_VERSION }}" >> $GITHUB_OUTPUT
- name: Filter for core changes - name: Filter for core changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@v3.0.2
id: core id: core
with: with:
filters: .core_files.yaml filters: .core_files.yaml
@@ -135,7 +135,7 @@ jobs:
echo "Result:" echo "Result:"
cat .integration_paths.yaml cat .integration_paths.yaml
- name: Filter for integration changes - name: Filter for integration changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@v3.0.2
id: integrations id: integrations
with: with:
filters: .integration_paths.yaml filters: .integration_paths.yaml
@@ -254,16 +254,16 @@ jobs:
- info - info
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- 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@v6.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@v4.2.4
with: with:
path: venv path: venv
key: >- key: >-
@@ -279,7 +279,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)" uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true lookup-only: true
@@ -300,16 +300,16 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.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@v6.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -318,7 +318,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -340,16 +340,16 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.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@v6.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -358,7 +358,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -380,16 +380,16 @@ jobs:
- pre-commit - pre-commit
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.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@v6.0.0
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -398,7 +398,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }} needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache - name: Restore pre-commit environment from cache
id: cache-precommit id: cache-precommit
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: ${{ env.PRE_COMMIT_CACHE }} path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -470,7 +470,7 @@ jobs:
- script/hassfest/docker/Dockerfile - script/hassfest/docker/Dockerfile
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Register hadolint problem matcher - name: Register hadolint problem matcher
run: | run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json" echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -489,10 +489,10 @@ jobs:
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v6.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
@@ -505,7 +505,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@v4.2.4
with: with:
path: venv path: venv
key: >- key: >-
@@ -513,7 +513,7 @@ jobs:
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache - name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@v4.2.4
with: with:
path: ${{ env.UV_CACHE_DIR }} path: ${{ env.UV_CACHE_DIR }}
key: >- key: >-
@@ -523,24 +523,22 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{ env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
env.HA_SHORT_VERSION }}- env.HA_SHORT_VERSION }}-
- name: Check if apt cache exists - name: Restore apt cache
id: cache-apt-check if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 id: cache-apt
uses: actions/cache@v4.2.4
with: with:
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
path: | path: |
${{ env.APT_CACHE_DIR }} ${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }} ${{ env.APT_LIST_CACHE_DIR }}
key: >- key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Install additional OS dependencies - name: Install additional OS dependencies
if: | if: steps.cache-venv.outputs.cache-hit != 'true'
steps.cache-venv.outputs.cache-hit != 'true'
|| steps.cache-apt-check.outputs.cache-hit != 'true'
timeout-minutes: 10 timeout-minutes: 10
run: | run: |
sudo rm /etc/apt/sources.list.d/microsoft-prod.list sudo rm /etc/apt/sources.list.d/microsoft-prod.list
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
mkdir -p ${{ env.APT_CACHE_DIR }} mkdir -p ${{ env.APT_CACHE_DIR }}
mkdir -p ${{ env.APT_LIST_CACHE_DIR }} mkdir -p ${{ env.APT_LIST_CACHE_DIR }}
fi fi
@@ -565,18 +563,9 @@ jobs:
libswscale-dev \ libswscale-dev \
libudev-dev libudev-dev
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
sudo chmod -R 755 ${{ env.APT_CACHE_BASE }} sudo chmod -R 755 ${{ env.APT_CACHE_BASE }}
fi fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
${{ env.APT_CACHE_DIR }}
${{ env.APT_LIST_CACHE_DIR }}
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
- name: Create Python virtual environment - name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
run: | run: |
@@ -596,7 +585,7 @@ jobs:
python --version python --version
uv pip freeze >> pip_freeze.txt uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact - name: Upload pip_freeze artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: pip-freeze-${{ matrix.python-version }} name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt path: pip_freeze.txt
@@ -642,16 +631,16 @@ jobs:
-o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \ -o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
libturbojpeg libturbojpeg
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- 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@v6.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -675,16 +664,16 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- 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@v6.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -709,9 +698,9 @@ jobs:
&& github.event_name == 'pull_request' && github.event_name == 'pull_request'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Dependency review - name: Dependency review
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3 uses: actions/dependency-review-action@v4.7.3
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks
@@ -732,16 +721,16 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }} python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v6.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -753,7 +742,7 @@ jobs:
. venv/bin/activate . venv/bin/activate
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
- name: Upload licenses - name: Upload licenses
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }} name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json path: licenses-${{ matrix.python-version }}.json
@@ -775,16 +764,16 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- 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@v6.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -822,16 +811,16 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- 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@v6.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -867,10 +856,10 @@ jobs:
- base - base
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- 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@v6.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@@ -883,7 +872,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -891,7 +880,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }} needs.info.outputs.python_cache_key }}
- name: Restore mypy cache - name: Restore mypy cache
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@v4.2.4
with: with:
path: .mypy_cache path: .mypy_cache
key: >- key: >-
@@ -958,16 +947,16 @@ jobs:
libturbojpeg \ libturbojpeg \
libgammu-dev libgammu-dev
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- 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@v6.0.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
- name: Restore base Python virtual environment - name: Restore base Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -979,7 +968,7 @@ jobs:
. venv/bin/activate . venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets - name: Upload pytest_buckets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: pytest_buckets name: pytest_buckets
path: pytest_buckets.txt path: pytest_buckets.txt
@@ -1033,16 +1022,16 @@ jobs:
libgammu-dev \ libgammu-dev \
libxml2-utils libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v6.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1056,7 +1045,7 @@ jobs:
run: | run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets - name: Download pytest_buckets
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
name: pytest_buckets name: pytest_buckets
- name: Compile English translations - name: Compile English translations
@@ -1095,14 +1084,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure' if: success() || failure() && steps.pytest-full.conclusion == 'failure'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt path: pytest-*.txt
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }} name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml path: coverage.xml
@@ -1115,7 +1104,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml" mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact - name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled() if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml path: junit.xml
@@ -1180,16 +1169,16 @@ jobs:
libmariadb-dev-compat \ libmariadb-dev-compat \
libxml2-utils libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v6.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1248,7 +1237,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }} steps.pytest-partial.outputs.mariadb }}
@@ -1256,7 +1245,7 @@ jobs:
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: coverage-${{ matrix.python-version }}-${{ name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }} steps.pytest-partial.outputs.mariadb }}
@@ -1270,7 +1259,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml" mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact - name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled() if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: test-results-mariadb-${{ matrix.python-version }}-${{ name: test-results-mariadb-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }} steps.pytest-partial.outputs.mariadb }}
@@ -1336,16 +1325,16 @@ jobs:
sudo apt-get -y install \ sudo apt-get -y install \
postgresql-server-dev-14 postgresql-server-dev-14
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v6.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1405,7 +1394,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }} steps.pytest-partial.outputs.postgresql }}
@@ -1413,7 +1402,7 @@ jobs:
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: coverage-${{ matrix.python-version }}-${{ name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }} steps.pytest-partial.outputs.postgresql }}
@@ -1427,7 +1416,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml" mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact - name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled() if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: test-results-postgres-${{ matrix.python-version }}-${{ name: test-results-postgres-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }} steps.pytest-partial.outputs.postgresql }}
@@ -1448,14 +1437,14 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true' if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 uses: codecov/codecov-action@v5.5.1
with: with:
fail_ci_if_error: true fail_ci_if_error: true
flags: full-suite flags: full-suite
@@ -1509,16 +1498,16 @@ jobs:
libgammu-dev \ libgammu-dev \
libxml2-utils libxml2-utils
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@v6.0.0
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
check-latest: true check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment - name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache/restore@v4.2.4
with: with:
path: venv path: venv
fail-on-cache-miss: true fail-on-cache-miss: true
@@ -1574,14 +1563,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt path: pytest-*.txt
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }} name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml path: coverage.xml
@@ -1594,7 +1583,7 @@ jobs:
mv "junit.xml-tmp" "junit.xml" mv "junit.xml-tmp" "junit.xml"
- name: Upload test results artifact - name: Upload test results artifact
if: needs.info.outputs.skip_coverage != 'true' && !cancelled() if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v4.6.2
with: with:
name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }}
path: junit.xml path: junit.xml
@@ -1612,14 +1601,14 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v5.0.0
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
pattern: coverage-* pattern: coverage-*
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false' if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 uses: codecov/codecov-action@v5.5.1
with: with:
fail_ci_if_error: true fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@@ -1639,11 +1628,11 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Download all coverage artifacts - name: Download all coverage artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@v5.0.0
with: with:
pattern: test-results-* pattern: test-results-*
- name: Upload test results to Codecov - name: Upload test results to Codecov
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 uses: codecov/test-results-action@v1
with: with:
fail_ci_if_error: true fail_ci_if_error: true
verbose: true verbose: true

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@v5.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 uses: github/codeql-action/init@v3.30.3
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 uses: github/codeql-action/analyze@v3.30.3
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@v8
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@v8
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@v2.0.1
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@v8
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@v8
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@v2.0.1
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@v8
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

@@ -12,7 +12,7 @@ jobs:
if: github.event.issue.type.name == 'Task' if: github.event.issue.type.name == '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@v8
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@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 uses: actions/stale@v10.0.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@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 uses: actions/stale@v10.0.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@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 uses: actions/stale@v10.0.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@v5.0.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@v6.0.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@v5.0.0
- 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@v6.0.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@v5.0.0
- 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,7 +158,6 @@ 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.07.0 uses: home-assistant/wheels@2025.07.0
with: with:
@@ -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@v5.0.0
- 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,7 +218,6 @@ 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.07.0 uses: home-assistant/wheels@2025.07.0
with: with:

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

@@ -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.*
@@ -443,7 +442,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.*

33
CODEOWNERS generated
View File

@@ -107,8 +107,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
@@ -292,8 +292,6 @@ 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
@@ -316,8 +314,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
@@ -412,8 +408,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
@@ -448,6 +442,8 @@ 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/enocean/ @bdurrer
/tests/components/enocean/ @bdurrer
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/entur_public_transport/ @hfurubotten
@@ -776,8 +772,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
@@ -1023,8 +1017,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
@@ -1334,8 +1327,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
@@ -1358,8 +1349,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
@@ -1541,8 +1530,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
@@ -1687,8 +1676,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
@@ -1718,8 +1705,6 @@ build.json @home-assistant/supervisor
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/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,8 +1720,8 @@ build.json @home-assistant/supervisor
/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/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

View File

@@ -8,7 +8,6 @@ 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
@@ -43,7 +42,6 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
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=self.async_update_listeners, 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
@@ -77,46 +74,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

@@ -71,4 +71,3 @@ POLLEN_CATEGORY_MAP = {
} }
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10) UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
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

@@ -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%]"
} }
} }
}, },
@@ -30,8 +19,7 @@
"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": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]", "already_configured": "[%key:common::config_flow::abort::already_configured_location%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
}, },
"entity": { "entity": {
@@ -251,9 +239,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

@@ -3,8 +3,10 @@
import logging import logging
from typing import Any from typing import Any
from aiohttp import web
import voluptuous as vol import voluptuous as vol
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR
from homeassistant.core import ( from homeassistant.core import (
@@ -26,6 +28,7 @@ from .const import (
ATTR_STRUCTURE, ATTR_STRUCTURE,
ATTR_TASK_NAME, ATTR_TASK_NAME,
DATA_COMPONENT, DATA_COMPONENT,
DATA_IMAGES,
DATA_PREFERENCES, DATA_PREFERENCES,
DOMAIN, DOMAIN,
SERVICE_GENERATE_DATA, SERVICE_GENERATE_DATA,
@@ -39,6 +42,7 @@ from .task import (
GenDataTaskResult, GenDataTaskResult,
GenImageTask, GenImageTask,
GenImageTaskResult, GenImageTaskResult,
ImageData,
async_generate_data, async_generate_data,
async_generate_image, async_generate_image,
) )
@@ -51,6 +55,7 @@ __all__ = [
"GenDataTaskResult", "GenDataTaskResult",
"GenImageTask", "GenImageTask",
"GenImageTaskResult", "GenImageTaskResult",
"ImageData",
"async_generate_data", "async_generate_data",
"async_generate_image", "async_generate_image",
"async_setup", "async_setup",
@@ -89,8 +94,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass) entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component hass.data[DATA_COMPONENT] = entity_component
hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) hass.data[DATA_PREFERENCES] = AITaskPreferences(hass)
hass.data[DATA_IMAGES] = {}
await hass.data[DATA_PREFERENCES].async_load() await hass.data[DATA_PREFERENCES].async_load()
async_setup_http(hass) async_setup_http(hass)
hass.http.register_view(ImageView)
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_GENERATE_DATA, SERVICE_GENERATE_DATA,
@@ -202,3 +209,28 @@ class AITaskPreferences:
def as_dict(self) -> dict[str, str | None]: def as_dict(self) -> dict[str, str | None]:
"""Get the current preferences.""" """Get the current preferences."""
return {key: getattr(self, key) for key in self.KEYS} return {key: getattr(self, key) for key in self.KEYS}
class ImageView(HomeAssistantView):
"""View to generated images."""
url = f"/api/{DOMAIN}/images/{{filename}}"
name = f"api:{DOMAIN}/images"
async def get(
self,
request: web.Request,
filename: str,
) -> web.Response:
"""Serve image."""
hass = request.app[KEY_HASS]
image_storage = hass.data[DATA_IMAGES]
image_data = image_storage.get(filename)
if image_data is None:
raise web.HTTPNotFound
return web.Response(
body=image_data.data,
content_type=image_data.mime_type,
)

View File

@@ -8,19 +8,19 @@ 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
from .entity import AITaskEntity from .entity import AITaskEntity
from .task import ImageData
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") DATA_IMAGES: HassKey[dict[str, ImageData]] = HassKey(f"{DOMAIN}_images")
IMAGE_DIR: Final = "image"
IMAGE_EXPIRY_TIME = 60 * 60 # 1 hour IMAGE_EXPIRY_TIME = 60 * 60 # 1 hour
MAX_IMAGES = 20
SERVICE_GENERATE_DATA = "generate_data" SERVICE_GENERATE_DATA = "generate_data"
SERVICE_GENERATE_IMAGE = "generate_image" SERVICE_GENERATE_IMAGE = "generate_image"

View File

@@ -1,7 +1,7 @@
{ {
"domain": "ai_task", "domain": "ai_task",
"name": "AI Task", "name": "AI Task",
"after_dependencies": ["camera"], "after_dependencies": ["camera", "http"],
"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",

View File

@@ -2,31 +2,89 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from datetime import timedelta
import logging
from homeassistant.components.media_source import MediaSource, local_source from homeassistant.components.http.auth import async_sign_path
from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.components.media_source import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
Unresolvable,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR from .const import DATA_IMAGES, DOMAIN, IMAGE_EXPIRY_TIME
_LOGGER = logging.getLogger(__name__)
async def async_get_media_source(hass: HomeAssistant) -> MediaSource: async def async_get_media_source(hass: HomeAssistant) -> ImageMediaSource:
"""Set up local media source.""" """Set up image media source."""
media_dirs = list(hass.config.media_dirs.values()) _LOGGER.debug("Setting up image media source")
return ImageMediaSource(hass)
if not media_dirs:
raise HomeAssistantError( class ImageMediaSource(MediaSource):
"AI Task media source requires at least one media directory configured" """Provide images as media sources."""
name: str = "AI Generated Images"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize ImageMediaSource."""
super().__init__(DOMAIN)
self.hass = hass
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
image_storage = self.hass.data[DATA_IMAGES]
image = image_storage.get(item.identifier)
if image is None:
raise Unresolvable(f"Could not resolve media item: {item.identifier}")
return PlayMedia(
async_sign_path(
self.hass,
f"/api/{DOMAIN}/images/{item.identifier}",
timedelta(seconds=IMAGE_EXPIRY_TIME or 1800),
),
image.mime_type,
) )
media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR async def async_browse_media(
self,
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
if item.identifier:
raise BrowseError("Unknown item")
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource( image_storage = self.hass.data[DATA_IMAGES]
hass,
DOMAIN, children = [
"AI Generated Images", BrowseMediaSource(
{IMAGE_DIR: str(media_dir)}, domain=DOMAIN,
f"/{DOMAIN}", identifier=filename,
) media_class=MediaClass.IMAGE,
return source media_content_type=image.mime_type,
title=image.title or filename,
can_play=True,
can_expand=False,
)
for filename, image in image_storage.items()
]
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.APP,
media_content_type="",
title="AI Generated Images",
can_play=False,
can_expand=True,
children_media_class=MediaClass.IMAGE,
children=children,
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
import io from functools import partial
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
import tempfile import tempfile
@@ -12,33 +12,34 @@ 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.components.http.auth import async_sign_path
from homeassistant.core import HomeAssistant, ServiceResponse, 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 import llm
from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session
from homeassistant.helpers.event import async_call_later
from homeassistant.util import RE_SANITIZE_FILENAME, slugify from homeassistant.util import RE_SANITIZE_FILENAME, slugify
from .const import ( from .const import (
DATA_COMPONENT, DATA_COMPONENT,
DATA_MEDIA_SOURCE, DATA_IMAGES,
DATA_PREFERENCES, DATA_PREFERENCES,
DOMAIN, DOMAIN,
IMAGE_DIR,
IMAGE_EXPIRY_TIME, IMAGE_EXPIRY_TIME,
MAX_IMAGES,
AITaskEntityFeature, 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)
@@ -54,31 +55,26 @@ async def _resolve_attachments(
for attachment in attachments or []: for attachment in attachments or []:
media_content_id = attachment["media_content_id"] media_content_id = attachment["media_content_id"]
# Special case for certain media sources # Special case for camera media sources
for integration in camera, image: if media_content_id.startswith("media-source://camera/"):
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 # Extract entity_id from the media content ID
entity_id = media_content_id.removeprefix(media_source_prefix) entity_id = media_content_id.removeprefix("media-source://camera/")
# Get snapshot from entity # Get snapshot from camera
image_data = await integration.async_get_image(hass, entity_id) image = await camera.async_get_image(hass, entity_id)
temp_filename = await hass.async_add_executor_job( temp_filename = await hass.async_add_executor_job(
_save_camera_snapshot, image_data _save_camera_snapshot, image
) )
created_files.append(temp_filename) created_files.append(temp_filename)
resolved_attachments.append( resolved_attachments.append(
conversation.Attachment( conversation.Attachment(
media_content_id=media_content_id, media_content_id=media_content_id,
mime_type=image_data.content_type, mime_type=image.content_type,
path=temp_filename, path=temp_filename,
) )
) )
break
else: else:
# Handle regular media sources # Handle regular media sources
media = await media_source.async_resolve_media(hass, media_content_id, None) media = await media_source.async_resolve_media(hass, media_content_id, None)
@@ -161,6 +157,24 @@ async def async_generate_data(
) )
def _cleanup_images(image_storage: dict[str, ImageData], num_to_remove: int) -> None:
"""Remove old images to keep the storage size under the limit."""
if num_to_remove <= 0:
return
if num_to_remove >= len(image_storage):
image_storage.clear()
return
sorted_images = sorted(
image_storage.items(),
key=lambda item: item[1].timestamp,
)
for filename, _ in sorted_images[:num_to_remove]:
image_storage.pop(filename, None)
async def async_generate_image( async def async_generate_image(
hass: HomeAssistant, hass: HomeAssistant,
*, *,
@@ -210,34 +224,36 @@ async def async_generate_image(
if service_result.get("revised_prompt") is None: if service_result.get("revised_prompt") is None:
service_result["revised_prompt"] = instructions service_result["revised_prompt"] = instructions
source = hass.data[DATA_MEDIA_SOURCE] image_storage = hass.data[DATA_IMAGES]
if len(image_storage) + 1 > MAX_IMAGES:
_cleanup_images(image_storage, len(image_storage) + 1 - MAX_IMAGES)
current_time = datetime.now() current_time = datetime.now()
ext = mimetypes.guess_extension(task_result.mime_type, False) or ".png" ext = mimetypes.guess_extension(task_result.mime_type, False) or ".png"
sanitized_task_name = RE_SANITIZE_FILENAME.sub("", slugify(task_name)) sanitized_task_name = RE_SANITIZE_FILENAME.sub("", slugify(task_name))
filename = f"{current_time.strftime('%Y-%m-%d_%H%M%S')}_{sanitized_task_name}{ext}"
image_file = ImageData( image_storage[filename] = ImageData(
filename=f"{current_time.strftime('%Y-%m-%d_%H%M%S')}_{sanitized_task_name}{ext}", data=image_data,
file=io.BytesIO(image_data), timestamp=int(current_time.timestamp()),
content_type=task_result.mime_type, mime_type=task_result.mime_type,
title=service_result["revised_prompt"],
) )
target_folder = media_source.MediaSourceItem.from_uri( def _purge_image(filename: str, now: datetime) -> None:
hass, f"media-source://{DOMAIN}/{IMAGE_DIR}", None """Remove image from storage."""
) image_storage.pop(filename, None)
service_result["media_source_id"] = await source.async_upload_media( if IMAGE_EXPIRY_TIME > 0:
target_folder, image_file async_call_later(hass, IMAGE_EXPIRY_TIME, partial(_purge_image, filename))
)
item = media_source.MediaSourceItem.from_uri(
hass, service_result["media_source_id"], None
)
service_result["url"] = async_sign_path( service_result["url"] = async_sign_path(
hass, hass,
(await source.async_resolve_media(item)).url, f"/api/{DOMAIN}/images/{filename}",
timedelta(seconds=IMAGE_EXPIRY_TIME), timedelta(seconds=IMAGE_EXPIRY_TIME or 1800),
) )
service_result["media_source_id"] = f"media-source://{DOMAIN}/images/{filename}"
return service_result return service_result
@@ -342,8 +358,20 @@ class GenImageTaskResult:
@dataclass(slots=True) @dataclass(slots=True)
class ImageData: class ImageData:
"""Implementation of media_source.local_source.UploadedFile protocol.""" """Image data for stored generated images."""
filename: str data: bytes
file: io.IOBase """Raw image data."""
content_type: str
timestamp: int
"""Timestamp when the image was generated, as a Unix timestamp."""
mime_type: str
"""MIME type of the image."""
title: str
"""Title of the image, usually the prompt used to generate it."""
def __str__(self) -> str:
"""Return image data as a string."""
return f"<ImageData {self.title}: {id(self)}>"

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

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from genie_partner_sdk.client import AladdinConnectClient from genie_partner_sdk.client import AladdinConnectClient
from genie_partner_sdk.model import GarageDoor
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -35,7 +36,22 @@ async def async_setup_entry(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
) )
doors = await client.get_doors() sdk_doors = await client.get_doors()
# Convert SDK GarageDoor objects to integration GarageDoor objects
doors = [
GarageDoor(
{
"device_id": door.device_id,
"door_number": door.door_number,
"name": door.name,
"status": door.status,
"link_status": door.link_status,
"battery_level": door.battery_level,
}
)
for door in sdk_doors
]
entry.runtime_data = { entry.runtime_data = {
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door) door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)

View File

@@ -41,10 +41,4 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
async def _async_update_data(self) -> GarageDoor: async def _async_update_data(self) -> GarageDoor:
"""Fetch data from the Aladdin Connect API.""" """Fetch data from the Aladdin Connect API."""
await self.client.update_door(self.data.device_id, self.data.door_number) 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 return self.data

View File

@@ -49,9 +49,7 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
@property @property
def is_closed(self) -> bool | None: def is_closed(self) -> bool | None:
"""Update is closed attribute.""" """Update is closed attribute."""
if (status := self.coordinator.data.status) is None: return self.coordinator.data.status == "closed"
return None
return status == "closed"
@property @property
def is_closing(self) -> bool | None: def is_closing(self) -> bool | None:

View File

@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["genie-partner-sdk==1.0.11"] "requirements": ["genie-partner-sdk==1.0.10"]
} }

View File

@@ -94,24 +94,12 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
known_devices: set[str] = set() async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
def _check_device() -> None: for sensor_desc in BINARY_SENSORS
current_devices = set(coordinator.data) for serial_num in coordinator.data
new_devices = current_devices - known_devices if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
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):

View File

@@ -57,23 +57,13 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
known_devices: set[str] = set() async_add_entities(
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
def _check_device() -> None: for sensor_desc in NOTIFY
current_devices = set(coordinator.data) for serial_num in coordinator.data
new_devices = current_devices - known_devices if sensor_desc.subkey in coordinator.data[serial_num].capabilities
if new_devices: and sensor_desc.is_supported(coordinator.data[serial_num])
known_devices.update(new_devices) )
async_add_entities(
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in NOTIFY
for serial_num in new_devices
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
and sensor_desc.is_supported(coordinator.data[serial_num])
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonNotifyEntity(AmazonEntity, NotifyEntity): class AmazonNotifyEntity(AmazonEntity, NotifyEntity):

View File

@@ -53,7 +53,7 @@ rules:
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: done docs-troubleshooting: done
docs-use-cases: done docs-use-cases: done
dynamic-devices: done dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done

View File

@@ -62,22 +62,12 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
known_devices: set[str] = set() async_add_entities(
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
def _check_device() -> None: for sensor_desc in SENSORS
current_devices = set(coordinator.data) for serial_num in coordinator.data
new_devices = current_devices - known_devices if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
if new_devices: )
known_devices.update(new_devices)
async_add_entities(
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in SENSORS
for serial_num in new_devices
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonSensorEntity(AmazonEntity, SensorEntity): class AmazonSensorEntity(AmazonEntity, SensorEntity):

View File

@@ -48,22 +48,12 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
known_devices: set[str] = set() async_add_entities(
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
def _check_device() -> None: for switch_desc in SWITCHES
current_devices = set(coordinator.data) for serial_num in coordinator.data
new_devices = current_devices - known_devices if switch_desc.subkey in coordinator.data[serial_num].capabilities
if new_devices: )
known_devices.update(new_devices)
async_add_entities(
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
for switch_desc in SWITCHES
for serial_num in new_devices
if switch_desc.subkey in coordinator.data[serial_num].capabilities
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonSwitchEntity(AmazonEntity, SwitchEntity): class AmazonSwitchEntity(AmazonEntity, SwitchEntity):

View File

@@ -41,7 +41,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE: if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
return [] return []
call_ids = await async_extract_entity_ids(call) call_ids = await async_extract_entity_ids(hass, call)
entity_ids = [] entity_ids = []
for entity_id in hass.data[DATA_AMCREST][CAMERAS]: for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids: if entity_id not in call_ids:

View File

@@ -12,25 +12,10 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from .analytics import ( from .analytics import Analytics
Analytics,
AnalyticsInput,
AnalyticsModifications,
DeviceAnalyticsModifications,
EntityAnalyticsModifications,
async_devices_payload,
)
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
from .http import AnalyticsDevicesView from .http import AnalyticsDevicesView
__all__ = [
"AnalyticsInput",
"AnalyticsModifications",
"DeviceAnalyticsModifications",
"EntityAnalyticsModifications",
"async_devices_payload",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN) DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)

View File

@@ -4,10 +4,9 @@ from __future__ import annotations
import asyncio import asyncio
from asyncio import timeout from asyncio import timeout
from collections.abc import Awaitable, Callable, Iterable, Mapping from dataclasses import asdict as dataclass_asdict, dataclass
from dataclasses import asdict as dataclass_asdict, dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any, Protocol from typing import Any
import uuid import uuid
import aiohttp import aiohttp
@@ -36,14 +35,11 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.typing import UNDEFINED
from homeassistant.loader import ( from homeassistant.loader import (
Integration, Integration,
IntegrationNotFound, IntegrationNotFound,
async_get_integration,
async_get_integrations, async_get_integrations,
) )
from homeassistant.setup import async_get_loaded_integrations from homeassistant.setup import async_get_loaded_integrations
@@ -79,115 +75,12 @@ from .const import (
ATTR_USER_COUNT, ATTR_USER_COUNT,
ATTR_UUID, ATTR_UUID,
ATTR_VERSION, ATTR_VERSION,
DOMAIN,
LOGGER, LOGGER,
PREFERENCE_SCHEMA, PREFERENCE_SCHEMA,
STORAGE_KEY, STORAGE_KEY,
STORAGE_VERSION, STORAGE_VERSION,
) )
DATA_ANALYTICS_MODIFIERS = "analytics_modifiers"
type AnalyticsModifier = Callable[
[HomeAssistant, AnalyticsInput], Awaitable[AnalyticsModifications]
]
@singleton(DATA_ANALYTICS_MODIFIERS)
def _async_get_modifiers(
hass: HomeAssistant,
) -> dict[str, AnalyticsModifier | None]:
"""Return the analytics modifiers."""
return {}
@dataclass
class AnalyticsInput:
"""Analytics input for a single integration.
This is sent to integrations that implement the platform.
"""
device_ids: Iterable[str] = field(default_factory=list)
entity_ids: Iterable[str] = field(default_factory=list)
@dataclass
class AnalyticsModifications:
"""Analytics config for a single integration.
This is used by integrations that implement the platform.
"""
remove: bool = False
devices: Mapping[str, DeviceAnalyticsModifications] | None = None
entities: Mapping[str, EntityAnalyticsModifications] | None = None
@dataclass
class DeviceAnalyticsModifications:
"""Analytics config for a single device.
This is used by integrations that implement the platform.
"""
remove: bool = False
@dataclass
class EntityAnalyticsModifications:
"""Analytics config for a single entity.
This is used by integrations that implement the platform.
"""
remove: bool = False
class AnalyticsPlatformProtocol(Protocol):
"""Define the format of analytics platforms."""
async def async_modify_analytics(
self,
hass: HomeAssistant,
analytics_input: AnalyticsInput,
) -> AnalyticsModifications:
"""Modify the analytics."""
async def _async_get_analytics_platform(
hass: HomeAssistant, domain: str
) -> AnalyticsPlatformProtocol | None:
"""Get analytics platform."""
try:
integration = await async_get_integration(hass, domain)
except IntegrationNotFound:
return None
try:
return await integration.async_get_platform(DOMAIN)
except ImportError:
return None
async def _async_get_modifier(
hass: HomeAssistant, domain: str
) -> AnalyticsModifier | None:
"""Get analytics modifier."""
modifiers = _async_get_modifiers(hass)
modifier = modifiers.get(domain, UNDEFINED)
if modifier is not UNDEFINED:
return modifier
platform = await _async_get_analytics_platform(hass, domain)
if platform is None:
modifiers[domain] = None
return None
modifier = getattr(platform, "async_modify_analytics", None)
modifiers[domain] = modifier
return modifier
def gen_uuid() -> str: def gen_uuid() -> str:
"""Generate a new UUID.""" """Generate a new UUID."""
@@ -500,20 +393,17 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
return domains return domains
DEFAULT_ANALYTICS_CONFIG = AnalyticsModifications()
DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
async def async_devices_payload(hass: HomeAssistant) -> dict: async def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return detailed information about entities and devices.""" """Return detailed information about entities and devices."""
integrations_info: dict[str, dict[str, Any]] = {}
dev_reg = dr.async_get(hass) dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
integration_inputs: dict[str, tuple[list[str], list[str]]] = {} # We need to refer to other devices, for example in `via_device` field.
integration_configs: dict[str, AnalyticsModifications] = {} # We don't however send the original device ids outside of Home Assistant,
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
device_id_mapping: dict[str, tuple[str, int]] = {}
# Get device list
for device_entry in dev_reg.devices.values(): for device_entry in dev_reg.devices.values():
if not device_entry.primary_config_entry: if not device_entry.primary_config_entry:
continue continue
@@ -526,113 +416,27 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
continue continue
integration_domain = config_entry.domain integration_domain = config_entry.domain
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
integration_input[0].append(device_entry.id)
# Get entity list
for entity_entry in ent_reg.entities.values():
integration_domain = entity_entry.platform
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
integration_input[1].append(entity_entry.entity_id)
integrations = {
domain: integration
for domain, integration in (
await async_get_integrations(hass, integration_inputs.keys())
).items()
if isinstance(integration, Integration)
}
# Filter out custom integrations and integrations that are not device or hub type
integration_inputs = {
domain: integration_info
for domain, integration_info in integration_inputs.items()
if (integration := integrations.get(domain)) is not None
and integration.is_built_in
and integration.integration_type in ("device", "hub")
}
# Call integrations that implement the analytics platform
for integration_domain, integration_input in integration_inputs.items():
if (
modifier := await _async_get_modifier(hass, integration_domain)
) is not None:
try:
integration_config = await modifier(
hass, AnalyticsInput(*integration_input)
)
except Exception as err: # noqa: BLE001
LOGGER.exception(
"Calling async_modify_analytics for integration '%s' failed: %s",
integration_domain,
err,
)
integration_configs[integration_domain] = AnalyticsModifications(
remove=True
)
continue
if not isinstance(integration_config, AnalyticsModifications):
LOGGER.error( # type: ignore[unreachable]
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
integration_domain,
)
integration_configs[integration_domain] = AnalyticsModifications(
remove=True
)
continue
integration_configs[integration_domain] = integration_config
integrations_info: dict[str, dict[str, Any]] = {}
# We need to refer to other devices, for example in `via_device` field.
# We don't however send the original device ids outside of Home Assistant,
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
device_id_mapping: dict[str, tuple[str, int]] = {}
# Fill out information about devices
for integration_domain, integration_input in integration_inputs.items():
integration_config = integration_configs.get(
integration_domain, DEFAULT_ANALYTICS_CONFIG
)
if integration_config.remove:
continue
integration_info = integrations_info.setdefault( integration_info = integrations_info.setdefault(
integration_domain, {"devices": [], "entities": []} integration_domain, {"devices": [], "entities": []}
) )
devices_info = integration_info["devices"] devices_info = integration_info["devices"]
for device_id in integration_input[0]: device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
device_config = DEFAULT_DEVICE_ANALYTICS_CONFIG
if integration_config.devices is not None:
device_config = integration_config.devices.get(device_id, device_config)
if device_config.remove: devices_info.append(
continue {
"entities": [],
device_entry = dev_reg.devices[device_id] "entry_type": device_entry.entry_type,
"has_configuration_url": device_entry.configuration_url is not None,
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info)) "hw_version": device_entry.hw_version,
"manufacturer": device_entry.manufacturer,
devices_info.append( "model": device_entry.model,
{ "model_id": device_entry.model_id,
"entities": [], "sw_version": device_entry.sw_version,
"entry_type": device_entry.entry_type, "via_device": device_entry.via_device_id,
"has_configuration_url": device_entry.configuration_url is not None, }
"hw_version": device_entry.hw_version, )
"manufacturer": device_entry.manufacturer,
"model": device_entry.model,
"model_id": device_entry.model_id,
"sw_version": device_entry.sw_version,
"via_device": device_entry.via_device_id,
}
)
# Fill out via_device with new device ids # Fill out via_device with new device ids
for integration_info in integrations_info.values(): for integration_info in integrations_info.values():
@@ -641,15 +445,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
continue continue
device_info["via_device"] = device_id_mapping.get(device_info["via_device"]) device_info["via_device"] = device_id_mapping.get(device_info["via_device"])
# Fill out information about entities ent_reg = er.async_get(hass)
for integration_domain, integration_input in integration_inputs.items():
integration_config = integration_configs.get(
integration_domain, DEFAULT_ANALYTICS_CONFIG
)
if integration_config.remove:
continue
for entity_entry in ent_reg.entities.values():
integration_domain = entity_entry.platform
integration_info = integrations_info.setdefault( integration_info = integrations_info.setdefault(
integration_domain, {"devices": [], "entities": []} integration_domain, {"devices": [], "entities": []}
) )
@@ -657,49 +456,53 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
devices_info = integration_info["devices"] devices_info = integration_info["devices"]
entities_info = integration_info["entities"] entities_info = integration_info["entities"]
for entity_id in integration_input[1]: entity_state = hass.states.get(entity_entry.entity_id)
entity_config = DEFAULT_ENTITY_ANALYTICS_CONFIG
if integration_config.entities is not None: entity_info = {
entity_config = integration_config.entities.get( # LIMITATION: `assumed_state` can be overridden by users;
entity_id, entity_config # we should replace it with the original value in the future.
# It is also not present, if entity is not in the state machine,
# which can happen for disabled entities.
"assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
if entity_state is not None
else None,
"capabilities": entity_entry.capabilities,
"domain": entity_entry.domain,
"entity_category": entity_entry.entity_category,
"has_entity_name": entity_entry.has_entity_name,
"original_device_class": entity_entry.original_device_class,
# LIMITATION: `unit_of_measurement` can be overridden by users;
# we should replace it with the original value in the future.
"unit_of_measurement": entity_entry.unit_of_measurement,
}
if (
((device_id := entity_entry.device_id) is not None)
and ((new_device_id := device_id_mapping.get(device_id)) is not None)
and (new_device_id[0] == integration_domain)
):
device_info = devices_info[new_device_id[1]]
device_info["entities"].append(entity_info)
else:
entities_info.append(entity_info)
integrations = {
domain: integration
for domain, integration in (
await async_get_integrations(hass, integrations_info.keys())
).items()
if isinstance(integration, Integration)
}
for domain, integration_info in integrations_info.items():
if integration := integrations.get(domain):
integration_info["is_custom_integration"] = not integration.is_built_in
# Include version for custom integrations
if not integration.is_built_in and integration.version:
integration_info["custom_integration_version"] = str(
integration.version
) )
if entity_config.remove:
continue
entity_entry = ent_reg.entities[entity_id]
entity_state = hass.states.get(entity_entry.entity_id)
entity_info = {
# LIMITATION: `assumed_state` can be overridden by users;
# we should replace it with the original value in the future.
# It is also not present, if entity is not in the state machine,
# which can happen for disabled entities.
"assumed_state": (
entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
if entity_state is not None
else None
),
"domain": entity_entry.domain,
"entity_category": entity_entry.entity_category,
"has_entity_name": entity_entry.has_entity_name,
"original_device_class": entity_entry.original_device_class,
# LIMITATION: `unit_of_measurement` can be overridden by users;
# we should replace it with the original value in the future.
"unit_of_measurement": entity_entry.unit_of_measurement,
}
if (
((device_id_ := entity_entry.device_id) is not None)
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
and (new_device_id[0] == integration_domain)
):
device_info = devices_info[new_device_id[1]]
device_info["entities"].append(entity_info)
else:
entities_info.append(entity_info)
return { return {
"version": "home-assistant:1", "version": "home-assistant:1",
"home_assistant": HA_VERSION, "home_assistant": HA_VERSION,

View File

@@ -2,7 +2,7 @@
"domain": "analytics", "domain": "analytics",
"name": "Analytics", "name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"], "after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core", "@ludeeus"],
"dependencies": ["api", "websocket_api", "http"], "dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics", "documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system", "integration_type": "system",

View File

@@ -33,11 +33,9 @@ from homeassistant.const import (
) )
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
CONF_ADB_SERVER_IP, CONF_ADB_SERVER_IP,
@@ -48,12 +46,10 @@ from .const import (
DEFAULT_ADB_SERVER_PORT, DEFAULT_ADB_SERVER_PORT,
DEVICE_ANDROIDTV, DEVICE_ANDROIDTV,
DEVICE_FIRETV, DEVICE_FIRETV,
DOMAIN,
PROP_ETHMAC, PROP_ETHMAC,
PROP_WIFIMAC, PROP_WIFIMAC,
SIGNAL_CONFIG_ENTITY, SIGNAL_CONFIG_ENTITY,
) )
from .services import async_setup_services
ADB_PYTHON_EXCEPTIONS: tuple = ( ADB_PYTHON_EXCEPTIONS: tuple = (
AdbTimeoutError, AdbTimeoutError,
@@ -67,8 +63,6 @@ ADB_PYTHON_EXCEPTIONS: tuple = (
) )
ADB_TCP_EXCEPTIONS: tuple = (ConnectionResetError, RuntimeError) ADB_TCP_EXCEPTIONS: tuple = (ConnectionResetError, RuntimeError)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES] RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES]
@@ -194,12 +188,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Android TV / Fire TV integration."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> bool:
"""Set up Android Debug Bridge platform.""" """Set up Android Debug Bridge platform."""

View File

@@ -8,6 +8,7 @@ import logging
from androidtv.constants import APPS, KEYS from androidtv.constants import APPS, KEYS
from androidtv.setup_async import AndroidTVAsync, FireTVAsync from androidtv.setup_async import AndroidTVAsync, FireTVAsync
import voluptuous as vol
from homeassistant.components import persistent_notification from homeassistant.components import persistent_notification
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@@ -16,7 +17,9 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
) )
from homeassistant.const import ATTR_COMMAND
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
@@ -36,10 +39,19 @@ from .const import (
SIGNAL_CONFIG_ENTITY, SIGNAL_CONFIG_ENTITY,
) )
from .entity import AndroidTVEntity, adb_decorator from .entity import AndroidTVEntity, adb_decorator
from .services import ATTR_ADB_RESPONSE, ATTR_HDMI_INPUT, SERVICE_LEARN_SENDEVENT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ATTR_ADB_RESPONSE = "adb_response"
ATTR_DEVICE_PATH = "device_path"
ATTR_HDMI_INPUT = "hdmi_input"
ATTR_LOCAL_PATH = "local_path"
SERVICE_ADB_COMMAND = "adb_command"
SERVICE_DOWNLOAD = "download"
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
SERVICE_UPLOAD = "upload"
# Translate from `AndroidTV` / `FireTV` reported state to HA state. # Translate from `AndroidTV` / `FireTV` reported state to HA state.
ANDROIDTV_STATES = { ANDROIDTV_STATES = {
"off": MediaPlayerState.OFF, "off": MediaPlayerState.OFF,
@@ -65,6 +77,32 @@ async def async_setup_entry(
] ]
) )
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_ADB_COMMAND,
{vol.Required(ATTR_COMMAND): cv.string},
"adb_command",
)
platform.async_register_entity_service(
SERVICE_LEARN_SENDEVENT, None, "learn_sendevent"
)
platform.async_register_entity_service(
SERVICE_DOWNLOAD,
{
vol.Required(ATTR_DEVICE_PATH): cv.string,
vol.Required(ATTR_LOCAL_PATH): cv.string,
},
"service_download",
)
platform.async_register_entity_service(
SERVICE_UPLOAD,
{
vol.Required(ATTR_DEVICE_PATH): cv.string,
vol.Required(ATTR_LOCAL_PATH): cv.string,
},
"service_upload",
)
class ADBDevice(AndroidTVEntity, MediaPlayerEntity): class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
"""Representation of an Android or Fire TV device.""" """Representation of an Android or Fire TV device."""

View File

@@ -1,66 +0,0 @@
"""Services for Android/Fire TV devices."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.const import ATTR_COMMAND
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
ATTR_ADB_RESPONSE = "adb_response"
ATTR_DEVICE_PATH = "device_path"
ATTR_HDMI_INPUT = "hdmi_input"
ATTR_LOCAL_PATH = "local_path"
SERVICE_ADB_COMMAND = "adb_command"
SERVICE_DOWNLOAD = "download"
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
SERVICE_UPLOAD = "upload"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the Android TV / Fire TV services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_ADB_COMMAND,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_COMMAND): cv.string},
func="adb_command",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_LEARN_SENDEVENT,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="learn_sendevent",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_DOWNLOAD,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Required(ATTR_DEVICE_PATH): cv.string,
vol.Required(ATTR_LOCAL_PATH): cv.string,
},
func="service_download",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_UPLOAD,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Required(ATTR_DEVICE_PATH): cv.string,
vol.Required(ATTR_LOCAL_PATH): cv.string,
},
func="service_upload",
)

View File

@@ -16,7 +16,7 @@ from .coordinator import (
AOSmithStatusCoordinator, AOSmithStatusCoordinator,
) )
PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR, Platform.WATER_HEATER] PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER]
async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> bool:

View File

@@ -1,10 +1,5 @@
{ {
"entity": { "entity": {
"select": {
"hot_water_plus_level": {
"default": "mdi:water-plus"
}
},
"sensor": { "sensor": {
"hot_water_availability": { "hot_water_availability": {
"default": "mdi:water-thermometer" "default": "mdi:water-thermometer"

View File

@@ -1,70 +0,0 @@
"""The select platform for the A. O. Smith integration."""
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AOSmithConfigEntry
from .coordinator import AOSmithStatusCoordinator
from .entity import AOSmithStatusEntity
HWP_LEVEL_HA_TO_AOSMITH = {
"off": 0,
"level1": 1,
"level2": 2,
"level3": 3,
}
HWP_LEVEL_AOSMITH_TO_HA = {value: key for key, value in HWP_LEVEL_HA_TO_AOSMITH.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: AOSmithConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up A. O. Smith select platform."""
data = entry.runtime_data
async_add_entities(
AOSmithHotWaterPlusSelectEntity(data.status_coordinator, device.junction_id)
for device in data.status_coordinator.data.values()
if device.supports_hot_water_plus
)
class AOSmithHotWaterPlusSelectEntity(AOSmithStatusEntity, SelectEntity):
"""Class for the Hot Water+ select entity."""
_attr_translation_key = "hot_water_plus_level"
_attr_options = list(HWP_LEVEL_HA_TO_AOSMITH)
def __init__(self, coordinator: AOSmithStatusCoordinator, junction_id: str) -> None:
"""Initialize the entity."""
super().__init__(coordinator, junction_id)
self._attr_unique_id = f"hot_water_plus_level_{junction_id}"
@property
def suggested_object_id(self) -> str | None:
"""Override the suggested object id to make '+' get converted to 'plus' in the entity id."""
return "hot_water_plus_level"
@property
def current_option(self) -> str | None:
"""Return the current Hot Water+ mode."""
hot_water_plus_level = self.device.status.hot_water_plus_level
return (
None
if hot_water_plus_level is None
else HWP_LEVEL_AOSMITH_TO_HA.get(hot_water_plus_level)
)
async def async_select_option(self, option: str) -> None:
"""Set the Hot Water+ mode."""
aosmith_hwp_level = HWP_LEVEL_HA_TO_AOSMITH[option]
await self.client.update_mode(
junction_id=self.junction_id,
mode=self.device.status.current_mode,
hot_water_plus_level=aosmith_hwp_level,
)
await self.coordinator.async_request_refresh()

View File

@@ -26,17 +26,6 @@
} }
}, },
"entity": { "entity": {
"select": {
"hot_water_plus_level": {
"name": "Hot Water+ level",
"state": {
"off": "[%key:common::state::off%]",
"level1": "Level 1",
"level2": "Level 2",
"level3": "Level 3"
}
}
},
"sensor": { "sensor": {
"hot_water_availability": { "hot_water_availability": {
"name": "Hot water availability" "name": "Hot water availability"

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["apcaccess"], "loggers": ["apcaccess"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aioapcaccess==1.0.0"] "requirements": ["aioapcaccess==0.4.2"]
} }

View File

@@ -395,7 +395,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
"upsmode": SensorEntityDescription( "upsmode": SensorEntityDescription(
key="upsmode", key="upsmode",
translation_key="ups_mode", translation_key="ups_mode",
entity_category=EntityCategory.DIAGNOSTIC,
), ),
"upsname": SensorEntityDescription( "upsname": SensorEntityDescription(
key="upsname", key="upsname",
@@ -467,10 +466,7 @@ async def async_setup_entry(
# periodical (or manual) self test since last daemon restart. It might not be available # periodical (or manual) self test since last daemon restart. It might not be available
# when we set up the integration, and we do not know if it would ever be available. Here we # when we set up the integration, and we do not know if it would ever be available. Here we
# add it anyway and mark it as unknown initially. # add it anyway and mark it as unknown initially.
# for resource in available_resources | {LAST_S_TEST}:
# We also sort the resources to ensure the order of entities created is deterministic since
# "APCMODEL" and "MODEL" resources map to the same "Model" name.
for resource in sorted(available_resources | {LAST_S_TEST}):
if resource not in SENSORS: if resource not in SENSORS:
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper()) _LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
continue continue

View File

@@ -1,7 +1,5 @@
"""Constants for the Assist pipeline integration.""" """Constants for the Assist pipeline integration."""
from pathlib import Path
DOMAIN = "assist_pipeline" DOMAIN = "assist_pipeline"
DATA_CONFIG = f"{DOMAIN}.config" DATA_CONFIG = f"{DOMAIN}.config"
@@ -25,5 +23,3 @@ SAMPLES_PER_CHUNK = SAMPLE_RATE // (1000 // MS_PER_CHUNK) # 10 ms @ 16Khz
BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit
OPTION_PREFERRED = "preferred" OPTION_PREFERRED = "preferred"
ACKNOWLEDGE_PATH = Path(__file__).parent / "acknowledge.mp3"

View File

@@ -23,12 +23,7 @@ from homeassistant.components import conversation, stt, tts, wake_word, websocke
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import ( from homeassistant.helpers import chat_session, intent
chat_session,
device_registry as dr,
entity_registry as er,
intent,
)
from homeassistant.helpers.collection import ( from homeassistant.helpers.collection import (
CHANGE_UPDATED, CHANGE_UPDATED,
CollectionError, CollectionError,
@@ -50,7 +45,6 @@ from homeassistant.util.limited_size_dict import LimitedSizeDict
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
from .const import ( from .const import (
ACKNOWLEDGE_PATH,
BYTES_PER_CHUNK, BYTES_PER_CHUNK,
CONF_DEBUG_RECORDING_DIR, CONF_DEBUG_RECORDING_DIR,
DATA_CONFIG, DATA_CONFIG,
@@ -119,7 +113,6 @@ PIPELINE_FIELDS: VolDictType = {
vol.Required("wake_word_entity"): vol.Any(str, None), vol.Required("wake_word_entity"): vol.Any(str, None),
vol.Required("wake_word_id"): vol.Any(str, None), vol.Required("wake_word_id"): vol.Any(str, None),
vol.Optional("prefer_local_intents"): bool, vol.Optional("prefer_local_intents"): bool,
vol.Optional("acknowledge_media_id"): str,
} }
STORED_PIPELINE_RUNS = 10 STORED_PIPELINE_RUNS = 10
@@ -1073,11 +1066,8 @@ class PipelineRun:
intent_input: str, intent_input: str,
conversation_id: str, conversation_id: str,
conversation_extra_system_prompt: str | None, conversation_extra_system_prompt: str | None,
) -> tuple[str, bool]: ) -> str:
"""Run intent recognition portion of pipeline. """Run intent recognition portion of pipeline. Returns text to speak."""
Returns (speech, all_targets_in_satellite_area).
"""
if self.intent_agent is None or self._conversation_data is None: if self.intent_agent is None or self._conversation_data is None:
raise RuntimeError("Recognize intent was not prepared") raise RuntimeError("Recognize intent was not prepared")
@@ -1126,7 +1116,6 @@ class PipelineRun:
agent_id = self.intent_agent.id agent_id = self.intent_agent.id
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
all_targets_in_satellite_area = False
intent_response: intent.IntentResponse | None = None intent_response: intent.IntentResponse | None = None
if not processed_locally and not self._intent_agent_only: if not processed_locally and not self._intent_agent_only:
# Sentence triggers override conversation agent # Sentence triggers override conversation agent
@@ -1301,17 +1290,6 @@ class PipelineRun:
if tts_input_stream and self._streamed_response_text: if tts_input_stream and self._streamed_response_text:
tts_input_stream.put_nowait(None) tts_input_stream.put_nowait(None)
if agent_id == conversation.HOME_ASSISTANT_AGENT:
# Check if all targeted entities were in the same area as
# the satellite device.
# If so, the satellite should respond with an acknowledge beep
# instead of a full response.
all_targets_in_satellite_area = (
self._get_all_targets_in_satellite_area(
conversation_result.response, self._device_id
)
)
except Exception as src_error: except Exception as src_error:
_LOGGER.exception("Unexpected error during intent recognition") _LOGGER.exception("Unexpected error during intent recognition")
raise IntentRecognitionError( raise IntentRecognitionError(
@@ -1334,45 +1312,7 @@ class PipelineRun:
if conversation_result.continue_conversation: if conversation_result.continue_conversation:
self._conversation_data.continue_conversation_agent = agent_id self._conversation_data.continue_conversation_agent = agent_id
return (speech, all_targets_in_satellite_area) return speech
def _get_all_targets_in_satellite_area(
self, intent_response: intent.IntentResponse, device_id: str | None
) -> bool:
"""Return true if all targeted entities were in the same area as the device."""
if (
(intent_response.response_type != intent.IntentResponseType.ACTION_DONE)
or (not intent_response.matched_states)
or (not device_id)
):
return False
device_registry = dr.async_get(self.hass)
if (not (device := device_registry.async_get(device_id))) or (
not device.area_id
):
return False
entity_registry = er.async_get(self.hass)
for state in intent_response.matched_states:
entity = entity_registry.async_get(state.entity_id)
if not entity:
return False
if (entity_area_id := entity.area_id) is None:
if (entity.device_id is None) or (
(entity_device := device_registry.async_get(entity.device_id))
is None
):
return False
entity_area_id = entity_device.area_id
if entity_area_id != device.area_id:
return False
return True
async def prepare_text_to_speech(self) -> None: async def prepare_text_to_speech(self) -> None:
"""Prepare text-to-speech.""" """Prepare text-to-speech."""
@@ -1410,9 +1350,7 @@ class PipelineRun:
), ),
) from err ) from err
async def text_to_speech( async def text_to_speech(self, tts_input: str) -> None:
self, tts_input: str, override_media_path: Path | None = None
) -> None:
"""Run text-to-speech portion of pipeline.""" """Run text-to-speech portion of pipeline."""
assert self.tts_stream is not None assert self.tts_stream is not None
@@ -1424,14 +1362,11 @@ class PipelineRun:
"language": self.pipeline.tts_language, "language": self.pipeline.tts_language,
"voice": self.pipeline.tts_voice, "voice": self.pipeline.tts_voice,
"tts_input": tts_input, "tts_input": tts_input,
"acknowledge_override": override_media_path is not None,
}, },
) )
) )
if override_media_path: if not self._streamed_response_text:
self.tts_stream.async_override_result(override_media_path)
elif not self._streamed_response_text:
self.tts_stream.async_set_message(tts_input) self.tts_stream.async_set_message(tts_input)
tts_output = { tts_output = {
@@ -1729,20 +1664,16 @@ class PipelineInput:
if self.run.end_stage != PipelineStage.STT: if self.run.end_stage != PipelineStage.STT:
tts_input = self.tts_input tts_input = self.tts_input
all_targets_in_satellite_area = False
if current_stage == PipelineStage.INTENT: if current_stage == PipelineStage.INTENT:
# intent-recognition # intent-recognition
assert intent_input is not None assert intent_input is not None
( tts_input = await self.run.recognize_intent(
tts_input,
all_targets_in_satellite_area,
) = await self.run.recognize_intent(
intent_input, intent_input,
self.session.conversation_id, self.session.conversation_id,
self.conversation_extra_system_prompt, self.conversation_extra_system_prompt,
) )
if all_targets_in_satellite_area or tts_input.strip(): if tts_input.strip():
current_stage = PipelineStage.TTS current_stage = PipelineStage.TTS
else: else:
# Skip TTS # Skip TTS
@@ -1751,14 +1682,8 @@ class PipelineInput:
if self.run.end_stage != PipelineStage.INTENT: if self.run.end_stage != PipelineStage.INTENT:
# text-to-speech # text-to-speech
if current_stage == PipelineStage.TTS: if current_stage == PipelineStage.TTS:
if all_targets_in_satellite_area: assert tts_input is not None
# Use acknowledge media instead of full response await self.run.text_to_speech(tts_input)
await self.run.text_to_speech(
tts_input or "", override_media_path=ACKNOWLEDGE_PATH
)
else:
assert tts_input is not None
await self.run.text_to_speech(tts_input)
except PipelineError as err: except PipelineError as err:
self.run.process_event( self.run.process_event(

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import replace
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, Platform from homeassistant.const import EntityCategory, Platform
@@ -65,36 +64,15 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
translation_key="pipeline", translation_key="pipeline",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
) )
_attr_should_poll = False _attr_should_poll = False
_attr_current_option = OPTION_PREFERRED _attr_current_option = OPTION_PREFERRED
_attr_options = [OPTION_PREFERRED] _attr_options = [OPTION_PREFERRED]
def __init__( def __init__(self, hass: HomeAssistant, domain: str, unique_id_prefix: str) -> None:
self,
hass: HomeAssistant,
domain: str,
unique_id_prefix: str,
index: int = 0,
) -> None:
"""Initialize a pipeline selector.""" """Initialize a pipeline selector."""
if index < 1:
# Keep compatibility
key_suffix = ""
placeholder = ""
else:
key_suffix = f"_{index + 1}"
placeholder = f" {index + 1}"
self.entity_description = replace(
self.entity_description,
key=f"pipeline{key_suffix}",
translation_placeholders={"index": placeholder},
)
self._domain = domain self._domain = domain
self._unique_id_prefix = unique_id_prefix self._unique_id_prefix = unique_id_prefix
self._attr_unique_id = f"{unique_id_prefix}-{self.entity_description.key}" self._attr_unique_id = f"{unique_id_prefix}-pipeline"
self.hass = hass self.hass = hass
self._update_options() self._update_options()
@@ -109,7 +87,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
) )
state = await self.async_get_last_state() state = await self.async_get_last_state()
if (state is not None) and (state.state in self.options): if state is not None and state.state in self.options:
self._attr_current_option = state.state self._attr_current_option = state.state
if self.registry_entry and (device_id := self.registry_entry.device_id): if self.registry_entry and (device_id := self.registry_entry.device_id):
@@ -119,7 +97,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
def cleanup() -> None: def cleanup() -> None:
"""Clean up registered device.""" """Clean up registered device."""
pipeline_data.pipeline_devices.pop(device_id, None) pipeline_data.pipeline_devices.pop(device_id)
self.async_on_remove(cleanup) self.async_on_remove(cleanup)

View File

@@ -7,7 +7,7 @@
}, },
"select": { "select": {
"pipeline": { "pipeline": {
"name": "Assistant{index}", "name": "Assistant",
"state": { "state": {
"preferred": "Preferred" "preferred": "Preferred"
} }

View File

@@ -120,7 +120,6 @@ class AsusWrtBridge(ABC):
def __init__(self, host: str) -> None: def __init__(self, host: str) -> None:
"""Initialize Bridge.""" """Initialize Bridge."""
self._configuration_url = f"http://{host}"
self._host = host self._host = host
self._firmware: str | None = None self._firmware: str | None = None
self._label_mac: str | None = None self._label_mac: str | None = None
@@ -128,11 +127,6 @@ class AsusWrtBridge(ABC):
self._model_id: str | None = None self._model_id: str | None = None
self._serial_number: str | None = None self._serial_number: str | None = None
@property
def configuration_url(self) -> str:
"""Return configuration URL."""
return self._configuration_url
@property @property
def host(self) -> str: def host(self) -> str:
"""Return hostname.""" """Return hostname."""
@@ -377,7 +371,6 @@ class AsusWrtHttpBridge(AsusWrtBridge):
# get main router properties # get main router properties
if mac := _identity.mac: if mac := _identity.mac:
self._label_mac = format_mac(mac) self._label_mac = format_mac(mac)
self._configuration_url = self._api.webpanel
self._firmware = str(_identity.firmware) self._firmware = str(_identity.firmware)
self._model = _identity.model self._model = _identity.model
self._model_id = _identity.product_id self._model_id = _identity.product_id

View File

@@ -388,13 +388,13 @@ class AsusWrtRouter:
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return the device information.""" """Return the device information."""
info = DeviceInfo( info = DeviceInfo(
configuration_url=self._api.configuration_url,
identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")}, identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")},
name=self.host, name=self.host,
model=self._api.model or "Asus Router", model=self._api.model or "Asus Router",
model_id=self._api.model_id, model_id=self._api.model_id,
serial_number=self._api.serial_number, serial_number=self._api.serial_number,
manufacturer="Asus", manufacturer="Asus",
configuration_url=f"http://{self.host}",
) )
if self._api.firmware: if self._api.firmware:
info["sw_version"] = self._api.firmware info["sw_version"] = self._api.firmware

View File

@@ -2,12 +2,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Coroutine
import logging import logging
from typing import Any from typing import Any
from aiohttp import ClientResponseError from aiohttp import ClientResponseError
from yalexs.activity import ActivityType from yalexs.activity import ActivityType, ActivityTypes
from yalexs.lock import Lock, LockOperation, LockStatus from yalexs.lock import Lock, LockStatus
from yalexs.util import get_latest_activity, update_lock_detail_from_activity from yalexs.util import get_latest_activity, update_lock_detail_from_activity
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
@@ -49,25 +50,30 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None: async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device.""" """Lock the device."""
await self._perform_lock_operation(LockOperation.LOCK) if self._data.push_updates_connected:
await self._data.async_lock_async(self._device_id, self._hyper_bridge)
return
await self._call_lock_operation(self._data.async_lock)
async def async_open(self, **kwargs: Any) -> None: async def async_open(self, **kwargs: Any) -> None:
"""Open/unlatch the device.""" """Open/unlatch the device."""
await self._perform_lock_operation(LockOperation.OPEN) if self._data.push_updates_connected:
await self._data.async_unlatch_async(self._device_id, self._hyper_bridge)
return
await self._call_lock_operation(self._data.async_unlatch)
async def async_unlock(self, **kwargs: Any) -> None: async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device.""" """Unlock the device."""
await self._perform_lock_operation(LockOperation.UNLOCK) if self._data.push_updates_connected:
await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
return
await self._call_lock_operation(self._data.async_unlock)
async def _perform_lock_operation(self, operation: LockOperation) -> None: async def _call_lock_operation(
"""Perform a lock operation.""" self, lock_operation: Callable[[str], Coroutine[Any, Any, list[ActivityTypes]]]
) -> None:
try: try:
activities = await self._data.async_operate_lock( activities = await lock_operation(self._device_id)
self._device_id,
operation,
self._data.push_updates_connected,
self._hyper_bridge,
)
except ClientResponseError as err: except ClientResponseError as err:
if err.status == LOCK_JAMMED_ERR: if err.status == LOCK_JAMMED_ERR:
self._detail.lock_status = LockStatus.JAMMED self._detail.lock_status = LockStatus.JAMMED

View File

@@ -29,5 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"], "loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"] "requirements": ["yalexs==9.0.1", "yalexs-ble==3.1.2"]
} }

View File

@@ -92,11 +92,7 @@ from homeassistant.components.http.ban import (
from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.network import ( from homeassistant.helpers.network import is_cloud_connection
NoURLAvailableError,
get_url,
is_cloud_connection,
)
from homeassistant.util.network import is_local from homeassistant.util.network import is_local
from . import indieauth from . import indieauth
@@ -129,18 +125,11 @@ class WellKnownOAuthInfoView(HomeAssistantView):
async def get(self, request: web.Request) -> web.Response: async def get(self, request: web.Request) -> web.Response:
"""Return the well known OAuth2 authorization info.""" """Return the well known OAuth2 authorization info."""
hass = request.app[KEY_HASS]
# Some applications require absolute urls, so we prefer using the
# current requests url if possible, with fallback to a relative url.
try:
url_prefix = get_url(hass, require_current_request=True)
except NoURLAvailableError:
url_prefix = ""
return self.json( return self.json(
{ {
"authorization_endpoint": f"{url_prefix}/auth/authorize", "authorization_endpoint": "/auth/authorize",
"token_endpoint": f"{url_prefix}/auth/token", "token_endpoint": "/auth/token",
"revocation_endpoint": f"{url_prefix}/auth/revoke", "revocation_endpoint": "/auth/revoke",
"response_types_supported": ["code"], "response_types_supported": ["code"],
"service_documentation": ( "service_documentation": (
"https://developers.home-assistant.io/docs/auth_api" "https://developers.home-assistant.io/docs/auth_api"

View File

@@ -8,7 +8,7 @@ import threading
from typing import IO, cast from typing import IO, cast
from aiohttp import BodyPartReader from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION, CONTENT_TYPE from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.web import FileResponse, Request, Response, StreamResponse from aiohttp.web import FileResponse, Request, Response, StreamResponse
from multidict import istr from multidict import istr
@@ -76,8 +76,7 @@ class DownloadBackupView(HomeAssistantView):
return Response(status=HTTPStatus.NOT_FOUND) return Response(status=HTTPStatus.NOT_FOUND)
headers = { headers = {
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar", CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
CONTENT_TYPE: "application/x-tar",
} }
try: try:

View File

@@ -14,15 +14,15 @@
}, },
"automatic_backup_failed_addons": { "automatic_backup_failed_addons": {
"title": "Not all add-ons could be included in automatic backup", "title": "Not all add-ons could be included in automatic backup",
"description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." "description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
}, },
"automatic_backup_failed_agents_addons_folders": { "automatic_backup_failed_agents_addons_folders": {
"title": "Automatic backup was created with errors", "title": "Automatic backup was created with errors",
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." "description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the core and supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
}, },
"automatic_backup_failed_folders": { "automatic_backup_failed_folders": {
"title": "Not all folders could be included in automatic backup", "title": "Not all folders could be included in automatic backup",
"description": "Folders {failed_folders} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." "description": "Folders {failed_folders} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
} }
}, },
"services": { "services": {

View File

@@ -13,30 +13,20 @@ from bluecurrent_api.exceptions import (
RequestLimitReached, RequestLimitReached,
WebsocketError, WebsocketError,
) )
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ( from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
ConfigEntryAuthFailed,
ConfigEntryNotReady,
ServiceValidationError,
)
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
BCU_APP,
CHARGEPOINT_SETTINGS, CHARGEPOINT_SETTINGS,
CHARGEPOINT_STATUS, CHARGEPOINT_STATUS,
CHARGING_CARD_ID,
DOMAIN, DOMAIN,
EVSE_ID, EVSE_ID,
LOGGER, LOGGER,
PLUG_AND_CHARGE, PLUG_AND_CHARGE,
SERVICE_START_CHARGE_SESSION,
VALUE, VALUE,
) )
@@ -44,7 +34,6 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector]
PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
CHARGE_POINTS = "CHARGE_POINTS" CHARGE_POINTS = "CHARGE_POINTS"
CHARGE_CARDS = "CHARGE_CARDS"
DATA = "data" DATA = "data"
DELAY = 5 DELAY = 5
@@ -52,16 +41,6 @@ GRID = "GRID"
OBJECT = "object" OBJECT = "object"
VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS] VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE_ID): cv.string,
# When no charging card is provided, use no charging card (BCU_APP = no charging card).
vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string,
}
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
@@ -88,66 +67,6 @@ async def async_setup_entry(
return True return True
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Blue Current."""
async def start_charge_session(service_call: ServiceCall) -> None:
"""Start a charge session with the provided device and charge card ID."""
# When no charge card is provided, use the default charge card set in the config flow.
charging_card_id = service_call.data[CHARGING_CARD_ID]
device_id = service_call.data[CONF_DEVICE_ID]
# Get the device based on the given device ID.
device = dr.async_get(hass).devices.get(device_id)
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="invalid_device_id"
)
blue_current_config_entry: ConfigEntry | None = None
for config_entry_id in device.config_entries:
config_entry = hass.config_entries.async_get_entry(config_entry_id)
if not config_entry or config_entry.domain != DOMAIN:
# Not the blue_current config entry.
continue
if config_entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
)
blue_current_config_entry = config_entry
break
if not blue_current_config_entry:
# The device is not connected to a valid blue_current config entry.
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="no_config_entry"
)
connector = blue_current_config_entry.runtime_data
# Get the evse_id from the identifier of the device.
evse_id = next(
identifier[1]
for identifier in device.identifiers
if identifier[0] == DOMAIN
)
await connector.client.start_session(evse_id, charging_card_id)
hass.services.async_register(
DOMAIN,
SERVICE_START_CHARGE_SESSION,
start_charge_session,
SERVICE_START_CHARGE_SESSION_SCHEMA,
)
return True
async def async_unload_entry( async def async_unload_entry(
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
) -> bool: ) -> bool:
@@ -168,7 +87,6 @@ class Connector:
self.client = client self.client = client
self.charge_points: dict[str, dict] = {} self.charge_points: dict[str, dict] = {}
self.grid: dict[str, Any] = {} self.grid: dict[str, Any] = {}
self.charge_cards: dict[str, dict[str, Any]] = {}
async def on_data(self, message: dict) -> None: async def on_data(self, message: dict) -> None:
"""Handle received data.""" """Handle received data."""

View File

@@ -8,12 +8,6 @@ LOGGER = logging.getLogger(__package__)
EVSE_ID = "evse_id" EVSE_ID = "evse_id"
MODEL_TYPE = "model_type" MODEL_TYPE = "model_type"
CARD = "card"
UID = "uid"
BCU_APP = "BCU-APP"
WITHOUT_CHARGING_CARD = "without_charging_card"
CHARGING_CARD_ID = "charging_card_id"
SERVICE_START_CHARGE_SESSION = "start_charge_session"
PLUG_AND_CHARGE = "plug_and_charge" PLUG_AND_CHARGE = "plug_and_charge"
VALUE = "value" VALUE = "value"
PERMISSION = "permission" PERMISSION = "permission"

View File

@@ -42,10 +42,5 @@
"default": "mdi:lock" "default": "mdi:lock"
} }
} }
},
"services": {
"start_charge_session": {
"service": "mdi:play"
}
} }
} }

View File

@@ -1,12 +0,0 @@
start_charge_session:
fields:
device_id:
selector:
device:
integration: blue_current
required: true
charging_card_id:
selector:
text:
required: false

View File

@@ -22,16 +22,6 @@
"wrong_account": "Wrong account: Please authenticate with the API token for {email}." "wrong_account": "Wrong account: Please authenticate with the API token for {email}."
} }
}, },
"options": {
"step": {
"init": {
"data": {
"card": "Card"
},
"description": "Select the default charging card you want to use"
}
}
},
"entity": { "entity": {
"sensor": { "sensor": {
"activity": { "activity": {
@@ -146,39 +136,5 @@
"name": "Block charge point" "name": "Block charge point"
} }
} }
},
"selector": {
"select_charging_card": {
"options": {
"without_charging_card": "Without charging card"
}
}
},
"services": {
"start_charge_session": {
"name": "Start charge session",
"description": "Starts a new charge session on a specified charge point.",
"fields": {
"charging_card_id": {
"name": "Charging card ID",
"description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used."
},
"device_id": {
"name": "Device ID",
"description": "The ID of the Blue Current charge point."
}
}
}
},
"exceptions": {
"invalid_device_id": {
"message": "Invalid device ID given."
},
"config_entry_not_loaded": {
"message": "Config entry not loaded."
},
"no_config_entry": {
"message": "Device has not a valid blue_current config entry."
}
} }
} }

View File

@@ -10,7 +10,6 @@ from asyncio import Future
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
from habluetooth import ( from habluetooth import (
BaseHaScanner, BaseHaScanner,
BluetoothScannerDevice, BluetoothScannerDevice,
@@ -39,16 +38,13 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
@hass_callback @hass_callback
def async_get_scanner(hass: HomeAssistant) -> BleakScanner: def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
"""Return a HaBleakScannerWrapper cast to BleakScanner. """Return a HaBleakScannerWrapper.
This is a wrapper around our BleakScanner singleton that allows This is a wrapper around our BleakScanner singleton that allows
multiple integrations to share the same BleakScanner. multiple integrations to share the same BleakScanner.
The wrapper is cast to BleakScanner for type compatibility with
libraries expecting a BleakScanner instance.
""" """
return cast(BleakScanner, HaBleakScannerWrapper()) return HaBleakScannerWrapper()
@hass_callback @hass_callback

View File

@@ -18,9 +18,9 @@
"bleak==1.0.1", "bleak==1.0.1",
"bleak-retry-connector==4.4.3", "bleak-retry-connector==4.4.3",
"bluetooth-adapters==2.1.0", "bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3", "bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.2", "bluetooth-data-tools==1.28.2",
"dbus-fast==2.44.3", "dbus-fast==2.44.3",
"habluetooth==5.6.4" "habluetooth==5.6.2"
] ]
} }

View File

@@ -205,7 +205,6 @@ class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]
async def _async_update_data(self) -> dict[str, BringActivityData]: async def _async_update_data(self) -> dict[str, BringActivityData]:
"""Fetch activity data from bring.""" """Fetch activity data from bring."""
self.lists = self.coordinator.lists
list_dict: dict[str, BringActivityData] = {} list_dict: dict[str, BringActivityData] = {}
for lst in self.lists: for lst in self.lists:

View File

@@ -43,7 +43,7 @@ async def async_setup_entry(
) )
lists_added |= new_lists lists_added |= new_lists
coordinator.data.async_add_listener(add_entities) coordinator.activity.async_add_listener(add_entities)
add_entities() add_entities()
@@ -67,8 +67,7 @@ class BringEventEntity(BringBaseEntity, EventEntity):
def _async_handle_event(self) -> None: def _async_handle_event(self) -> None:
"""Handle the activity event.""" """Handle the activity event."""
if (bring_list := self.coordinator.data.get(self._list_uuid)) is None: bring_list = self.coordinator.data[self._list_uuid]
return
last_event_triggered = self.state last_event_triggered = self.state
if bring_list.activity.timeline and ( if bring_list.activity.timeline and (
last_event_triggered is None last_event_triggered is None

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome", "documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bthome-ble==3.14.2"] "requirements": ["bthome-ble==3.13.1"]
} }

View File

@@ -25,7 +25,6 @@ from homeassistant.const import (
DEGREE, DEGREE,
LIGHT_LUX, LIGHT_LUX,
PERCENTAGE, PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory, EntityCategory,
UnitOfConductivity, UnitOfConductivity,
@@ -270,15 +269,6 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=DEGREE, native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
# Rotational speed (rpm)
(
BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED,
Units.REVOLUTIONS_PER_MINUTE,
): SensorEntityDescription(
key=f"{BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED}_{Units.REVOLUTIONS_PER_MINUTE}",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
),
# Signal Strength (RSSI) (dB) # Signal Strength (RSSI) (dB)
( (
BTHomeSensorDeviceClass.SIGNAL_STRENGTH, BTHomeSensorDeviceClass.SIGNAL_STRENGTH,

View File

@@ -37,10 +37,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.loader import (
async_get_custom_components,
async_get_loaded_integration,
)
from homeassistant.util.location import async_detect_location_info from homeassistant.util.location import async_detect_location_info
from .alexa_config import entity_supported as entity_supported_by_alexa from .alexa_config import entity_supported as entity_supported_by_alexa
@@ -435,79 +431,6 @@ class DownloadSupportPackageView(HomeAssistantView):
url = "/api/cloud/support_package" url = "/api/cloud/support_package"
name = "api:cloud:support_package" name = "api:cloud:support_package"
async def _get_integration_info(self, hass: HomeAssistant) -> dict[str, Any]:
"""Collect information about active and custom integrations."""
# Get loaded components from hass.config.components
loaded_components = hass.config.components.copy()
# Get custom integrations
custom_domains = set()
with suppress(Exception):
custom_domains = set(await async_get_custom_components(hass))
# Separate built-in and custom integrations
builtin_integrations = []
custom_integrations = []
for domain in sorted(loaded_components):
try:
integration = async_get_loaded_integration(hass, domain)
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package
# generation. If we can't get integration info,
# just add the domain
if domain in custom_domains:
custom_integrations.append(
{
"domain": domain,
"name": "Unknown",
"version": "Unknown",
"documentation": "Unknown",
}
)
else:
builtin_integrations.append(
{
"domain": domain,
"name": "Unknown",
}
)
else:
if domain in custom_domains:
# This is a custom integration
# include version and documentation link
version = (
str(integration.version) if integration.version else "Unknown"
)
if not (documentation := integration.documentation):
documentation = "Unknown"
custom_integrations.append(
{
"domain": domain,
"name": integration.name,
"version": version,
"documentation": documentation,
}
)
else:
# This is a built-in integration.
# No version needed, as it is always the same as the
# Home Assistant version
builtin_integrations.append(
{
"domain": domain,
"name": integration.name,
}
)
return {
"builtin_count": len(builtin_integrations),
"builtin_integrations": builtin_integrations,
"custom_count": len(custom_integrations),
"custom_integrations": custom_integrations,
}
async def _generate_markdown( async def _generate_markdown(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
@@ -530,38 +453,6 @@ class DownloadSupportPackageView(HomeAssistantView):
markdown = "## System Information\n\n" markdown = "## System Information\n\n"
markdown += get_domain_table_markdown(hass_info) markdown += get_domain_table_markdown(hass_info)
# Add integration information
try:
integration_info = await self._get_integration_info(hass)
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
# If there's any error getting integration info, just note it
markdown += "## Active integrations\n\n"
markdown += "Unable to collect integration information\n\n"
else:
markdown += "## Active Integrations\n\n"
markdown += f"Built-in integrations: {integration_info['builtin_count']}\n"
markdown += f"Custom integrations: {integration_info['custom_count']}\n\n"
# Built-in integrations
if integration_info["builtin_integrations"]:
markdown += "<details><summary>Built-in integrations</summary>\n\n"
markdown += "Domain | Name\n"
markdown += "--- | ---\n"
for integration in integration_info["builtin_integrations"]:
markdown += f"{integration['domain']} | {integration['name']}\n"
markdown += "\n</details>\n\n"
# Custom integrations
if integration_info["custom_integrations"]:
markdown += "<details><summary>Custom integrations</summary>\n\n"
markdown += "Domain | Name | Version | Documentation\n"
markdown += "--- | --- | --- | ---\n"
for integration in integration_info["custom_integrations"]:
doc_url = integration.get("documentation") or "N/A"
markdown += f"{integration['domain']} | {integration['name']} | {integration['version']} | {doc_url}\n"
markdown += "\n</details>\n\n"
for domain, domain_info in domains_info.items(): for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info) domain_info_md = get_domain_table_markdown(domain_info)
markdown += ( markdown += (

View File

@@ -25,11 +25,7 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo
return await cloud.payments.subscription_info() return await cloud.payments.subscription_info()
except PaymentsApiError as exception: except PaymentsApiError as exception:
_LOGGER.error("Failed to fetch subscription information - %s", exception) _LOGGER.error("Failed to fetch subscription information - %s", exception)
except TimeoutError:
_LOGGER.error(
"A timeout of %s was reached while trying to fetch subscription information",
REQUEST_TIMEOUT,
)
return None return None

View File

@@ -29,23 +29,10 @@ async def async_setup_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
known_devices: set[int] = set() async_add_entities(
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
def _check_device() -> None: for device in coordinator.data["alarm_zones"].values()
current_devices = set(coordinator.data["alarm_zones"]) )
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitVedoBinarySensorEntity(
coordinator, device, config_entry.entry_id
)
for device in coordinator.data["alarm_zones"].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitVedoBinarySensorEntity( class ComelitVedoBinarySensorEntity(

View File

@@ -2,7 +2,7 @@
from abc import abstractmethod from abc import abstractmethod
from datetime import timedelta from datetime import timedelta
from typing import Any, TypeVar from typing import TypeVar
from aiocomelit.api import ( from aiocomelit.api import (
AlarmDataObject, AlarmDataObject,
@@ -13,16 +13,7 @@ from aiocomelit.api import (
ComelitVedoAreaObject, ComelitVedoAreaObject,
ComelitVedoZoneObject, ComelitVedoZoneObject,
) )
from aiocomelit.const import ( from aiocomelit.const import BRIDGE, VEDO
BRIDGE,
CLIMATE,
COVER,
IRRIGATION,
LIGHT,
OTHER,
SCENARIO,
VEDO,
)
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession from aiohttp import ClientSession
@@ -120,32 +111,6 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
async def _async_update_system_data(self) -> T: async def _async_update_system_data(self) -> T:
"""Class method for updating data.""" """Class method for updating data."""
async def _async_remove_stale_devices(
self,
previous_list: dict[int, Any],
current_list: dict[int, Any],
dev_type: str,
) -> None:
"""Remove stale devices."""
device_registry = dr.async_get(self.hass)
for i in previous_list:
if i not in current_list:
_LOGGER.debug(
"Detected change in %s devices: index %s removed",
dev_type,
i,
)
identifier = f"{self.config_entry.entry_id}-{dev_type}-{i}"
device = device_registry.async_get_device(
identifiers={(DOMAIN, identifier)}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
class ComelitSerialBridge( class ComelitSerialBridge(
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]] ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
@@ -172,15 +137,7 @@ class ComelitSerialBridge(
self, self,
) -> dict[str, dict[int, ComelitSerialBridgeObject]]: ) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
"""Specific method for updating data.""" """Specific method for updating data."""
data = await self.api.get_all_devices() return await self.api.get_all_devices()
if self.data:
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
await self._async_remove_stale_devices(
self.data[dev_type], data[dev_type], dev_type
)
return data
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
@@ -206,14 +163,4 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
self, self,
) -> AlarmDataObject: ) -> AlarmDataObject:
"""Specific method for updating data.""" """Specific method for updating data."""
data = await self.api.get_all_areas_and_zones() return await self.api.get_all_areas_and_zones()
if self.data:
for obj_type in ("alarm_areas", "alarm_zones"):
await self._async_remove_stale_devices(
self.data[obj_type],
data[obj_type],
"area" if obj_type == "alarm_areas" else "zone",
)
return data

View File

@@ -29,21 +29,10 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
known_devices: set[int] = set() async_add_entities(
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
def _check_device() -> None: for device in coordinator.data[COVER].values()
current_devices = set(coordinator.data[COVER]) )
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[COVER].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):

View File

@@ -27,21 +27,10 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
known_devices: set[int] = set() async_add_entities(
ComelitLightEntity(coordinator, device, config_entry.entry_id)
def _check_device() -> None: for device in coordinator.data[LIGHT].values()
current_devices = set(coordinator.data[LIGHT]) )
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitLightEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[LIGHT].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):

View File

@@ -57,7 +57,9 @@ rules:
docs-supported-functions: done docs-supported-functions: done
docs-troubleshooting: done docs-troubleshooting: done
docs-use-cases: done docs-use-cases: done
dynamic-devices: done dynamic-devices:
status: todo
comment: missing implementation
entity-category: entity-category:
status: exempt status: exempt
comment: no config or diagnostic entities comment: no config or diagnostic entities
@@ -70,7 +72,9 @@ rules:
repair-issues: repair-issues:
status: exempt status: exempt
comment: no known use cases for repair issues or flows, yet comment: no known use cases for repair issues or flows, yet
stale-devices: done stale-devices:
status: todo
comment: missing implementation
# Platinum # Platinum
async-dependency: done async-dependency: done

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Final, cast from typing import Final, cast
from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -65,24 +65,15 @@ async def async_setup_bridge_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
known_devices: set[int] = set() entities: list[ComelitBridgeSensorEntity] = []
for device in coordinator.data[OTHER].values():
def _check_device() -> None: entities.extend(
current_devices = set(coordinator.data[OTHER]) ComelitBridgeSensorEntity(
new_devices = current_devices - known_devices coordinator, device, config_entry.entry_id, sensor_desc
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_BRIDGE_TYPES
for device in coordinator.data[OTHER].values()
if device.index in new_devices
) )
for sensor_desc in SENSOR_BRIDGE_TYPES
_check_device() )
config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) async_add_entities(entities)
async def async_setup_vedo_entry( async def async_setup_vedo_entry(
@@ -94,24 +85,15 @@ async def async_setup_vedo_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
known_devices: set[int] = set() entities: list[ComelitVedoSensorEntity] = []
for device in coordinator.data["alarm_zones"].values():
def _check_device() -> None: entities.extend(
current_devices = set(coordinator.data["alarm_zones"]) ComelitVedoSensorEntity(
new_devices = current_devices - known_devices coordinator, device, config_entry.entry_id, sensor_desc
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_VEDO_TYPES
for device in coordinator.data["alarm_zones"].values()
if device.index in new_devices
) )
for sensor_desc in SENSOR_VEDO_TYPES
_check_device() )
config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) async_add_entities(entities)
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):

View File

@@ -39,25 +39,6 @@ async def async_setup_entry(
) )
async_add_entities(entities) async_add_entities(entities)
known_devices: dict[str, set[int]] = {
dev_type: set() for dev_type in (IRRIGATION, OTHER)
}
def _check_device() -> None:
for dev_type in (IRRIGATION, OTHER):
current_devices = set(coordinator.data[dev_type])
new_devices = current_devices - known_devices[dev_type]
if new_devices:
known_devices[dev_type].update(new_devices)
async_add_entities(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
"""Switch device.""" """Switch device."""

View File

@@ -1,45 +0,0 @@
"""The Compit integration."""
from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PLATFORMS = [
Platform.CLIMATE,
]
async def async_setup_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
"""Set up Compit from a config entry."""
session = async_get_clientsession(hass)
connector = CompitApiConnector(session)
try:
connected = await connector.init(
entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], hass.config.language
)
except CannotConnect as e:
raise ConfigEntryNotReady(f"Error while connecting to Compit: {e}") from e
except InvalidAuth as e:
raise ConfigEntryAuthFailed(
f"Invalid credentials for {entry.data[CONF_EMAIL]}"
) from e
if not connected:
raise ConfigEntryAuthFailed("Authentication API error")
coordinator = CompitDataUpdateCoordinator(hass, entry, connector)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
"""Unload an entry for the Compit integration."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,265 +0,0 @@
"""Module contains the CompitClimate class for controlling climate entities."""
import logging
from typing import Any
from compit_inext_api import Param, Parameter
from compit_inext_api.consts import (
CompitFanMode,
CompitHVACMode,
CompitParameter,
CompitPresetMode,
)
from propcache.api import cached_property
from homeassistant.components.climate import (
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_OFF,
PRESET_AWAY,
PRESET_ECO,
PRESET_HOME,
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER_NAME
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
_LOGGER: logging.Logger = logging.getLogger(__name__)
# Device class for climate devices in Compit system
CLIMATE_DEVICE_CLASS = 10
PARALLEL_UPDATES = 0
COMPIT_MODE_MAP = {
CompitHVACMode.COOL: HVACMode.COOL,
CompitHVACMode.HEAT: HVACMode.HEAT,
CompitHVACMode.OFF: HVACMode.OFF,
}
COMPIT_FANSPEED_MAP = {
CompitFanMode.OFF: FAN_OFF,
CompitFanMode.AUTO: FAN_AUTO,
CompitFanMode.LOW: FAN_LOW,
CompitFanMode.MEDIUM: FAN_MEDIUM,
CompitFanMode.HIGH: FAN_HIGH,
CompitFanMode.HOLIDAY: FAN_AUTO,
}
COMPIT_PRESET_MAP = {
CompitPresetMode.AUTO: PRESET_HOME,
CompitPresetMode.HOLIDAY: PRESET_ECO,
CompitPresetMode.MANUAL: PRESET_NONE,
CompitPresetMode.AWAY: PRESET_AWAY,
}
HVAC_MODE_TO_COMPIT_MODE = {v: k for k, v in COMPIT_MODE_MAP.items()}
FAN_MODE_TO_COMPIT_FAN_MODE = {v: k for k, v in COMPIT_FANSPEED_MAP.items()}
PRESET_MODE_TO_COMPIT_PRESET_MODE = {v: k for k, v in COMPIT_PRESET_MAP.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: CompitConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CompitClimate platform from a config entry."""
coordinator = entry.runtime_data
climate_entities = []
for device_id in coordinator.connector.all_devices:
device = coordinator.connector.all_devices[device_id]
if device.definition.device_class == CLIMATE_DEVICE_CLASS:
climate_entities.append(
CompitClimate(
coordinator,
device_id,
{
parameter.parameter_code: parameter
for parameter in device.definition.parameters
},
device.definition.name,
)
)
async_add_devices(climate_entities)
class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntity):
"""Representation of a Compit climate device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [*COMPIT_MODE_MAP.values()]
_attr_name = None
_attr_has_entity_name = True
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
)
def __init__(
self,
coordinator: CompitDataUpdateCoordinator,
device_id: int,
parameters: dict[str, Parameter],
device_name: str,
) -> None:
"""Initialize the climate device."""
super().__init__(coordinator)
self._attr_unique_id = f"{device_name}_{device_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(device_id))},
name=device_name,
manufacturer=MANUFACTURER_NAME,
model=device_name,
)
self.parameters = parameters
self.device_id = device_id
self.available_presets: Parameter | None = self.parameters.get(
CompitParameter.PRESET_MODE.value
)
self.available_fan_modes: Parameter | None = self.parameters.get(
CompitParameter.FAN_MODE.value
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.device_id in self.coordinator.connector.all_devices
)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE)
if value is None:
return None
return float(value.value)
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE)
if value is None:
return None
return float(value.value)
@cached_property
def preset_modes(self) -> list[str] | None:
"""Return the available preset modes."""
if self.available_presets is None or self.available_presets.details is None:
return []
preset_modes = []
for item in self.available_presets.details:
if item is not None:
ha_preset = COMPIT_PRESET_MAP.get(CompitPresetMode(item.state))
if ha_preset and ha_preset not in preset_modes:
preset_modes.append(ha_preset)
return preset_modes
@cached_property
def fan_modes(self) -> list[str] | None:
"""Return the available fan modes."""
if self.available_fan_modes is None or self.available_fan_modes.details is None:
return []
fan_modes = []
for item in self.available_fan_modes.details:
if item is not None:
ha_fan_mode = COMPIT_FANSPEED_MAP.get(CompitFanMode(item.state))
if ha_fan_mode and ha_fan_mode not in fan_modes:
fan_modes.append(ha_fan_mode)
return fan_modes
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
if preset_mode:
compit_preset_mode = CompitPresetMode(preset_mode.value)
return COMPIT_PRESET_MAP.get(compit_preset_mode)
return None
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
if fan_mode:
compit_fan_mode = CompitFanMode(fan_mode.value)
return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
return None
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
if hvac_mode:
compit_hvac_mode = CompitHVACMode(hvac_mode.value)
return COMPIT_MODE_MAP.get(compit_hvac_mode)
return None
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temp = kwargs.get(ATTR_TEMPERATURE)
if temp is None:
raise ServiceValidationError("Temperature argument missing")
await self.set_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE, temp)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target HVAC mode."""
if not (mode := HVAC_MODE_TO_COMPIT_MODE.get(hvac_mode)):
raise ServiceValidationError(f"Invalid hvac mode {hvac_mode}")
await self.set_parameter_value(CompitParameter.HVAC_MODE, mode.value)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target preset mode."""
compit_preset = PRESET_MODE_TO_COMPIT_PRESET_MODE.get(preset_mode)
if compit_preset is None:
raise ServiceValidationError(f"Invalid preset mode: {preset_mode}")
await self.set_parameter_value(CompitParameter.PRESET_MODE, compit_preset.value)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
compit_fan_mode = FAN_MODE_TO_COMPIT_FAN_MODE.get(fan_mode)
if compit_fan_mode is None:
raise ServiceValidationError(f"Invalid fan mode: {fan_mode}")
await self.set_parameter_value(CompitParameter.FAN_MODE, compit_fan_mode.value)
async def set_parameter_value(self, parameter: CompitParameter, value: int) -> None:
"""Call the API to set a parameter to a new value."""
await self.coordinator.connector.set_device_parameter(
self.device_id, parameter, value
)
self.async_write_ha_state()
def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
"""Get the parameter value from the device state."""
return self.coordinator.connector.get_device_parameter(
self.device_id, parameter
)

View File

@@ -1,110 +0,0 @@
"""Config flow for Compit integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)
STEP_REAUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
class CompitConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Compit."""
VERSION = 1
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_create_clientsession(self.hass)
api = CompitApiConnector(session)
success = False
try:
success = await api.init(
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
self.hass.config.language,
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if not success:
# Api returned unexpected result but no exception
_LOGGER.error("Compit api returned unexpected result")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_EMAIL])
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult:
"""Handle re-auth."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm re-authentication."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
reauth_entry_data = reauth_entry.data
if user_input:
# Reuse async_step_user with combined credentials
return await self.async_step_user(
{
CONF_EMAIL: reauth_entry_data[CONF_EMAIL],
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_SCHEMA,
description_placeholders={CONF_EMAIL: reauth_entry_data[CONF_EMAIL]},
errors=errors,
)

View File

@@ -1,4 +0,0 @@
"""Constants for the Compit integration."""
DOMAIN = "compit"
MANUFACTURER_NAME = "Compit"

View File

@@ -1,43 +0,0 @@
"""Define an object to manage fetching Compit data."""
from datetime import timedelta
import logging
from compit_inext_api import CompitApiConnector, DeviceInstance
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER: logging.Logger = logging.getLogger(__name__)
type CompitConfigEntry = ConfigEntry[CompitDataUpdateCoordinator]
class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance]]):
"""Class to manage fetching data from the API."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
connector: CompitApiConnector,
) -> None:
"""Initialize."""
self.connector = connector
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
async def _async_update_data(self) -> dict[int, DeviceInstance]:
"""Update data via library."""
await self.connector.update_state(device_id=None) # Update all devices
return self.connector.all_devices

View File

@@ -1,12 +0,0 @@
{
"domain": "compit",
"name": "Compit",
"codeowners": ["@Przemko92"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/compit",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.3.1"]
}

View File

@@ -1,86 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules:
status: exempt
comment: |
This integration does not use any common modules.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration is a cloud service and does not support discovery.
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
This integration does not have any entities that should disabled by default.
entity-translations: done
exception-translations: todo
icon-translations:
status: exempt
comment: |
There is no need for icon translations.
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: done

View File

@@ -1,35 +0,0 @@
{
"config": {
"step": {
"user": {
"description": "Please enter your https://inext.compit.pl/ credentials.",
"title": "Connect to Compit iNext",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "The email address of your inext.compit.pl account",
"password": "The password of your inext.compit.pl account"
}
},
"reauth_confirm": {
"description": "Please update your password for {email}",
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::compit::config::step::user::data_description::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View File

@@ -50,13 +50,14 @@ from .const import (
ATTR_LANGUAGE, ATTR_LANGUAGE,
ATTR_TEXT, ATTR_TEXT,
DATA_COMPONENT, DATA_COMPONENT,
DATA_DEFAULT_ENTITY,
DOMAIN, DOMAIN,
HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT,
SERVICE_PROCESS, SERVICE_PROCESS,
SERVICE_RELOAD, SERVICE_RELOAD,
ConversationEntityFeature, ConversationEntityFeature,
) )
from .default_agent import async_setup_default_agent from .default_agent import DefaultAgent, async_setup_default_agent
from .entity import ConversationEntity from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult from .models import AbstractConversationAgent, ConversationInput, ConversationResult
@@ -141,7 +142,7 @@ def async_unset_agent(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
) -> None: ) -> None:
"""Unset the agent to handle the conversations.""" """Set the agent to handle the conversations."""
get_agent_manager(hass).async_unset_agent(config_entry.entry_id) get_agent_manager(hass).async_unset_agent(config_entry.entry_id)
@@ -240,10 +241,10 @@ async def async_handle_sentence_triggers(
Returns None if no match occurred. Returns None if no match occurred.
""" """
agent = get_agent_manager(hass).default_agent default_agent = async_get_agent(hass)
assert agent is not None assert isinstance(default_agent, DefaultAgent)
return await agent.async_handle_sentence_triggers(user_input) return await default_agent.async_handle_sentence_triggers(user_input)
async def async_handle_intents( async def async_handle_intents(
@@ -256,10 +257,12 @@ async def async_handle_intents(
Returns None if no match occurred. Returns None if no match occurred.
""" """
agent = get_agent_manager(hass).default_agent default_agent = async_get_agent(hass)
assert agent is not None assert isinstance(default_agent, DefaultAgent)
return await agent.async_handle_intents(user_input, intent_filter=intent_filter) return await default_agent.async_handle_intents(
user_input, intent_filter=intent_filter
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -295,9 +298,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None: async def handle_reload(service: ServiceCall) -> None:
"""Reload intents.""" """Reload intents."""
agent = get_agent_manager(hass).default_agent await hass.data[DATA_DEFAULT_ENTITY].async_reload(
if agent is not None: language=service.data.get(ATTR_LANGUAGE)
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) )
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,

View File

@@ -4,21 +4,15 @@ from __future__ import annotations
import dataclasses import dataclasses
import logging import logging
from typing import TYPE_CHECKING, Any from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.core import ( from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
CALLBACK_TYPE,
Context,
HomeAssistant,
async_get_hass,
callback,
)
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent, singleton from homeassistant.helpers import config_validation as cv, intent, singleton
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT
from .entity import ConversationEntity from .entity import ConversationEntity
from .models import ( from .models import (
AbstractConversationAgent, AbstractConversationAgent,
@@ -34,10 +28,6 @@ from .trace import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .default_agent import DefaultAgent
from .trigger import TriggerDetails
@singleton.singleton("conversation_agent") @singleton.singleton("conversation_agent")
@callback @callback
@@ -59,10 +49,8 @@ def async_get_agent(
hass: HomeAssistant, agent_id: str | None = None hass: HomeAssistant, agent_id: str | None = None
) -> AbstractConversationAgent | ConversationEntity | None: ) -> AbstractConversationAgent | ConversationEntity | None:
"""Get specified agent.""" """Get specified agent."""
manager = get_agent_manager(hass)
if agent_id is None or agent_id == HOME_ASSISTANT_AGENT: if agent_id is None or agent_id == HOME_ASSISTANT_AGENT:
return manager.default_agent return hass.data[DATA_DEFAULT_ENTITY]
if "." in agent_id: if "." in agent_id:
return hass.data[DATA_COMPONENT].get_entity(agent_id) return hass.data[DATA_COMPONENT].get_entity(agent_id)
@@ -146,8 +134,6 @@ class AgentManager:
"""Initialize the conversation agents.""" """Initialize the conversation agents."""
self.hass = hass self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {} self._agents: dict[str, AbstractConversationAgent] = {}
self.default_agent: DefaultAgent | None = None
self.triggers_details: list[TriggerDetails] = []
@callback @callback
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None: def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
@@ -196,23 +182,3 @@ class AgentManager:
def async_unset_agent(self, agent_id: str) -> None: def async_unset_agent(self, agent_id: str) -> None:
"""Unset the agent.""" """Unset the agent."""
self._agents.pop(agent_id, None) self._agents.pop(agent_id, None)
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
"""Set up the default agent."""
agent.update_triggers(self.triggers_details)
self.default_agent = agent
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
"""Register a trigger."""
self.triggers_details.append(trigger_details)
if self.default_agent is not None:
self.default_agent.update_triggers(self.triggers_details)
@callback
def unregister_trigger() -> None:
"""Unregister the trigger."""
self.triggers_details.remove(trigger_details)
if self.default_agent is not None:
self.default_agent.update_triggers(self.triggers_details)
return unregister_trigger

View File

@@ -10,9 +10,11 @@ from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from .default_agent import DefaultAgent
from .entity import ConversationEntity from .entity import ConversationEntity
DOMAIN = "conversation" DOMAIN = "conversation"
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
HOME_ASSISTANT_AGENT = "conversation.home_assistant" HOME_ASSISTANT_AGENT = "conversation.home_assistant"
ATTR_TEXT = "text" ATTR_TEXT = "text"
@@ -24,6 +26,7 @@ SERVICE_PROCESS = "process"
SERVICE_RELOAD = "reload" SERVICE_RELOAD = "reload"
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN) DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity")
class ConversationEntityFeature(IntFlag): class ConversationEntityFeature(IntFlag):

View File

@@ -4,11 +4,13 @@ from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, auto from enum import Enum, auto
import functools
import logging import logging
from pathlib import Path from pathlib import Path
import re
import time import time
from typing import IO, Any, cast from typing import IO, Any, cast
@@ -51,7 +53,6 @@ from homeassistant.components.homeassistant.exposed_entities import (
async_should_expose, async_should_expose,
) )
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
from homeassistant.core import Event, callback
from homeassistant.helpers import ( from homeassistant.helpers import (
area_registry as ar, area_registry as ar,
device_registry as dr, device_registry as dr,
@@ -67,22 +68,25 @@ from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util import language as language_util from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog from .chat_log import AssistantContent, ChatLog
from .const import DOMAIN, ConversationEntityFeature from .const import (
DATA_DEFAULT_ENTITY,
DEFAULT_EXPOSED_ATTRIBUTES,
DOMAIN,
ConversationEntityFeature,
)
from .entity import ConversationEntity from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append from .trace import ConversationTraceEventType, async_conversation_trace_append
from .trigger import TriggerDetails
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} REGEX_TYPE = type(re.compile(""))
TRIGGER_CALLBACK_TYPE = Callable[
[ConversationInput, RecognizeResult], Awaitable[str | None]
]
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file" METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match" METADATA_FUZZY_MATCH = "hass_fuzzy_match"
@@ -108,6 +112,14 @@ class LanguageIntents:
fuzzy_responses: FuzzyLanguageResponses | None = None fuzzy_responses: FuzzyLanguageResponses | None = None
@dataclass(slots=True)
class TriggerData:
"""List of sentences and the callback for a trigger."""
sentences: list[str]
callback: TRIGGER_CALLBACK_TYPE
@dataclass(slots=True) @dataclass(slots=True)
class SentenceTriggerResult: class SentenceTriggerResult:
"""Result when matching a sentence trigger in an automation.""" """Result when matching a sentence trigger in an automation."""
@@ -143,8 +155,8 @@ class IntentCacheKey:
language: str language: str
"""Language of text.""" """Language of text."""
satellite_id: str | None device_id: str | None
"""Satellite id from user input.""" """Device id from user input."""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -197,9 +209,9 @@ async def async_setup_default_agent(
config_intents: dict[str, Any], config_intents: dict[str, Any],
) -> None: ) -> None:
"""Set up entity registry listener for the default agent.""" """Set up entity registry listener for the default agent."""
agent = DefaultAgent(hass, config_intents) entity = DefaultAgent(hass, config_intents)
await entity_component.async_add_entities([agent]) await entity_component.async_add_entities([entity])
await get_agent_manager(hass).async_setup_default_agent(agent) hass.data[DATA_DEFAULT_ENTITY] = entity
@core.callback @core.callback
def async_entity_state_listener( def async_entity_state_listener(
@@ -230,23 +242,21 @@ class DefaultAgent(ConversationEntity):
"""Initialize the default agent.""" """Initialize the default agent."""
self.hass = hass self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {} self._lang_intents: dict[str, LanguageIntents | object] = {}
self._load_intents_lock = asyncio.Lock()
# intent -> [sentences] # intent -> [sentences]
self._config_intents: dict[str, Any] = config_intents self._config_intents: dict[str, Any] = config_intents
# Sentences that will trigger a callback (skipping intent recognition)
self._triggers_details: list[TriggerDetails] = []
self._trigger_intents: Intents | None = None
# Slot lists for entities, areas, etc.
self._slot_lists: dict[str, SlotList] | None = None self._slot_lists: dict[str, SlotList] | None = None
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
# Used to filter slot lists before intent matching # Used to filter slot lists before intent matching
self._exposed_names_trie: Trie | None = None self._exposed_names_trie: Trie | None = None
self._unexposed_names_trie: Trie | None = None self._unexposed_names_trie: Trie | None = None
# Sentences that will trigger a callback (skipping intent recognition)
self.trigger_sentences: list[TriggerData] = []
self._trigger_intents: Intents | None = None
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
self._load_intents_lock = asyncio.Lock()
# LRU cache to avoid unnecessary intent matching # LRU cache to avoid unnecessary intent matching
self._intent_cache = IntentCache(capacity=128) self._intent_cache = IntentCache(capacity=128)
@@ -435,15 +445,9 @@ class DefaultAgent(ConversationEntity):
} }
for entity in result.entities_list for entity in result.entities_list
} }
device_area = self._get_device_area(user_input.device_id)
satellite_id = user_input.satellite_id if device_area:
device_id = user_input.device_id slots["preferred_area_id"] = {"value": device_area.id}
satellite_area, device_id = self._get_satellite_area_and_device(
satellite_id, device_id
)
if satellite_area is not None:
slots["preferred_area_id"] = {"value": satellite_area.id}
async_conversation_trace_append( async_conversation_trace_append(
ConversationTraceEventType.TOOL_CALL, ConversationTraceEventType.TOOL_CALL,
{ {
@@ -465,8 +469,8 @@ class DefaultAgent(ConversationEntity):
user_input.context, user_input.context,
language, language,
assistant=DOMAIN, assistant=DOMAIN,
device_id=device_id, device_id=user_input.device_id,
satellite_id=satellite_id, satellite_id=user_input.satellite_id,
conversation_agent_id=user_input.agent_id, conversation_agent_id=user_input.agent_id,
) )
except intent.MatchFailedError as match_error: except intent.MatchFailedError as match_error:
@@ -532,9 +536,7 @@ class DefaultAgent(ConversationEntity):
# Try cache first # Try cache first
cache_key = IntentCacheKey( cache_key = IntentCacheKey(
text=user_input.text, text=user_input.text, language=language, device_id=user_input.device_id
language=language,
satellite_id=user_input.satellite_id,
) )
cache_value = self._intent_cache.get(cache_key) cache_value = self._intent_cache.get(cache_key)
if cache_value is not None: if cache_value is not None:
@@ -844,7 +846,7 @@ class DefaultAgent(ConversationEntity):
context = {"domain": state.domain} context = {"domain": state.domain}
if state.attributes: if state.attributes:
# Include some attributes # Include some attributes
for attr in _DEFAULT_EXPOSED_ATTRIBUTES: for attr in DEFAULT_EXPOSED_ATTRIBUTES:
if attr not in state.attributes: if attr not in state.attributes:
continue continue
context[attr] = state.attributes[attr] context[attr] = state.attributes[attr]
@@ -1190,8 +1192,8 @@ class DefaultAgent(ConversationEntity):
fuzzy_responses=fuzzy_responses, fuzzy_responses=fuzzy_responses,
) )
@callback @core.callback
def _async_clear_slot_list(self, event: Event[Any] | None = None) -> None: def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None:
"""Clear slot lists when a registry has changed.""" """Clear slot lists when a registry has changed."""
# Two subscribers can be scheduled at same time # Two subscribers can be scheduled at same time
_LOGGER.debug("Clearing slot lists") _LOGGER.debug("Clearing slot lists")
@@ -1304,40 +1306,28 @@ class DefaultAgent(ConversationEntity):
self, user_input: ConversationInput self, user_input: ConversationInput
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Return intent recognition context for user input.""" """Return intent recognition context for user input."""
satellite_area, _ = self._get_satellite_area_and_device( if not user_input.device_id:
user_input.satellite_id, user_input.device_id
)
if satellite_area is None:
return None return None
return {"area": {"value": satellite_area.name, "text": satellite_area.name}} device_area = self._get_device_area(user_input.device_id)
if device_area is None:
return None
def _get_satellite_area_and_device( return {"area": {"value": device_area.name, "text": device_area.name}}
self, satellite_id: str | None, device_id: str | None = None
) -> tuple[ar.AreaEntry | None, str | None]:
"""Return area entry and device id."""
hass = self.hass
area_id: str | None = None def _get_device_area(self, device_id: str | None) -> ar.AreaEntry | None:
"""Return area object for given device identifier."""
if device_id is None:
return None
if ( devices = dr.async_get(self.hass)
satellite_id is not None device = devices.async_get(device_id)
and (entity_entry := er.async_get(hass).async_get(satellite_id)) is not None if (device is None) or (device.area_id is None):
): return None
area_id = entity_entry.area_id
device_id = entity_entry.device_id
if ( areas = ar.async_get(self.hass)
area_id is None
and device_id is not None
and (device_entry := dr.async_get(hass).async_get(device_id)) is not None
):
area_id = device_entry.area_id
if area_id is None: return areas.async_get_area(device.area_id)
return None, device_id
return ar.async_get(hass).async_get_area(area_id), device_id
def _get_error_text( def _get_error_text(
self, self,
@@ -1361,14 +1351,22 @@ class DefaultAgent(ConversationEntity):
return response_template.async_render(response_args) return response_template.async_render(response_args)
@callback @core.callback
def update_triggers(self, triggers_details: list[TriggerDetails]) -> None: def register_trigger(
"""Update triggers.""" self,
self._triggers_details = triggers_details sentences: list[str],
callback: TRIGGER_CALLBACK_TYPE,
) -> core.CALLBACK_TYPE:
"""Register a list of sentences that will trigger a callback when recognized."""
trigger_data = TriggerData(sentences=sentences, callback=callback)
self.trigger_sentences.append(trigger_data)
# Force rebuild on next use # Force rebuild on next use
self._trigger_intents = None self._trigger_intents = None
return functools.partial(self._unregister_trigger, trigger_data)
@core.callback
def _rebuild_trigger_intents(self) -> None: def _rebuild_trigger_intents(self) -> None:
"""Rebuild the HassIL intents object from the current trigger sentences.""" """Rebuild the HassIL intents object from the current trigger sentences."""
intents_dict = { intents_dict = {
@@ -1377,8 +1375,8 @@ class DefaultAgent(ConversationEntity):
# Use trigger data index as a virtual intent name for HassIL. # Use trigger data index as a virtual intent name for HassIL.
# This works because the intents are rebuilt on every # This works because the intents are rebuilt on every
# register/unregister. # register/unregister.
str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]} str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]}
for trigger_id, trigger_details in enumerate(self._triggers_details) for trigger_id, trigger_data in enumerate(self.trigger_sentences)
}, },
} }
@@ -1398,6 +1396,14 @@ class DefaultAgent(ConversationEntity):
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict) _LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
@core.callback
def _unregister_trigger(self, trigger_data: TriggerData) -> None:
"""Unregister a set of trigger sentences."""
self.trigger_sentences.remove(trigger_data)
# Force rebuild on next use
self._trigger_intents = None
async def async_recognize_sentence_trigger( async def async_recognize_sentence_trigger(
self, user_input: ConversationInput self, user_input: ConversationInput
) -> SentenceTriggerResult | None: ) -> SentenceTriggerResult | None:
@@ -1406,7 +1412,7 @@ class DefaultAgent(ConversationEntity):
Calls the registered callbacks if there's a match and returns a sentence Calls the registered callbacks if there's a match and returns a sentence
trigger result. trigger result.
""" """
if not self._triggers_details: if not self.trigger_sentences:
# No triggers registered # No triggers registered
return None return None
@@ -1451,7 +1457,7 @@ class DefaultAgent(ConversationEntity):
# Gather callback responses in parallel # Gather callback responses in parallel
trigger_callbacks = [ trigger_callbacks = [
self._triggers_details[trigger_id].callback(user_input, trigger_result) self.trigger_sentences[trigger_id].callback(user_input, trigger_result)
for trigger_id, trigger_result in result.matched_triggers.items() for trigger_id, trigger_result in result.matched_triggers.items()
] ]

View File

@@ -25,7 +25,7 @@ from .agent_manager import (
async_get_agent, async_get_agent,
get_agent_manager, get_agent_manager,
) )
from .const import DATA_COMPONENT from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
from .default_agent import ( from .default_agent import (
METADATA_CUSTOM_FILE, METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE, METADATA_CUSTOM_SENTENCE,
@@ -169,11 +169,11 @@ async def websocket_list_sentences(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None: ) -> None:
"""List custom registered sentences.""" """List custom registered sentences."""
manager = get_agent_manager(hass) agent = hass.data[DATA_DEFAULT_ENTITY]
sentences = [] sentences = []
for trigger_details in manager.triggers_details: for trigger_data in agent.trigger_sentences:
sentences.extend(trigger_details.sentences) sentences.extend(trigger_data.sentences)
connection.send_result(msg["id"], {"trigger_sentences": sentences}) connection.send_result(msg["id"], {"trigger_sentences": sentences})
@@ -191,8 +191,7 @@ async def websocket_hass_agent_debug(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None: ) -> None:
"""Return intents that would be matched by the default agent for a list of sentences.""" """Return intents that would be matched by the default agent for a list of sentences."""
agent = get_agent_manager(hass).default_agent agent = hass.data[DATA_DEFAULT_ENTITY]
assert agent is not None
# Return results for each sentence in the same order as the input. # Return results for each sentence in the same order as the input.
result_dicts: list[dict[str, Any] | None] = [] result_dicts: list[dict[str, Any] | None] = []

View File

@@ -1,9 +1,4 @@
{ {
"entity_component": {
"_": {
"default": "mdi:forum-outline"
}
},
"services": { "services": {
"process": { "process": {
"service": "mdi:message-processing" "service": "mdi:message-processing"

View File

@@ -4,7 +4,7 @@
"codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"], "codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
"dependencies": ["http", "intent"], "dependencies": ["http", "intent"],
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"] "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"]
} }

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